diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ef665f03..f50fc9ea7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,4 +131,4 @@ jobs: # direct-DB suites (mailbox persist/send) are not order-dependent on some # hub-booting suite migrating first. - run: bun scripts/db-setup.ts - - run: bun test e2e/grants e2e/hub e2e/install e2e/workflows + - run: bun test e2e/hub e2e/install e2e/workflows diff --git a/apps/web/src/chat/agent-redeploy.test.ts b/apps/web/src/chat/agent-redeploy.test.ts deleted file mode 100644 index 2826b74fe..000000000 --- a/apps/web/src/chat/agent-redeploy.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { agentsToAutoDeploy, stoppedAgents } from "./agent-redeploy"; -import { redeployMode, workerState } from "./deployment-liveness"; -import type { WorkbenchParticipant } from "./threads-api"; -import { workerStatus } from "../pages/workers-page"; - -const agent = (over: Partial): WorkbenchParticipant => ({ - id: "a1", - kind: "agent", - name: "Worker", - address: "", - assetName: "worker", - ...over, -}); - -describe("agent redeploy selection", () => { - test("an agent marked auto is redeployed on its own", () => { - const released = agent({ redeploy: "auto" }); - expect(agentsToAutoDeploy([released]).map((a) => a.id)).toEqual([released.id]); - expect(stoppedAgents([released])).toEqual([]); - }); - - test("an agent marked manual waits for Restart", () => { - const capped = agent({ redeploy: "manual" }); - expect(agentsToAutoDeploy([capped])).toEqual([]); - expect(stoppedAgents([capped]).map((a) => a.id)).toEqual([capped.id]); - }); - - test("a coming-up or live agent is neither", () => { - for (const other of [agent({}), agent({ address: "worker@bench.example" })]) { - expect(agentsToAutoDeploy([other])).toEqual([]); - expect(stoppedAgents([other])).toEqual([]); - } - }); -}); - -describe("workers list and chat agree", () => { - const shapes = [ - { liveAddress: "w@bench.example", latest: "live", capped: false }, - { liveAddress: null, latest: "starting", capped: false }, - { liveAddress: null, latest: "stopped", capped: false }, - { liveAddress: null, latest: "stopped", capped: true }, - ] as const; - - for (const shape of shapes) { - test(`${shape.latest} live=${shape.liveAddress !== null} capped=${shape.capped}`, () => { - const stopped = redeployMode(shape) === "manual"; - expect(workerStatus(shape).tone === "ready").toBe(stopped); - expect(workerState(shape) === "stopped").toBe(stopped); - }); - } -}); diff --git a/apps/web/src/chat/redeploy-failures.test.ts b/apps/web/src/chat/redeploy-failures.test.ts deleted file mode 100644 index f3978b51a..000000000 --- a/apps/web/src/chat/redeploy-failures.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { claimAutoRedeploy, releaseAutoRedeploy } from "./redeploy-failures"; - -describe("auto-redeploy claim", () => { - test("only the first surface claims an asset until it settles", () => { - expect(claimAutoRedeploy("asset-x")).toBe(true); - expect(claimAutoRedeploy("asset-x")).toBe(false); - expect(claimAutoRedeploy("asset-y")).toBe(true); - releaseAutoRedeploy("asset-x"); - releaseAutoRedeploy("asset-y"); - expect(claimAutoRedeploy("asset-x")).toBe(true); - releaseAutoRedeploy("asset-x"); - }); -}); diff --git a/apps/web/src/provider-skip.test.ts b/apps/web/src/provider-skip.test.ts deleted file mode 100644 index 85728c572..000000000 --- a/apps/web/src/provider-skip.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import type { ModelInfo } from "@intx/types"; - -import { - clearProviderSkipWhenUsable, - isProviderSkipped, - setProviderSkipped, -} from "./provider-skip"; - -const store = new Map(); - -function model(offerings: number): ModelInfo { - return { offerings: Array.from({ length: offerings }, () => ({})) } as unknown as ModelInfo; -} - -beforeEach(() => { - store.clear(); - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - value: { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => void store.set(key, value), - removeItem: (key: string) => void store.delete(key), - }, - }); -}); - -afterEach(() => { - Reflect.deleteProperty(globalThis, "localStorage"); -}); - -describe("clearProviderSkipWhenUsable", () => { - test("clears the skip flag once a model has an offering", () => { - setProviderSkipped("u1", true); - clearProviderSkipWhenUsable("u1", [model(1)]); - expect(isProviderSkipped("u1")).toBe(false); - }); - - test("keeps the skip flag while no model has an offering", () => { - setProviderSkipped("u1", true); - clearProviderSkipWhenUsable("u1", [model(0)]); - expect(isProviderSkipped("u1")).toBe(true); - }); -}); diff --git a/apps/web/src/settings-groups.tsx b/apps/web/src/settings-groups.tsx index ed5480689..838bc7ef8 100644 --- a/apps/web/src/settings-groups.tsx +++ b/apps/web/src/settings-groups.tsx @@ -6,7 +6,7 @@ import { insertEveryoneSections, resolveSettingsSectionGroups } from "@/settings import type { SettingsSectionGroup, TenancyAccess } from "@/settings"; import { InsightsRoute } from "./pages/insights-page"; -import { SETTINGS_INSIGHTS_PATH } from "./settings-insights-path"; +import { INSIGHTS_RUNS_PATH } from "./path-ids"; export function resolveAppSettingsSectionGroups( access: TenancyAccess, @@ -17,7 +17,11 @@ export function resolveAppSettingsSectionGroups( id: "insights", title: "Insights", icon: ChartBar, - render: () => , + // InsightsRoute falls back to the browser URL when given no path, and + // the dialog's own `/settings/insights` parses as an Insights landing + // page — landing renders a redirect to a top-level route, which unmounts + // the dialog. Hand it the runs history instead: that mode renders inline. + render: () => , }, ]); } diff --git a/apps/web/src/settings-insights-path.test.ts b/apps/web/src/settings-insights-path.test.ts deleted file mode 100644 index 3955bf02c..000000000 --- a/apps/web/src/settings-insights-path.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parseInsightsPath } from "./insights-path"; -import { SETTINGS_INSIGHTS_PATH } from "./settings-insights-path"; -import type { TenancyAccess } from "./settings/access"; - -describe("settings-hosted insights path", () => { - test("renders the runs history inline instead of landing-redirecting away", () => { - expect(parseInsightsPath(SETTINGS_INSIGHTS_PATH).mode).toBe("runs"); - }); - - test("the settings URL itself is an insights landing page, so the dialog must never hand it to the route", () => { - expect(parseInsightsPath("/settings/insights").mode).toBe("landing"); - }); - - test("the embedded insights section gets an explicit non-landing path, never the host URL fallback", async () => { - // The section's module chain reads `window.location` at import time, and - // bun's runner has no DOM — stub just enough of it to evaluate, then - // inspect the returned element's props instead of mounting. - Object.assign(globalThis, { - window: { - location: { pathname: "/settings/insights" }, - history: { replaceState: () => {} }, - addEventListener: () => {}, - removeEventListener: () => {}, - }, - }); - const { resolveAppSettingsSectionGroups } = await import("./settings-groups"); - const access: TenancyAccess = { - people: "allowed", - roles: "allowed", - grants: "allowed", - credentials: "allowed", - }; - const sections = resolveAppSettingsSectionGroups(access).flatMap((group) => group.sections); - const insights = sections.find((section) => section.id === "insights"); - if (insights === undefined) throw new Error("insights section missing from settings groups"); - const { path, embedded } = insights.render({ tenantId: null, principalId: null }).props as { - readonly path?: unknown; - readonly embedded?: unknown; - }; - if (typeof path !== "string") { - throw new Error("insights section must hand the embedded route an explicit path"); - } - expect(parseInsightsPath(path).mode).not.toBe("landing"); - // The embedded route must suppress its full-page chrome (crumb links, - // run-row top-level navigation, sidebar toggle), or clicking any of them - // navigates the host app out from under the dialog. - expect(embedded).toBe(true); - }); -}); diff --git a/apps/web/src/settings-insights-path.ts b/apps/web/src/settings-insights-path.ts deleted file mode 100644 index 976bd4750..000000000 --- a/apps/web/src/settings-insights-path.ts +++ /dev/null @@ -1,9 +0,0 @@ -// The path the Settings dialog hands its embedded Insights route. The route -// falls back to the browser URL when it gets no path, and the dialog's own -// `/settings/insights` parses as an Insights landing page — landing renders a -// redirect to a top-level route, which unmounts the dialog. Hand it the runs -// history instead: that mode renders inline and never navigates away. - -import { INSIGHTS_RUNS_PATH } from "./path-ids"; - -export const SETTINGS_INSIGHTS_PATH = INSIGHTS_RUNS_PATH; diff --git a/apps/web/src/settings/grant-preview.test.ts b/apps/web/src/settings/grant-preview.test.ts deleted file mode 100644 index 61870f7b8..000000000 --- a/apps/web/src/settings/grant-preview.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { expiryIsoFromPreset, expiryLabelFromPreset, grantPreviewSentence } from "./grant-preview"; - -describe("grantPreviewSentence", () => { - test("an allow reads as permission for the named target", () => { - expect( - grantPreviewSentence({ - targetLabel: "Billing", - resource: "credential", - action: "use", - effect: "allow", - expiresLabel: null, - }), - ).toBe("Billing may use on credential."); - }); - - test("a deny reads as refusal, and an ask as a checkpoint", () => { - expect( - grantPreviewSentence({ - targetLabel: "Deploy bot", - resource: "workflow-run", - action: "create", - effect: "deny", - expiresLabel: null, - }), - ).toBe("Deploy bot must not create on workflow-run."); - expect( - grantPreviewSentence({ - targetLabel: "Deploy bot", - resource: "workflow-run", - action: "create", - effect: "ask", - expiresLabel: null, - }), - ).toBe("Deploy bot must ask before they can create on workflow-run."); - }); - - test("a missing target falls back to Someone, and an expiry extends the sentence", () => { - expect( - grantPreviewSentence({ - targetLabel: null, - resource: "credential", - action: "use", - effect: "allow", - expiresLabel: null, - }), - ).toBe("Someone may use on credential."); - expect( - grantPreviewSentence({ - targetLabel: " ", - resource: "credential", - action: "use", - effect: "allow", - expiresLabel: "in 7 days", - }), - ).toBe("Someone may use on credential, until in 7 days."); - }); -}); - -describe("expiry presets", () => { - test("never means no expiry in both projections", () => { - expect(expiryIsoFromPreset("never")).toBeNull(); - expect(expiryLabelFromPreset("never")).toBeNull(); - }); - - test("a 7d preset lands exactly seven days after now", () => { - const now = new Date("2026-02-10T00:00:00.000Z"); - expect(expiryIsoFromPreset("7d", now)).toBe("2026-02-17T00:00:00.000Z"); - expect(expiryLabelFromPreset("7d")).toBe("in 7 days"); - }); -}); diff --git a/apps/web/src/settings/provider-models.test.ts b/apps/web/src/settings/provider-models.test.ts deleted file mode 100644 index 57214c161..000000000 --- a/apps/web/src/settings/provider-models.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { PROVIDER_MODELS } from "./provider-models"; - -// Mirrors `grok models` on Grok Build CLI 1.0.46 (unauthenticated, served by -// the same cli-chat-proxy.grok.com the xai provider option mints offerings -// against): the default first, then the rest of the listed catalog. -const GROK_CLI_1_0_46_CATALOG = [ - { id: "grok-4.7", label: "Grok 4.7" }, - { id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" }, - { id: "grok-4.6", label: "Grok 4.6" }, - { id: "grok-4.5", label: "Grok 4.5" }, -] as const; - -describe("xai provider models", () => { - test("matches the Grok Build CLI 1.0.46 proxy catalog, default first", () => { - expect(PROVIDER_MODELS["xai"]).toEqual([...GROK_CLI_1_0_46_CATALOG]); - }); -}); diff --git a/apps/web/src/tools/mcp-server-icons.test.ts b/apps/web/src/tools/mcp-server-icons.test.ts deleted file mode 100644 index d774bb624..000000000 --- a/apps/web/src/tools/mcp-server-icons.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { MCP_SERVER_CATALOG } from "@corbits/worker/workflow-ids"; -import { Captions, GitPullRequest, MagnifyingGlass, Plugs } from "@/lib/icons"; - -import { mcpServerIcon } from "./mcp-server-icons"; - -describe("mcpServerIcon", () => { - test("popular servers wear their own mark", () => { - expect(mcpServerIcon("exa")).toBe(MagnifyingGlass); - expect(mcpServerIcon("linear")).toBe(GitPullRequest); - expect(mcpServerIcon("granola")).toBe(Captions); - }); - - test("unknown handles fall back to the plugs mark", () => { - expect(mcpServerIcon("some-custom-server")).toBe(Plugs); - expect(mcpServerIcon("")).toBe(Plugs); - }); - - test("every platform catalog entry has its own mark", () => { - for (const entry of MCP_SERVER_CATALOG) { - expect(mcpServerIcon(entry.handle)).not.toBe(Plugs); - } - }); -}); diff --git a/apps/web/src/tools/tool-count.test.ts b/apps/web/src/tools/tool-count.test.ts deleted file mode 100644 index 629efaaef..000000000 --- a/apps/web/src/tools/tool-count.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { toolCountLabel } from "./tool-count"; - -describe("toolCountLabel", () => { - test("singular for one tool", () => { - expect(toolCountLabel(1)).toBe("1 tool"); - }); - - test("plural otherwise", () => { - expect(toolCountLabel(0)).toBe("0 tools"); - expect(toolCountLabel(3)).toBe("3 tools"); - }); -}); diff --git a/e2e/grants/grants-not-walkable.test.ts b/e2e/grants/grants-not-walkable.test.ts deleted file mode 100644 index a32e68a94..000000000 --- a/e2e/grants/grants-not-walkable.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Proves the tenant-tree rule over stock routes: credentials resolve down -// from an ancestor tenant, grants never do. Booting the hub runs package -// migrations, so the suite skips without a reachable DATABASE_URL. - -import { afterAll, expect, test } from "bun:test"; -import { type } from "arktype"; -import { dbGate } from "../lib/db-gate"; -import { bootHub } from "../lib/hub"; -import { cleanupTenants } from "../lib/tenant-cleanup"; - -const describeIfDb = dbGate(process.env["DATABASE_URL"] ?? "", import.meta.path); - -const Id = type({ id: "string" }); -const GrantPage = type({ data: type({ id: "string" }).array() }); -const Evaluation = type({ effect: "string", matchingGrants: type({ id: "string" }).array() }); - -describeIfDb("grants are explicit per tenant", () => { - const booted = bootHub(); - const seededTenantIds: string[] = []; - afterAll(async () => { - await cleanupTenants(seededTenantIds); - }); - - test("a credential resolves from a child tenant; a grant does not", async () => { - const hub = booted(); - const origin = "http://localhost"; - const suffix = crypto.randomUUID().slice(0, 8); - - async function call(path: string, cookie: string, method = "GET", body?: unknown) { - return hub.fetch( - new Request(`${origin}${path}`, { - method, - headers: { - "content-type": "application/json", - origin, - ...(cookie !== "" ? { cookie } : {}), - }, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }), - ); - } - - async function signUp(name: string): Promise<{ email: string; cookie: string }> { - const email = `${name}-${suffix}@example.com`; - const res = await call("/api/auth/sign-up/email", "", "POST", { - email, - password: "password123", - name, - }); - expect(res.status).toBe(200); - const cookie = res.headers - .getSetCookie() - .map((c) => c.split(";")[0]) - .join("; "); - return { email, cookie }; - } - - async function created(res: Response) { - expect(res.status).toBe(201); - return Id.assert(await res.json()).id; - } - - const alice = await signUp("alice"); - const bob = await signUp("bob"); - - const workspace = await created( - await call("/api/tenants", alice.cookie, "POST", { - name: "Workspace", - slug: `ws-${suffix}`, - }), - ); - seededTenantIds.push(workspace); - const bench = await created( - await call("/api/tenants", alice.cookie, "POST", { - name: "Bench", - slug: `bench-${suffix}`, - parentId: workspace, - }), - ); - seededTenantIds.push(bench); - - // Bob is a member of both tenants with no role: his only authority is - // whatever grant is assigned to his principal in that tenant. - const bobInWorkspace = await created( - await call(`/api/tenants/${workspace}/members/invite`, alice.cookie, "POST", { - email: bob.email, - }), - ); - const bobInBench = await created( - await call(`/api/tenants/${bench}/members/invite`, alice.cookie, "POST", { - email: bob.email, - }), - ); - - // Credentials walk down the tree. - const provider = await created( - await call(`/api/tenants/${workspace}/providers`, alice.cookie, "POST", { - name: "example", - plugin: "example", - }), - ); - const credential = await created( - await call(`/api/tenants/${workspace}/credentials`, alice.cookie, "POST", { - providerId: provider, - name: "shared-key", - type: "api_key", - secret: "not-a-real-secret", - }), - ); - const resolved = await call( - `/api/tenants/${bench}/credentials/resolve/shared-key`, - alice.cookie, - ); - expect(resolved.status).toBe(200); - expect(Id.assert(await resolved.json()).id).toBe(credential); - - // A grant assigned to bob's workspace principal is effective there. - await created( - await call(`/api/tenants/${workspace}/grants`, alice.cookie, "POST", { - principalId: bobInWorkspace, - resource: "credential:*", - action: "use", - effect: "allow", - origin: "role", - }), - ); - const evaluate = (tenant: string, principal: string) => - call(`/api/tenants/${tenant}/principals/${principal}/evaluate`, alice.cookie, "POST", { - resource: `credential:${credential}`, - action: "use", - }); - const inWorkspace = await evaluate(workspace, bobInWorkspace); - expect(inWorkspace.status).toBe(200); - expect(Evaluation.assert(await inWorkspace.json()).effect).toBe("allow"); - - // It is not effective in the child: the child lists no such grant, bob's - // child principal is denied, and the workspace principal is unknown there. - const listed = await call( - `/api/tenants/${bench}/grants?principalId=${bobInWorkspace}`, - alice.cookie, - ); - expect(listed.status).toBe(200); - expect(GrantPage.assert(await listed.json()).data).toEqual([]); - - const inBench = await evaluate(bench, bobInBench); - expect(inBench.status).toBe(200); - const benchResult = Evaluation.assert(await inBench.json()); - expect(benchResult.effect).toBe("deny"); - expect(benchResult.matchingGrants).toEqual([]); - - expect((await evaluate(bench, bobInWorkspace)).status).toBe(404); - }); -}); diff --git a/e2e/grants/roles-assign-evaluate.test.ts b/e2e/grants/roles-assign-evaluate.test.ts deleted file mode 100644 index a9bace5fc..000000000 --- a/e2e/grants/roles-assign-evaluate.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -// Walks the Settings roles flow over stock routes: create a role, target a -// grant at it, assign it to a person AND to an agent principal, then prove -// both evaluations allow while an unassigned member stays denied. The agent -// principal is seeded straight into the database to stand in for first-run -// minting (agent/workflow principals only exist after their first run, never -// at deploy time), and the closing 404s pin the contract the Settings -// assignment picker keys its first-run guidance off. - -import { afterAll, expect, test } from "bun:test"; -import { type } from "arktype"; - -import { createDB } from "@intx/db"; -import { principal } from "@intx/db/schema"; -import { generateId } from "@intx/hub-common"; -import { e2eDatabaseUrl } from "../lib/database-url"; -import { dbGate } from "../lib/db-gate"; -import { bootHub } from "../lib/hub"; -import { cleanupTenants } from "../lib/tenant-cleanup"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -function dbConfigFromUrl(url: string) { - const parsed = new URL(url); - return { - host: parsed.hostname, - port: parsed.port === "" ? 5432 : Number(parsed.port), - user: decodeURIComponent(parsed.username), - password: decodeURIComponent(parsed.password), - database: parsed.pathname.replace(/^\//, ""), - }; -} - -const Id = type({ id: "string" }); -const Evaluation = type({ - effect: "string", - matchingGrants: type({ id: "string" }).array(), -}); -const PrincipalDetail = type({ - id: "string", - kind: "string", - roles: type({ id: "string", name: "string" }).array(), -}); -const PrincipalPage = type({ - data: type({ id: "string", kind: "string" }).array(), -}); - -describeIfDb("settings roles flow allows people and agents alike", () => { - const booted = bootHub(); - const closers: (() => Promise)[] = []; - const seededTenantIds: string[] = []; - afterAll(async () => { - for (const close of closers) await close(); - await cleanupTenants(seededTenantIds); - }); - - test("create-role, grant, assign (person and agent), evaluate", async () => { - if (databaseUrl === undefined) return; - const hub = booted(); - const origin = "http://localhost"; - const suffix = crypto.randomUUID().slice(0, 8); - const resource = "billing:invoice"; - const action = "refund"; - - async function call(path: string, cookie: string, method = "GET", body?: unknown) { - return hub.fetch( - new Request(`${origin}${path}`, { - method, - headers: { - "content-type": "application/json", - origin, - ...(cookie !== "" ? { cookie } : {}), - }, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }), - ); - } - - async function signUp(name: string): Promise<{ email: string; cookie: string }> { - const email = `${name}-${suffix}@example.com`; - const res = await call("/api/auth/sign-up/email", "", "POST", { - email, - password: "password123", - name, - }); - expect(res.status).toBe(200); - const cookie = res.headers - .getSetCookie() - .map((c) => c.split(";")[0]) - .join("; "); - return { email, cookie }; - } - - async function created(res: Response): Promise { - expect(res.status).toBe(201); - return Id.assert(await res.json()).id; - } - - const alice = await signUp("alice"); - const bob = await signUp("bob"); - const charlie = await signUp("charlie"); - - // Settings "New role" dialog. - const tenant = await created( - await call("/api/tenants", alice.cookie, "POST", { - name: "Workspace", - slug: `ws-${suffix}`, - }), - ); - seededTenantIds.push(tenant); - const role = await created( - await call(`/api/tenants/${tenant}/roles`, alice.cookie, "POST", { - name: `Billing ${suffix}`, - description: "May refund billing invoices", - }), - ); - - // Settings "New rule" dialog targeting the role. - const grant = await created( - await call(`/api/tenants/${tenant}/grants`, alice.cookie, "POST", { - roleId: role, - resource, - action, - effect: "allow", - origin: "role", - }), - ); - - // Person leg: invite Bob, assign the role, prove the grant reaches him. - const bobPrincipal = await created( - await call(`/api/tenants/${tenant}/members/invite`, alice.cookie, "POST", { - email: bob.email, - }), - ); - expect( - ( - await call( - `/api/tenants/${tenant}/principals/${bobPrincipal}/roles/${role}`, - alice.cookie, - "POST", - ) - ).status, - ).toBe(204); - - const bobDetail = await call(`/api/tenants/${tenant}/principals/${bobPrincipal}`, alice.cookie); - expect(bobDetail.status).toBe(200); - expect(PrincipalDetail.assert(await bobDetail.json()).roles.map((r) => r.id)).toContain(role); - - // Agent leg: the run path mints this row on first delivery; the test - // stands that minting in directly, then drives the same assignment and - // evaluation routes the Settings picker uses. - const agentPrincipal = generateId("principal"); - const handle = createDB(dbConfigFromUrl(databaseUrl)); - closers.push(handle.close); - await handle.db.insert(principal).values({ - id: agentPrincipal, - tenantId: tenant, - kind: "workflow", - refId: generateId("workflowRun"), - status: "active", - }); - expect( - ( - await call( - `/api/tenants/${tenant}/principals/${agentPrincipal}/roles/${role}`, - alice.cookie, - "POST", - ) - ).status, - ).toBe(204); - - // The assignment picker reads the tenant-wide principal list, so the - // minted agent principal must show up there next to people. - const listed = await call(`/api/tenants/${tenant}/principals?kind=workflow`, alice.cookie); - expect(listed.status).toBe(200); - expect(PrincipalPage.assert(await listed.json()).data.map((p) => p.id)).toContain( - agentPrincipal, - ); - - async function effectOf(principalId: string) { - const res = await call( - `/api/tenants/${tenant}/principals/${principalId}/evaluate`, - alice.cookie, - "POST", - { resource, action }, - ); - expect(res.status).toBe(200); - return Evaluation.assert(await res.json()); - } - - const bobResult = await effectOf(bobPrincipal); - expect(bobResult.effect).toBe("allow"); - expect(bobResult.matchingGrants.map((g) => g.id)).toContain(grant); - - const agentResult = await effectOf(agentPrincipal); - expect(agentResult.effect).toBe("allow"); - expect(agentResult.matchingGrants.map((g) => g.id)).toContain(grant); - - // Fail-closed control: an invited member holding no role is denied. - const charliePrincipal = await created( - await call(`/api/tenants/${tenant}/members/invite`, alice.cookie, "POST", { - email: charlie.email, - }), - ); - const charlieResult = await effectOf(charliePrincipal); - expect(charlieResult.effect).toBe("deny"); - expect(charlieResult.matchingGrants).toEqual([]); - - // The 404 contract the assignment picker's first-run guidance keys off: - // a principal that was never minted (no first run yet) is "not found", - // not a transient failure. - const ghost = `principal_ghost_${suffix}`; - expect( - (await call(`/api/tenants/${tenant}/principals/${ghost}/roles/${role}`, alice.cookie, "POST")) - .status, - ).toBe(404); - expect( - ( - await call(`/api/tenants/${tenant}/principals/${ghost}/evaluate`, alice.cookie, "POST", { - resource, - action, - }) - ).status, - ).toBe(404); - }, 180_000); -});