Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions frontend/src/auth/msalConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
24 changes: 15 additions & 9 deletions frontend/src/auth/msalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,23 @@ export interface AuthConfig {
}

export async function fetchAuthConfig(): Promise<AuthConfig> {
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 {
Expand Down
5 changes: 4 additions & 1 deletion pyrit/scenario/core/matrix_atomic_attack_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/scenario/core/test_matrix_atomic_attack_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* optional baseline emission from the flattened seed groups.
"""

import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -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")
Expand Down