summaryrefslogtreecommitdiffhomepage
path: root/packages/api/tests/permission-manager.test.ts
blob: 172adb32574501acbd6a78606f3ab7308a83ca9f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import { describe, expect, it, vi } from "vitest";

// Mock @dispatch/core to provide only the PermissionService impl this test
// touches — the core barrel transitively pulls in bun:sqlite, which vitest
// running under Node cannot resolve.
vi.mock("@dispatch/core", async () => {
	const mod = await import("../../core/src/permission/service.js");
	return {
		PermissionService: mod.PermissionService,
	};
});

const { PermissionManager } = await import("../src/permission-manager.js");

interface PermissionRequest {
	permission: string;
	patterns: string[];
	always: string[];
	description: string;
	metadata: Record<string, unknown>;
}

function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
	return {
		permission: "bash",
		patterns: ["git *"],
		always: ["git status"],
		description: "Run git status",
		metadata: {},
		...overrides,
	};
}

describe("PermissionManager.onPromptAdded", () => {
	it("fires once per newly-added pending prompt", () => {
		const mgr = new PermissionManager();
		const seen: Array<{ id: string; permission: string }> = [];
		mgr.onPromptAdded((p) => {
			seen.push({ id: p.id, permission: p.permission });
		});

		void mgr.ask(makeRequest(), []);
		void mgr.ask(makeRequest({ permission: "read", description: "Read X" }), []);

		expect(seen).toHaveLength(2);
		expect(seen[0].permission).toBe("bash");
		expect(seen[1].permission).toBe("read");
		// Distinct ids
		expect(seen[0].id).not.toBe(seen[1].id);
	});

	it("does not re-fire when the pending list is rebroadcast for an unrelated change", async () => {
		const mgr = new PermissionManager();
		const seen: string[] = [];
		mgr.onPromptAdded((p) => seen.push(p.id));

		// Two prompts in; should see two notifications.
		const p1 = mgr.ask(makeRequest(), []);
		void mgr.ask(makeRequest({ permission: "read" }), []);
		expect(seen).toHaveLength(2);

		// Resolve the first one — broadcastPending fires again, but the
		// remaining (already-announced) prompt must NOT re-notify.
		const pending = mgr.getPending();
		const firstId = pending[0].id;
		mgr.reply(firstId, "once");
		await p1;

		expect(seen).toHaveLength(2);
	});

	it("unsubscribe stops further notifications", () => {
		const mgr = new PermissionManager();
		const seen: string[] = [];
		const unsub = mgr.onPromptAdded((p) => seen.push(p.id));
		void mgr.ask(makeRequest(), []);
		unsub();
		void mgr.ask(makeRequest({ permission: "read" }), []);
		expect(seen).toHaveLength(1);
	});

	it("listener throws are caught and don't break other listeners", () => {
		const mgr = new PermissionManager();
		const seen: string[] = [];
		mgr.onPromptAdded(() => {
			throw new Error("boom");
		});
		mgr.onPromptAdded((p) => seen.push(p.id));
		// Swallow the warn during this test.
		const origWarn = console.warn;
		console.warn = () => {};
		try {
			void mgr.ask(makeRequest(), []);
		} finally {
			console.warn = origWarn;
		}
		expect(seen).toHaveLength(1);
	});
});