import { describe, expect, it, vi } from "vitest"; import { LocalUserAuthorizer } from "#src/authority/local-user-authorizer"; import type { PermissionPromptDecision } from "#src/authority/permission-dialog"; import type { requestPermissionDecision } from "#src/authority/permission-prompt-component"; import type { PromptPermissionDetails } from "#src/authority/permission-prompter"; import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures"; import { makePromptDetails, makePromptPayload, } from "#test/helpers/prompt-details-fixtures"; import { makePromptPreferences } from "#test/helpers/prompt-view-fixtures"; // ── Helpers ───────────────────────────────────────────────────────────────── /** * This file's semantic defaults over the shared structural fixture: several * cases assert `agentName` and `toolName` on a no-override call. */ function makeDetails( overrides?: Partial, ): PromptPermissionDetails { return makePromptDetails({ requestId: "req-123", agentName: "test-agent", toolName: "read", ...overrides, }); } /** A `PermissionPromptUi` double; the tool-expansion accessors go unused here. */ function makePromptUi() { return { select: vi.fn(), input: vi.fn(), custom: vi.fn(), getToolsExpanded: vi.fn(() => false), setToolsExpanded: vi.fn(), }; } function makeDeps( overrides: { requestPermissionDecision?: typeof requestPermissionDecision; } = {}, ) { const events = { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined), }; const ui = makePromptUi(); const decisionFn = overrides.requestPermissionDecision ?? vi.fn().mockResolvedValue({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); return { deps: { ui, mode: "tui" as const, events, getPromptPreferences: () => makePromptPreferences(), requestPermissionDecision: decisionFn, }, events, ui, decisionFn, }; } // ── Tests ───────────────────────────────────────────────────────────────── describe("LocalUserAuthorizer", () => { it("emits a UI prompt event with normalized surface and value", async () => { const { deps, events } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); await authorizer.authorize( makeDetails({ toolName: "bash", command: "git push", toolInputPreview: "git push", }), ); expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", { requestId: "req-123", source: "tool_call", surface: "bash", value: "git push", agentName: "test-agent", request: makePromptPayload().request, forwarding: null, }); }); it("normalizes skill prompt events to the skill surface", async () => { const { deps, events } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); await authorizer.authorize( makeDetails({ source: "skill_input", toolName: undefined, skillName: "deploy-helper", }), ); expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", { requestId: "req-123", source: "skill_input", surface: "skill", value: "deploy-helper", agentName: "test-agent", request: makePromptPayload().request, forwarding: null, }); }); it("calls requestPermissionDecision with the threaded view, title, and payload", async () => { const { deps, ui, decisionFn } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); const details = makeDetails(); await authorizer.authorize(details); expect(decisionFn).toHaveBeenCalledWith( { mode: "tui", ui, ...makePromptPreferences() }, "Permission Required", details.payload, undefined, ); }); it("passes the sessionLabel option when present", async () => { const { deps, decisionFn } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); await authorizer.authorize( makeDetails({ sessionLabel: "Yes, for 'read' tool" }), ); expect(decisionFn).toHaveBeenCalledWith( expect.anything(), expect.any(String), expect.anything(), { sessionLabel: "Yes, for 'read' tool" }, ); }); it("emits the UI event before calling requestPermissionDecision", async () => { const calls: string[] = []; const events = { emit: vi.fn(() => { calls.push("emit"); }), on: vi.fn().mockReturnValue(() => undefined), }; const ui = makePromptUi(); const decisionFn = vi.fn(() => { calls.push("dialog"); return Promise.resolve({ approved: true, state: "approved", decidedBy: DECIDED_BY_HUMAN, }); }); const authorizer = new LocalUserAuthorizer({ ui, mode: "tui", events, getPromptPreferences: () => makePromptPreferences(), requestPermissionDecision: decisionFn, }); await authorizer.authorize(makeDetails()); expect(calls).toEqual(["emit", "dialog"]); }); describe("forwarded provenance", () => { it("emits a non-degraded forwarded event with populated forwarding and the child's display projection", async () => { const { deps, events } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); await authorizer.authorize( makeDetails({ source: "tool_call", agentName: "Explore", surface: "bash", value: "git push", forwarding: { requesterAgentName: "Explore", requesterSessionId: "child-session", }, }), ); expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", { requestId: "req-123", source: "tool_call", surface: "bash", value: "git push", agentName: "Explore", request: makePromptPayload().request, forwarding: { requesterAgentName: "Explore", requesterSessionId: "child-session", }, }); }); it("uses the '(Subagent)' dialog title when the ask is forwarded", async () => { const { deps, ui, decisionFn } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); const details = makeDetails({ forwarding: { requesterAgentName: "Explore", requesterSessionId: "child-session", }, }); await authorizer.authorize(details); expect(decisionFn).toHaveBeenCalledWith( { mode: "tui", ui, ...makePromptPreferences() }, "Permission Required (Subagent)", details.payload, undefined, ); }); it("offers a sessionScope when the forwarded ask carries a suggestion", async () => { const { deps, decisionFn } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); await authorizer.authorize( makeDetails({ toolName: "bash", command: "git push", forwarding: { requesterAgentName: "Explore", requesterSessionId: "child-session", }, sessionApproval: { surface: "bash", patterns: ["git *"] }, }), ); expect(decisionFn).toHaveBeenCalledWith( expect.anything(), "Permission Required (Subagent)", expect.anything(), { sessionScope: { subagentLabel: "This subagent ('Explore') only", servingSessionLabel: 'The whole session — allow bash "git *" for parent and all subagents', }, }, ); }); it("offers no sessionScope for a forwarded ask without a suggestion", async () => { const { deps, decisionFn } = makeDeps(); const authorizer = new LocalUserAuthorizer(deps); await authorizer.authorize( makeDetails({ forwarding: { requesterAgentName: "Explore", requesterSessionId: "child-session", }, }), ); expect(decisionFn).toHaveBeenCalledWith( expect.anything(), expect.any(String), expect.anything(), undefined, ); }); }); it("returns the decision from requestPermissionDecision", async () => { const decision: PermissionPromptDecision = { approved: false, state: "denied", decidedBy: DECIDED_BY_HUMAN, }; const { deps } = makeDeps({ requestPermissionDecision: vi .fn() .mockResolvedValue(decision), }); const authorizer = new LocalUserAuthorizer(deps); const result = await authorizer.authorize(makeDetails()); expect(result).toEqual(decision); }); });