summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/config/loader.test.ts
blob: 0d84d0b49361acfa6e38efd201d36e874b9b4804 (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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { configToRuleset, loadConfig } from "../../src/config/loader.js";

const TMP = join("/tmp/opencode", "dispatch-config-test");

// Point the global config at a path that does not exist so these tests are
// hermetic — they must not pick up this machine's real
// ~/.config/dispatch/dispatch.toml.
const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;

beforeEach(() => {
	mkdirSync(TMP, { recursive: true });
	process.env.DISPATCH_GLOBAL_CONFIG = join(TMP, "__no_such_global__.toml");
});

afterEach(() => {
	rmSync(TMP, { recursive: true, force: true });
	if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
	else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
});

function writeToml(content: string): void {
	writeFileSync(join(TMP, "dispatch.toml"), content, "utf-8");
}

describe("loadConfig", () => {
	it("returns empty permissions when dispatch.toml is missing", () => {
		const config = loadConfig(TMP);
		expect(config.permissions).toEqual({});
	});

	it("parses simple string permissions", () => {
		writeToml(`[permissions]\nread = "allow"\nedit = "deny"\n`);
		const config = loadConfig(TMP);
		expect(config.permissions.read).toBe("allow");
		expect(config.permissions.edit).toBe("deny");
	});

	it("parses nested pattern permissions", () => {
		writeToml(`[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`);
		const config = loadConfig(TMP);
		const bash = config.permissions.bash as Record<string, string>;
		expect(bash["npm test"]).toBe("allow");
		expect(bash["*"]).toBe("ask");
	});

	it("ignores comment lines", () => {
		writeToml(`# this is a comment\n[permissions]\n# another comment\nread = "allow"\n`);
		const config = loadConfig(TMP);
		expect(config.permissions.read).toBe("allow");
	});

	it("handles ~ expansion in nested keys", () => {
		writeToml(`[permissions.read]\n"~/projects/*" = "allow"\n`);
		const config = loadConfig(TMP);
		const read = config.permissions.read as Record<string, string>;
		expect(read["~/projects/*"]).toBe("allow");
	});

	it("handles $HOME expansion in nested keys", () => {
		writeToml(`[permissions.read]\n"$HOME/docs/*" = "allow"\n`);
		const config = loadConfig(TMP);
		const read = config.permissions.read as Record<string, string>;
		expect(read["$HOME/docs/*"]).toBe("allow");
	});

	it("parses quoted keys", () => {
		writeToml(`[permissions.bash]\n"git commit *" = "allow"\n"rm *" = "deny"\n`);
		const config = loadConfig(TMP);
		const bash = config.permissions.bash as Record<string, string>;
		expect(bash["git commit *"]).toBe("allow");
		expect(bash["rm *"]).toBe("deny");
	});

	it("handles multiple permission groups", () => {
		writeToml(
			`[permissions]\nread = "allow"\n\n[permissions.edit]\n"*" = "ask"\n"src/**" = "allow"\n\n[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`,
		);
		const config = loadConfig(TMP);
		expect(config.permissions.read).toBe("allow");
		const edit = config.permissions.edit as Record<string, string>;
		expect(edit["*"]).toBe("ask");
		expect(edit["src/**"]).toBe("allow");
		const bash = config.permissions.bash as Record<string, string>;
		expect(bash["npm test"]).toBe("allow");
		expect(bash["*"]).toBe("ask");
	});

	it("preserves # inside quoted string keys", () => {
		writeToml(`[permissions.bash]\n"file#1" = "allow"\n`);
		const config = loadConfig(TMP);
		const bash = config.permissions.bash as Record<string, string>;
		expect(bash["file#1"]).toBe("allow");
	});

	it("strips inline comments on table headers", () => {
		writeToml(`[permissions.bash] # scripts\n"*" = "allow"\n`);
		const config = loadConfig(TMP);
		const bash = config.permissions.bash as Record<string, string>;
		expect(bash["*"]).toBe("allow");
	});

	it("expands ~ with platform path separator", () => {
		// Simulate a path using the OS separator
		const pattern = `~${sep}projects${sep}*`;
		writeToml(`[permissions.read]\n"${pattern}" = "allow"\n`);
		const config = loadConfig(TMP);
		const read = config.permissions.read as Record<string, string>;
		expect(read[pattern]).toBe("allow");
	});

	it("throws on TOML parse errors", () => {
		writeToml("this is not valid TOML [[[");
		expect(() => loadConfig(TMP)).toThrow();
	});
});

describe("configToRuleset — new validations", () => {
	it("falls back to ask and warns for invalid action in string value", () => {
		const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
		const rules = configToRuleset({ permissions: { read: "allw" } });
		expect(rules[0]?.action).toBe("ask");
		expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw"));
		warn.mockRestore();
	});

	it("falls back to ask and warns for invalid action in nested pattern", () => {
		const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
		const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } });
		expect(rules[0]?.action).toBe("ask");
		expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID"));
		warn.mockRestore();
	});

	it("expands ~ with backslash separator in patterns", () => {
		const home = homedir();
		// Force backslash path even on Linux to test the regex
		const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } });
		expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`);
	});
});

describe("configToRuleset", () => {
	it("produces a rule with pattern * for string value", () => {
		const rules = configToRuleset({ permissions: { read: "allow" } });
		expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]);
	});

	it("produces rules for each pattern in an object value", () => {
		const rules = configToRuleset({
			permissions: { bash: { "npm test": "allow", "*": "ask" } },
		});
		expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" });
		expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" });
	});

	it("expands ~ in patterns", () => {
		const home = homedir();
		const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } });
		expect(rules[0]?.pattern).toBe(`${home}/foo/*`);
	});

	it("expands $HOME in patterns", () => {
		const home = homedir();
		const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } });
		expect(rules[0]?.pattern).toBe(`${home}/bar/*`);
	});

	it("handles empty permissions", () => {
		const rules = configToRuleset({ permissions: {} });
		expect(rules).toEqual([]);
	});
});