diff --git a/frontend/src/auth/msalConfig.test.ts b/frontend/src/auth/msalConfig.test.ts index bda21bf5fa..2ed9472849 100644 --- a/frontend/src/auth/msalConfig.test.ts +++ b/frontend/src/auth/msalConfig.test.ts @@ -73,22 +73,24 @@ describe("msalConfig", () => { expect(global.fetch).toHaveBeenCalledWith("/api/auth/config"); }); - it("returns empty config when response is not ok", async () => { - (global.fetch as jest.Mock).mockResolvedValue({ ok: false }); + it("throws when response is not ok (transient failure is not auth-disabled)", async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }); const { fetchAuthConfig } = await import("./msalConfig"); - const result = await fetchAuthConfig(); - expect(result).toEqual({ clientId: "", tenantId: "", allowedGroupIds: "" }); + await expect(fetchAuthConfig()).rejects.toThrow("/api/auth/config returned 503 Service Unavailable"); }); - it("returns empty config on network error", async () => { + it("throws on network error (transient failure is not auth-disabled)", async () => { (global.fetch as jest.Mock).mockRejectedValue(new Error("Network error")); const { fetchAuthConfig } = await import("./msalConfig"); - const result = await fetchAuthConfig(); - expect(result).toEqual({ clientId: "", tenantId: "", allowedGroupIds: "" }); + await expect(fetchAuthConfig()).rejects.toThrow("Failed to reach /api/auth/config"); }); }); }); diff --git a/frontend/src/auth/msalConfig.ts b/frontend/src/auth/msalConfig.ts index c63e3a68cf..1d4a70b121 100644 --- a/frontend/src/auth/msalConfig.ts +++ b/frontend/src/auth/msalConfig.ts @@ -24,17 +24,23 @@ export interface AuthConfig { } export async function fetchAuthConfig(): Promise { + let response: Response try { - const response = await fetch('/api/auth/config') - if (!response.ok) { - // Auth endpoint not available — treat as auth disabled - return { clientId: '', tenantId: '', allowedGroupIds: '' } - } - return (await response.json()) as AuthConfig - } catch { - // Network error (e.g., backend not running yet) — treat as auth disabled - return { clientId: '', tenantId: '', allowedGroupIds: '' } + response = await fetch('/api/auth/config') + } catch (e) { + // A network error (e.g., backend not running yet) is a transient + // infrastructure failure, not proof that auth is disabled. Surface it so + // AuthProvider can show its error state instead of rendering the shell + // while protected APIs return 401. + throw Object.assign(new Error(`Failed to reach /api/auth/config: ${e instanceof Error ? e.message : String(e)}`), { + cause: e, + }) } + if (!response.ok) { + // HTTP-level failures on the config endpoint are equally inconclusive. + throw new Error(`/api/auth/config returned ${response.status} ${response.statusText}`) + } + return (await response.json()) as AuthConfig } export function buildMsalConfig(authConfig: AuthConfig): Configuration { diff --git a/pyrit/scenario/core/matrix_atomic_attack_builder.py b/pyrit/scenario/core/matrix_atomic_attack_builder.py index 40a35ca474..34787b248b 100644 --- a/pyrit/scenario/core/matrix_atomic_attack_builder.py +++ b/pyrit/scenario/core/matrix_atomic_attack_builder.py @@ -142,7 +142,7 @@ def resolve_technique_factories( Reads the ``AttackTechniqueRegistry`` singleton and keeps only the factories whose name matches a selected technique, preserving selection order. Techniques with no registered - factory are silently dropped so the caller can proceed with whatever techniques exist. + factory are logged and dropped so the caller can proceed with whatever techniques exist. Args: context (ScenarioContext): The resolved runtime inputs for this run. @@ -160,6 +160,9 @@ def resolve_technique_factories( all_factories = dict(AttackTechniqueRegistry.get_registry_singleton().get_factories_or_raise()) if extra_factories: all_factories.update(extra_factories) + missing = [technique.value for technique in context.scenario_techniques if technique.value not in all_factories] + if missing: + logger.warning(f"Selected techniques have no registered factory and will be skipped: {missing}") return { technique.value: all_factories[technique.value] for technique in context.scenario_techniques diff --git a/tests/unit/scenario/core/test_matrix_atomic_attack_builder.py b/tests/unit/scenario/core/test_matrix_atomic_attack_builder.py index 044ce78512..2d2c78ddc5 100644 --- a/tests/unit/scenario/core/test_matrix_atomic_attack_builder.py +++ b/tests/unit/scenario/core/test_matrix_atomic_attack_builder.py @@ -15,6 +15,7 @@ * optional baseline emission from the flattened seed groups. """ +import logging from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -408,6 +409,17 @@ def test_drops_techniques_without_factory(self): resolved = resolve_technique_factories(context=context) assert list(resolved.keys()) == ["alpha"] + def test_warns_when_selected_technique_has_no_factory(self, caplog: pytest.LogCaptureFixture) -> None: + factories = {"alpha": _mock_factory(name="alpha")} + context = _context(techniques=[_technique("alpha"), _technique("missing")]) + with ( + _patch_registry(factories), + caplog.at_level(logging.WARNING, logger="pyrit.scenario.core.matrix_atomic_attack_builder"), + ): + resolved = resolve_technique_factories(context=context) + assert list(resolved.keys()) == ["alpha"] + assert any("missing" in r.getMessage() for r in caplog.records) + def test_extra_factories_merged_and_override_registry(self): registry_factories = {"alpha": _mock_factory(name="alpha")} local_alpha = _mock_factory(name="alpha")