import { describe, expect, it, vi } from "vitest"; import { createDeniedPermissionDecision, isPermissionDecisionState, normalizePermissionDenialReason, type PermissionDecisionUi, requestPermissionDecisionFromUi, } from "#src/authority/permission-dialog"; describe("isPermissionDecisionState", () => { it("accepts approved", () => { expect(isPermissionDecisionState("approved")).toBe(true); }); it("accepts denied", () => { expect(isPermissionDecisionState("denied")).toBe(true); }); it("accepts denied_with_reason", () => { expect(isPermissionDecisionState("denied_with_reason")).toBe(true); }); it("accepts approved_for_session", () => { expect(isPermissionDecisionState("approved_for_session")).toBe(true); }); it("accepts approved_for_serving_session", () => { expect(isPermissionDecisionState("approved_for_serving_session")).toBe( true, ); }); it("rejects unknown strings", () => { expect(isPermissionDecisionState("unknown")).toBe(false); }); it("rejects non-strings", () => { expect(isPermissionDecisionState(42)).toBe(false); expect(isPermissionDecisionState(null)).toBe(false); }); }); describe("requestPermissionDecisionFromUi", () => { it("returns approved when user selects Yes", async () => { const ui: PermissionDecisionUi = { select: vi.fn().mockResolvedValue("Yes"), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", ); expect(result).toEqual({ approved: true, state: "approved" }); }); it("returns approved_for_session when user selects session option", async () => { const ui: PermissionDecisionUi = { select: vi.fn().mockResolvedValue("Yes, for this session"), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", ); expect(result).toEqual({ approved: true, state: "approved_for_session" }); }); it("returns denied when user selects No", async () => { const ui: PermissionDecisionUi = { select: vi.fn().mockResolvedValue("No"), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", ); expect(result).toEqual({ approved: false, state: "denied" }); }); it("returns denied_with_reason when user provides reason", async () => { const ui: PermissionDecisionUi = { select: vi.fn().mockResolvedValue("No, provide reason"), input: vi.fn().mockResolvedValue("not now"), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", ); expect(result).toEqual({ approved: false, state: "denied_with_reason", denialReason: "not now", }); }); it("returns denied when user selects deny-with-reason but gives empty input", async () => { const ui: PermissionDecisionUi = { select: vi.fn().mockResolvedValue("No, provide reason"), input: vi.fn().mockResolvedValue(""), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", ); expect(result).toEqual({ approved: false, state: "denied" }); }); it("returns denied when user dismisses dialog (undefined)", async () => { const ui: PermissionDecisionUi = { select: vi.fn().mockResolvedValue(undefined), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", ); expect(result).toEqual({ approved: false, state: "denied" }); }); it("passes four options to ui.select", async () => { const selectFn = vi.fn().mockResolvedValue("Yes"); const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn(), }; await requestPermissionDecisionFromUi(ui, "Title", "Message"); const options = selectFn.mock.calls[0][1] as string[]; expect(options).toEqual([ "Yes", "Yes, for this session", "No", "No, provide reason", ]); }); it("uses custom sessionLabel when provided", async () => { const selectFn = vi.fn().mockResolvedValue("Yes"); const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn(), }; await requestPermissionDecisionFromUi(ui, "Title", "Message", { sessionLabel: 'Yes, allow "git *" for this session', }); const options = selectFn.mock.calls[0][1] as string[]; expect(options[1]).toBe('Yes, allow "git *" for this session'); }); it("still returns approved_for_session when user selects the custom session label", async () => { const customLabel = 'Yes, allow "git *" for this session'; const ui: PermissionDecisionUi = { select: vi.fn().mockResolvedValue(customLabel), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", { sessionLabel: customLabel }, ); expect(result).toEqual({ approved: true, state: "approved_for_session" }); }); it("falls back to default session label when no options provided", async () => { const selectFn = vi.fn().mockResolvedValue("Yes"); const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn(), }; await requestPermissionDecisionFromUi(ui, "Title", "Message"); const options = selectFn.mock.calls[0][1] as string[]; expect(options[1]).toBe("Yes, for this session"); }); describe("sessionScope two-step (forwarded asks)", () => { const sessionScope = { subagentLabel: "This subagent only", servingSessionLabel: "The whole session", }; it("opens a second scope select after the session option is chosen", async () => { const selectFn = vi .fn() .mockResolvedValueOnce("Yes, for this session") .mockResolvedValueOnce("This subagent only"); const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() }; await requestPermissionDecisionFromUi(ui, "Title", "Message", { sessionScope, }); expect(selectFn).toHaveBeenCalledTimes(2); const scopeOptions = selectFn.mock.calls[1][1] as string[]; expect(scopeOptions).toEqual(["This subagent only", "The whole session"]); }); it("maps the subagent scope to approved_for_session", async () => { const ui: PermissionDecisionUi = { select: vi .fn() .mockResolvedValueOnce("Yes, for this session") .mockResolvedValueOnce("This subagent only"), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", { sessionScope }, ); expect(result).toEqual({ approved: true, state: "approved_for_session" }); }); it("maps the whole-session scope to approved_for_serving_session", async () => { const ui: PermissionDecisionUi = { select: vi .fn() .mockResolvedValueOnce("Yes, for this session") .mockResolvedValueOnce("The whole session"), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", { sessionScope }, ); expect(result).toEqual({ approved: true, state: "approved_for_serving_session", }); }); it("defaults to the least-privilege subagent scope when the scope select is cancelled", async () => { const ui: PermissionDecisionUi = { select: vi .fn() .mockResolvedValueOnce("Yes, for this session") .mockResolvedValueOnce(undefined), input: vi.fn(), }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", { sessionScope }, ); expect(result).toEqual({ approved: true, state: "approved_for_session" }); }); it("does not open the scope select when the user picks plain Yes", async () => { const selectFn = vi.fn().mockResolvedValueOnce("Yes"); const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() }; const result = await requestPermissionDecisionFromUi( ui, "Title", "Message", { sessionScope }, ); expect(selectFn).toHaveBeenCalledTimes(1); expect(result).toEqual({ approved: true, state: "approved" }); }); }); }); describe("normalizePermissionDenialReason", () => { it("returns trimmed string for non-empty input", () => { expect(normalizePermissionDenialReason(" reason ")).toBe("reason"); }); it("returns undefined for empty string", () => { expect(normalizePermissionDenialReason("")).toBeUndefined(); }); it("returns undefined for non-string", () => { expect(normalizePermissionDenialReason(42)).toBeUndefined(); }); }); describe("createDeniedPermissionDecision", () => { it("returns denied_with_reason when reason provided", () => { expect(createDeniedPermissionDecision("nope")).toEqual({ approved: false, state: "denied_with_reason", denialReason: "nope", }); }); it("returns denied when no reason", () => { expect(createDeniedPermissionDecision()).toEqual({ approved: false, state: "denied", }); }); });