feat(auth): include authentication in workspace and tht diagnostics
This commit is contained in:
@@ -8,6 +8,8 @@ import {
|
||||
getWorkspaceRuntimeConfiguration,
|
||||
listWorkspaces,
|
||||
saveWorkspaceSecrets,
|
||||
testWorkspace,
|
||||
validateWorkspace,
|
||||
} from "./workspaces";
|
||||
|
||||
const workspace = canonicalWorkspaceFixture("psd-clinical");
|
||||
@@ -98,3 +100,39 @@ test("forget targets one declared requirement", async () => {
|
||||
.resolves.toEqual(runtimeConfiguration);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
test("decodes the shared authentication diagnostics on static validation and live connection tests", async () => {
|
||||
const authentication = {
|
||||
ready: false,
|
||||
mode: "oidc" as const,
|
||||
checks: [{
|
||||
level: "error" as const,
|
||||
code: "oidc_mapped_group_missing" as const,
|
||||
field: "Thoth Administrators",
|
||||
message: "A configured authorization group does not exist.",
|
||||
}],
|
||||
};
|
||||
server.use(
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({
|
||||
workspace,
|
||||
contract: {},
|
||||
activatable: false,
|
||||
diagnostics: [],
|
||||
authentication,
|
||||
})),
|
||||
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||
activatable: false,
|
||||
diagnostics: [],
|
||||
authentication,
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(validateWorkspace(workspace)).resolves.toMatchObject({
|
||||
activatable: false,
|
||||
authentication,
|
||||
});
|
||||
await expect(testWorkspace("psd-clinical")).resolves.toMatchObject({
|
||||
activatable: false,
|
||||
authentication,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,9 +138,46 @@ export interface WorkspaceDiagnostic {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type AuthDiagnosticCode =
|
||||
| "auth_ready"
|
||||
| "auth_config_incomplete"
|
||||
| "auth_config_invalid"
|
||||
| "auth_session_store_invalid"
|
||||
| "local_user_registry_invalid"
|
||||
| "local_admin_missing"
|
||||
| "oidc_secret_missing"
|
||||
| "oidc_discovery_unreachable"
|
||||
| "oidc_issuer_mismatch"
|
||||
| "oidc_jwks_unreachable"
|
||||
| "oidc_group_catalog_unreachable"
|
||||
| "oidc_group_catalog_unauthorized"
|
||||
| "oidc_mapped_group_missing"
|
||||
| "oidc_mapped_group_ambiguous"
|
||||
| "oidc_groups_claim_invalid"
|
||||
| "oidc_device_flow_unavailable";
|
||||
|
||||
export interface AuthDiagnostic {
|
||||
level: "error" | "info";
|
||||
code: AuthDiagnosticCode;
|
||||
message: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export interface AuthDiagnostics {
|
||||
ready: boolean;
|
||||
mode: "local" | "oidc" | "upstream" | "none" | "mock";
|
||||
checks: AuthDiagnostic[];
|
||||
}
|
||||
|
||||
export interface WorkspaceDiagnostics {
|
||||
activatable: boolean;
|
||||
diagnostics: WorkspaceDiagnostic[];
|
||||
authentication: AuthDiagnostics;
|
||||
}
|
||||
|
||||
export interface WorkspaceValidationResult extends WorkspaceDiagnostics {
|
||||
workspace: CanonicalWorkspace;
|
||||
contract: unknown;
|
||||
}
|
||||
|
||||
export interface WorkspaceSecretRequirement {
|
||||
@@ -179,6 +216,62 @@ function object(value: unknown): Record<string, unknown> | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const authDiagnosticCodes = new Set<AuthDiagnosticCode>([
|
||||
"auth_ready", "auth_config_incomplete", "auth_config_invalid", "auth_session_store_invalid",
|
||||
"local_user_registry_invalid", "local_admin_missing", "oidc_secret_missing",
|
||||
"oidc_discovery_unreachable", "oidc_issuer_mismatch", "oidc_jwks_unreachable",
|
||||
"oidc_group_catalog_unreachable", "oidc_group_catalog_unauthorized", "oidc_mapped_group_missing",
|
||||
"oidc_mapped_group_ambiguous", "oidc_groups_claim_invalid", "oidc_device_flow_unavailable",
|
||||
]);
|
||||
|
||||
function text(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value);
|
||||
}
|
||||
|
||||
function decodeAuthentication(value: unknown): AuthDiagnostics {
|
||||
const source = object(value);
|
||||
if (!source || typeof source.ready !== "boolean"
|
||||
|| !["local", "oidc", "upstream", "none", "mock"].includes(String(source.mode))
|
||||
|| !Array.isArray(source.checks)) throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
const checks = source.checks.map((item): AuthDiagnostic => {
|
||||
const check = object(item);
|
||||
if (!check || (check.level !== "error" && check.level !== "info")
|
||||
|| typeof check.code !== "string" || !authDiagnosticCodes.has(check.code as AuthDiagnosticCode)
|
||||
|| !text(check.message) || (check.field !== undefined && !text(check.field))) {
|
||||
throw new Error("Workspace API returned invalid authentication diagnostics");
|
||||
}
|
||||
return {
|
||||
level: check.level,
|
||||
code: check.code as AuthDiagnosticCode,
|
||||
message: check.message,
|
||||
...(check.field === undefined ? {} : { field: check.field }),
|
||||
};
|
||||
});
|
||||
return { ready: source.ready, mode: source.mode as AuthDiagnostics["mode"], checks };
|
||||
}
|
||||
|
||||
function decodeWorkspaceDiagnostics(value: unknown): WorkspaceDiagnostics {
|
||||
const source = object(value);
|
||||
if (!source || typeof source.activatable !== "boolean" || !Array.isArray(source.diagnostics)) {
|
||||
throw new Error("Workspace API returned an invalid diagnostic result");
|
||||
}
|
||||
const diagnostics = source.diagnostics.map((item): WorkspaceDiagnostic => {
|
||||
const diagnostic = object(item);
|
||||
if (!diagnostic || (diagnostic.level !== "error" && diagnostic.level !== "warning" && diagnostic.level !== "info")
|
||||
|| typeof diagnostic.code !== "string" || !text(diagnostic.message)
|
||||
|| (diagnostic.field !== undefined && !text(diagnostic.field))) {
|
||||
throw new Error("Workspace API returned an invalid diagnostic result");
|
||||
}
|
||||
return {
|
||||
level: diagnostic.level,
|
||||
code: diagnostic.code as WorkspaceDiagnostic["code"],
|
||||
message: diagnostic.message,
|
||||
...(diagnostic.field === undefined ? {} : { field: diagnostic.field }),
|
||||
};
|
||||
});
|
||||
return { activatable: source.activatable, diagnostics, authentication: decodeAuthentication(source.authentication) };
|
||||
}
|
||||
|
||||
function exactObject(value: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
|
||||
const source = object(value);
|
||||
return source && Object.keys(source).every((key) => keys.includes(key)) ? source : undefined;
|
||||
@@ -278,18 +371,22 @@ export const getWorkspace = async (id: string): Promise<WorkspaceRecord> => {
|
||||
export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
|
||||
export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" });
|
||||
|
||||
export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
|
||||
export const validateWorkspace = async (workspace: CanonicalWorkspace): Promise<WorkspaceValidationResult> => {
|
||||
const safe = requireCanonicalWorkspace(workspace);
|
||||
const response = await apiFetch<unknown>("/workspaces/validate", {
|
||||
method: "POST", body: JSON.stringify({ workspace: safe }),
|
||||
});
|
||||
const source = object(response);
|
||||
if (!source) throw new Error("Workspace API returned an invalid validation result");
|
||||
return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract };
|
||||
return {
|
||||
workspace: requireCanonicalWorkspace(source.workspace),
|
||||
contract: source.contract,
|
||||
...decodeWorkspaceDiagnostics(source),
|
||||
};
|
||||
};
|
||||
|
||||
export const testWorkspace = (id: string) =>
|
||||
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
|
||||
export const testWorkspace = async (id: string): Promise<WorkspaceDiagnostics> =>
|
||||
decodeWorkspaceDiagnostics(await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }));
|
||||
|
||||
function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration {
|
||||
const source = exactObject(value, [
|
||||
|
||||
@@ -26,6 +26,12 @@ const requirement = {
|
||||
configured: false,
|
||||
};
|
||||
|
||||
const readyAuthentication = {
|
||||
ready: true,
|
||||
mode: "none",
|
||||
checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }],
|
||||
};
|
||||
|
||||
function runtimeConfiguration(configured = false) {
|
||||
return {
|
||||
workspaceId: "psd-clinical",
|
||||
@@ -187,10 +193,13 @@ test("workspace-specific commands remain isolated until a workspace is selected"
|
||||
test("keeps validation and connection results inside their respective action cards", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })),
|
||||
http.post("/api/workspaces/validate", () => HttpResponse.json({
|
||||
workspace, contract: {}, activatable: true, diagnostics: [], authentication: readyAuthentication,
|
||||
})),
|
||||
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||
activatable: false,
|
||||
diagnostics: [{ level: "error", code: "connector_unavailable", message: "Connector diagnostic failed." }],
|
||||
authentication: readyAuthentication,
|
||||
})),
|
||||
);
|
||||
renderManager();
|
||||
@@ -200,9 +209,9 @@ test("keeps validation and connection results inside their respective action car
|
||||
const connectionCard = screen.getByTestId("workspace-connection-card");
|
||||
await user.click(within(validationCard).getByRole("button", { name: "Validate workspace source" }));
|
||||
const validationStatus = await within(validationCard).findByRole("status");
|
||||
expect(validationStatus).toHaveTextContent("Workspace source is valid.");
|
||||
expect(validationStatus).toHaveTextContent("Workspace source and authentication are valid.");
|
||||
expect(validationStatus).toHaveClass("text-emerald-700");
|
||||
expect(within(connectionCard).queryByText("Workspace source is valid.")).not.toBeInTheDocument();
|
||||
expect(within(connectionCard).queryByText("Workspace source and authentication are valid.")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
|
||||
expect(await within(connectionCard).findByRole("alert")).toHaveTextContent(
|
||||
@@ -211,12 +220,43 @@ test("keeps validation and connection results inside their respective action car
|
||||
expect(within(validationCard).queryByText("connector_unavailable: Connector diagnostic failed.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders one authentication section with configured-group errors and no unmapped-group list", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({
|
||||
workspace,
|
||||
contract: {},
|
||||
activatable: false,
|
||||
diagnostics: [],
|
||||
authentication: {
|
||||
ready: false,
|
||||
mode: "oidc",
|
||||
checks: [{
|
||||
level: "error",
|
||||
code: "oidc_mapped_group_missing",
|
||||
field: "Thoth Administrators",
|
||||
message: "A configured authorization group does not exist.",
|
||||
}],
|
||||
},
|
||||
})));
|
||||
renderManager();
|
||||
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Validate workspace source" }));
|
||||
|
||||
const section = await screen.findByTestId("workspace-authentication");
|
||||
expect(within(section).getByRole("heading", { name: "Authentication" })).toBeVisible();
|
||||
expect(within(section).getByText("Failed")).toBeVisible();
|
||||
expect(within(section).getByText("oidc_mapped_group_missing: Thoth Administrators — A configured authorization group does not exist.")).toBeVisible();
|
||||
expect(within(section).queryByText(/unmapped/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders binding_ok as a green connection success", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
|
||||
activatable: true,
|
||||
diagnostics: [{ level: "info", code: "binding_ok", message: "Installation bindings and diagnostics succeeded." }],
|
||||
authentication: readyAuthentication,
|
||||
})),
|
||||
);
|
||||
renderManager();
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
saveWorkspaceSecrets,
|
||||
testWorkspace,
|
||||
validateWorkspace,
|
||||
type AuthDiagnostics,
|
||||
type WorkspaceRuntimeConfiguration,
|
||||
} from "../api/workspaces";
|
||||
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation";
|
||||
@@ -81,6 +82,7 @@ export function WorkspaceManager({
|
||||
const [validationDiagnostics, setValidationDiagnostics] = useState<string[]>([]);
|
||||
const [connectionNotice, setConnectionNotice] = useState<string>();
|
||||
const [connectionDiagnostics, setConnectionDiagnostics] = useState<string[]>([]);
|
||||
const [authentication, setAuthentication] = useState<AuthDiagnostics>();
|
||||
const [busyAction, setBusyAction] = useState<string>();
|
||||
const operationEpochRef = useRef(0);
|
||||
const selectedIdRef = useRef(selectedId);
|
||||
@@ -135,6 +137,7 @@ export function WorkspaceManager({
|
||||
setValidationDiagnostics([]);
|
||||
setConnectionNotice(undefined);
|
||||
setConnectionDiagnostics([]);
|
||||
setAuthentication(undefined);
|
||||
};
|
||||
|
||||
const clearGlobalMessages = () => {
|
||||
@@ -194,9 +197,10 @@ export function WorkspaceManager({
|
||||
setValidationNotice(undefined);
|
||||
setValidationDiagnostics([]);
|
||||
try {
|
||||
await validateWorkspace(detailQuery.data.workspace);
|
||||
const result = await validateWorkspace(detailQuery.data.workspace);
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
setValidationNotice("Workspace source is valid.");
|
||||
setAuthentication(result.authentication);
|
||||
setValidationNotice(result.activatable ? "Workspace source and authentication are valid." : "Workspace source is valid.");
|
||||
} catch (error) {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
|
||||
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
|
||||
@@ -218,6 +222,7 @@ export function WorkspaceManager({
|
||||
try {
|
||||
const result = await testWorkspace(selectedId);
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
|
||||
setAuthentication(result.authentication);
|
||||
const issues = result.diagnostics.filter(({ level }) => level !== "info");
|
||||
const informational = result.diagnostics.find(({ level }) => level === "info");
|
||||
setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`));
|
||||
@@ -480,6 +485,23 @@ export function WorkspaceManager({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="font-heading font-semibold">Authentication</h4>
|
||||
<span className={authentication.ready ? "text-sm font-medium text-emerald-700" : "text-sm font-medium text-amber-700"}>
|
||||
{authentication.ready ? "Passed" : "Failed"}
|
||||
</span>
|
||||
</div>
|
||||
{!authentication.ready && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (
|
||||
<p key={`${code}:${field ?? ""}`} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />
|
||||
{code}: {field === undefined ? message : `${field} — ${message}`}
|
||||
</p>
|
||||
))}
|
||||
</div>}
|
||||
</section>}
|
||||
|
||||
{canManageSecrets && <div className="rounded-lg border border-border p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<KeyRound className="mt-0.5 size-5 text-primary" />
|
||||
|
||||
Reference in New Issue
Block a user