Skip to content

Commit 44b34d5

Browse files
committed
Add the Phase 7 lab policy console
1 parent 40bc132 commit 44b34d5

8 files changed

Lines changed: 474 additions & 8 deletions

File tree

frontend/src/App.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "./components";
1111
import { FindingsPanel } from "./Findings";
1212
import { Intelligence } from "./Intelligence";
13+
import { Lab } from "./Lab";
1314
import { RedPath } from "./RedPath";
1415
import { Reports } from "./Reports";
1516
import { formatBytes, formatDate } from "./format";
@@ -33,6 +34,7 @@ type Page =
3334
| "Findings"
3435
| "RedPath"
3536
| "Intelligence"
37+
| "Lab"
3638
| "RedLedger"
3739
| "Reports"
3840
| "Settings";
@@ -44,19 +46,21 @@ const pages: Page[] = [
4446
"Findings",
4547
"RedPath",
4648
"Intelligence",
49+
"Lab",
4750
"RedLedger",
4851
"Reports",
4952
"Settings",
5053
];
5154

52-
// Phase 6 adds deterministic reports and portable, hash-verifiable DockPacks.
55+
// Lab is available only as a policy console; the deployment gate stays outside the API.
5356
const availablePages = new Set<Page>([
5457
"Dashboard",
5558
"Dockyards",
5659
"Assets",
5760
"Findings",
5861
"RedPath",
5962
"Intelligence",
63+
"Lab",
6064
"RedLedger",
6165
"Reports",
6266
]);
@@ -183,6 +187,7 @@ export function App() {
183187
{page === "Findings" && <FindingsPage dockyards={dockyards} onError={setError} />}
184188
{page === "RedPath" && <RedPath dockyards={dockyards} onError={setError} />}
185189
{page === "Intelligence" && <Intelligence dockyards={dockyards} onError={setError} />}
190+
{page === "Lab" && <Lab dockyards={dockyards} onError={setError} />}
186191
{page === "RedLedger" && <LedgerPage dockyards={dockyards} onError={setError} />}
187192
{page === "Reports" && <Reports dockyards={dockyards} onError={setError} />}
188193
{!availablePages.has(page) && <Planned page={page} />}

frontend/src/Lab.test.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { afterEach, expect, it, vi } from "vitest";
4+
import { Lab } from "./Lab";
5+
6+
const acknowledgement =
7+
"I confirm this Dockyard is an isolated lab that I am authorized to test.";
8+
const dockyard = {
9+
id: 7,
10+
name: "Isolated range",
11+
description: "Loopback only",
12+
status: "draft",
13+
created_at: "2026-09-05T06:00:00Z",
14+
updated_at: "2026-09-05T06:00:00Z",
15+
};
16+
const capability = {
17+
id: "discovery.nmap.extended-service",
18+
title: "Extended TCP service discovery",
19+
description: "Fixed, bounded lab discovery.",
20+
risk: "lab" as const,
21+
single_host_only: true,
22+
};
23+
24+
afterEach(() => {
25+
cleanup();
26+
vi.unstubAllGlobals();
27+
});
28+
29+
function stubLabApi(enabled = true) {
30+
let authorizations: object[] = [];
31+
let audit: object[] = [];
32+
const calls: { authorize: unknown[]; revoke: unknown[] } = { authorize: [], revoke: [] };
33+
vi.stubGlobal(
34+
"fetch",
35+
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
36+
const path = new URL(String(input), "http://localhost").pathname;
37+
const json = (body: unknown, status = 200) =>
38+
Promise.resolve(new Response(JSON.stringify(body), { status }));
39+
if (path.endsWith("/lab/status")) {
40+
return json({
41+
deployment_enabled: enabled,
42+
acknowledgement,
43+
max_authorization_minutes: 120,
44+
capabilities: [capability],
45+
});
46+
}
47+
if (path.endsWith("/lab/audit")) return json(audit);
48+
if (path.endsWith("/lab/authorizations") && init?.method === "POST") {
49+
const body = JSON.parse(String(init.body));
50+
calls.authorize.push(body);
51+
const created = {
52+
id: 3,
53+
dockyard_id: 7,
54+
capability: capability.id,
55+
status: "active",
56+
acknowledgement,
57+
note: body.note,
58+
created_at: "2026-09-05T06:00:00Z",
59+
expires_at: "2026-09-05T07:00:00Z",
60+
revoked_at: null,
61+
};
62+
authorizations = [created];
63+
audit = [
64+
{
65+
id: 4,
66+
dockyard_id: 7,
67+
capability: capability.id,
68+
action: "authorize",
69+
decision: "allowed",
70+
reason: "Explicit authorization",
71+
authorization_id: 3,
72+
discovery_run_id: null,
73+
created_at: "2026-09-05T06:00:00Z",
74+
},
75+
];
76+
return json(created, 201);
77+
}
78+
if (path.endsWith("/revoke") && init?.method === "POST") {
79+
calls.revoke.push(JSON.parse(String(init.body)));
80+
const revoked = { ...authorizations[0], status: "revoked" };
81+
authorizations = [revoked];
82+
return json(revoked);
83+
}
84+
if (path.endsWith("/lab/authorizations")) return json(authorizations);
85+
return json({ detail: "Unexpected request" }, 500);
86+
}),
87+
);
88+
return calls;
89+
}
90+
91+
it("keeps authorization disabled when the deployment gate is closed", async () => {
92+
stubLabApi(false);
93+
render(<Lab dockyards={[dockyard]} onError={vi.fn()} />);
94+
expect(await screen.findByText("DEPLOYMENT DISABLED")).toBeInTheDocument();
95+
expect(screen.getByRole("button", { name: "Create temporary authorization" })).toBeDisabled();
96+
expect(screen.getByText(/The API cannot change this setting/)).toBeInTheDocument();
97+
});
98+
99+
it("sends the exact acknowledgement and can revoke the resulting grant", async () => {
100+
const calls = stubLabApi();
101+
const user = userEvent.setup();
102+
render(<Lab dockyards={[dockyard]} onError={vi.fn()} />);
103+
104+
await screen.findByText("DEPLOYMENT ENABLED");
105+
await user.type(screen.getByLabelText("Approval note"), "Authorized loopback range");
106+
await user.click(screen.getByLabelText(acknowledgement));
107+
await user.click(screen.getByRole("button", { name: "Create temporary authorization" }));
108+
109+
await screen.findByText("Authorization active");
110+
expect(calls.authorize).toEqual([
111+
{
112+
capability: capability.id,
113+
acknowledgement,
114+
note: "Authorized loopback range",
115+
duration_minutes: 60,
116+
},
117+
]);
118+
expect(screen.getByText("Explicit authorization")).toBeInTheDocument();
119+
120+
await user.click(screen.getByRole("button", { name: "Revoke now" }));
121+
await waitFor(() => expect(calls.revoke).toEqual([{}]));
122+
expect(await screen.findByText("Authorize temporarily")).toBeInTheDocument();
123+
});

0 commit comments

Comments
 (0)