mcp-access.test.ts267 lines · main
1import { describe, expect, test } from 'bun:test';
2
3import type { AuditEntry } from './audit.js';
4import {
5 deleteRevokedKey,
6 disableForProject,
7 enableForProject,
8 generateMcpKey,
9 isPlanEligibleForMcp,
10 issueKey,
11 MCP_KEY_PREFIX,
12 McpKeyNotRevokedError,
13 McpPlanRequiredError,
14 maskKey,
15 revokeKey,
16 setGlobalEnabled,
17 type McpAccessDeps,
18 type McpActor,
19} from './mcp-access.js';
20import type { McpKey, NewMcpKey, ProjectTier } from '../db/schema.js';
21
22/* ─── in-memory deps so the mutators are testable without a DB ──────────── */
23
24interface Fake {
25 deps: McpAccessDeps;
26 audits: AuditEntry[];
27 keys: Map<string, McpKey>;
28 projectEnabled: Map<string, boolean>;
29 globalOn: boolean | null;
30}
31
32function makeFake(planByProject: Record<string, ProjectTier | null>): Fake {
33 const audits: AuditEntry[] = [];
34 const keys = new Map<string, McpKey>();
35 const projectEnabled = new Map<string, boolean>();
36 const state = { globalOn: null as boolean | null };
37
38 const deps: McpAccessDeps = {
39 async setGlobalSetting(on) {
40 state.globalOn = on;
41 },
42 async getProjectPlanTier(projectId) {
43 return projectId in planByProject ? planByProject[projectId]! : null;
44 },
45 async setProjectEnabled(projectId, on) {
46 projectEnabled.set(projectId, on);
47 },
48 async insertKey(row: NewMcpKey) {
49 const record: McpKey = {
50 id: row.id!,
51 projectId: row.projectId,
52 name: row.name,
53 hash: row.hash,
54 prefix: row.prefix,
55 suffix: row.suffix,
56 scope: row.scope ?? 'read',
57 enabled: row.enabled ?? true,
58 createdBy: row.createdBy ?? null,
59 createdAt: new Date(),
60 lastUsedAt: null,
61 revokedAt: null,
62 };
63 keys.set(record.id, record);
64 return record;
65 },
66 async getKeyById(keyId) {
67 return keys.get(keyId) ?? null;
68 },
69 async setKeyRevoked(keyId) {
70 const row = keys.get(keyId);
71 if (row) keys.set(keyId, { ...row, revokedAt: new Date(), enabled: false });
72 },
73 async deleteKey(keyId) {
74 keys.delete(keyId);
75 },
76 async audit(entry) {
77 audits.push(entry);
78 },
79 };
80
81 return {
82 deps,
83 audits,
84 keys,
85 projectEnabled,
86 get globalOn() {
87 return state.globalOn;
88 },
89 } as Fake;
90}
91
92const actor: McpActor = { id: 'usr_admin', ipHash: null, userAgent: null };
93
94describe('isPlanEligibleForMcp (the plan gate, pure)', () => {
95 test('pro and team qualify', () => {
96 expect(isPlanEligibleForMcp('pro')).toBe(true);
97 expect(isPlanEligibleForMcp('team')).toBe(true);
98 });
99 test('free / null / undefined never qualify', () => {
100 expect(isPlanEligibleForMcp('free')).toBe(false);
101 expect(isPlanEligibleForMcp(null)).toBe(false);
102 expect(isPlanEligibleForMcp(undefined)).toBe(false);
103 });
104});
105
106describe('enableForProject (server-side plan gate)', () => {
107 test('(a) REJECTS a free-tier project — even called directly', async () => {
108 const fake = makeFake({ proj_free: 'free' });
109 await expect(enableForProject('proj_free', actor, fake.deps)).rejects.toBeInstanceOf(
110 McpPlanRequiredError,
111 );
112 // gate fired before any state change
113 expect(fake.projectEnabled.has('proj_free')).toBe(false);
114 expect(fake.audits).toHaveLength(0);
115 });
116
117 test('rejects an unknown project (tier null)', async () => {
118 const fake = makeFake({});
119 await expect(enableForProject('nope', actor, fake.deps)).rejects.toBeInstanceOf(
120 McpPlanRequiredError,
121 );
122 });
123
124 test('(b) ACCEPTS a Pro project and (e) records an audit row', async () => {
125 const fake = makeFake({ proj_pro: 'pro' });
126 const res = await enableForProject('proj_pro', actor, fake.deps);
127 expect(res).toEqual({ projectId: 'proj_pro', enabled: true });
128 expect(fake.projectEnabled.get('proj_pro')).toBe(true);
129 expect(fake.audits).toHaveLength(1);
130 expect(fake.audits[0]!.action).toBe('mcp.project.enable');
131 expect(fake.audits[0]!.projectId).toBe('proj_pro');
132 });
133
134 test('(b) ACCEPTS a Team project', async () => {
135 const fake = makeFake({ proj_team: 'team' });
136 await expect(enableForProject('proj_team', actor, fake.deps)).resolves.toEqual({
137 projectId: 'proj_team',
138 enabled: true,
139 });
140 });
141});
142
143describe('issueKey (one-time reveal)', () => {
144 test('(c) returns the FULL key once, then only prefix/suffix are exposed', async () => {
145 const fake = makeFake({ proj_pro: 'pro' });
146 const issued = await issueKey(
147 { projectId: 'proj_pro', name: 'agent-1', scope: 'read' },
148 actor,
149 fake.deps,
150 );
151
152 // Full plaintext returned exactly once.
153 expect(issued.plaintext.startsWith(MCP_KEY_PREFIX)).toBe(true);
154 expect(issued.plaintext.length).toBeGreaterThan(MCP_KEY_PREFIX.length + 20);
155
156 // The masked key carries NO plaintext and NO hash — only prefix…suffix.
157 expect(issued.key).not.toHaveProperty('hash');
158 expect(issued.key).not.toHaveProperty('plaintext');
159 expect(issued.key.prefix).toBe(MCP_KEY_PREFIX);
160 expect(issued.key.suffix).toBe(issued.plaintext.slice(-4));
161
162 // What's stored is the hash, never the plaintext.
163 const stored = fake.keys.get(issued.key.id)!;
164 expect(stored.hash).not.toContain(issued.plaintext);
165 expect(JSON.stringify(maskKey(stored))).not.toContain(issued.plaintext);
166
167 // audit row recorded for the issue.
168 expect(fake.audits.some((a) => a.action === 'mcp.key.issue')).toBe(true);
169 });
170
171 test('plan gate also guards key issue (free rejected)', async () => {
172 const fake = makeFake({ proj_free: 'free' });
173 await expect(
174 issueKey({ projectId: 'proj_free', name: 'x', scope: 'read' }, actor, fake.deps),
175 ).rejects.toBeInstanceOf(McpPlanRequiredError);
176 expect(fake.keys.size).toBe(0);
177 });
178});
179
180describe('revokeKey', () => {
181 test('(d) disables the key (enabled false + revoked_at set) and audits', async () => {
182 const fake = makeFake({ proj_pro: 'pro' });
183 const issued = await issueKey(
184 { projectId: 'proj_pro', name: 'agent-1', scope: 'read' },
185 actor,
186 fake.deps,
187 );
188 expect(fake.keys.get(issued.key.id)!.enabled).toBe(true);
189
190 const res = await revokeKey(issued.key.id, actor, fake.deps);
191 expect(res).toEqual({ keyId: issued.key.id, revoked: true });
192
193 const stored = fake.keys.get(issued.key.id)!;
194 expect(stored.enabled).toBe(false);
195 expect(stored.revokedAt).not.toBeNull();
196 expect(fake.audits.some((a) => a.action === 'mcp.key.revoke')).toBe(true);
197 });
198
199 test('throws NotFound for an unknown key id', async () => {
200 const fake = makeFake({});
201 await expect(revokeKey('mck_nope', actor, fake.deps)).rejects.toThrow();
202 });
203});
204
205describe('deleteRevokedKey (revoke-then-delete)', () => {
206 test('refuses an ACTIVE key with McpKeyNotRevokedError — row survives', async () => {
207 const fake = makeFake({ proj_pro: 'pro' });
208 const issued = await issueKey(
209 { projectId: 'proj_pro', name: 'agent-1', scope: 'read' },
210 actor,
211 fake.deps,
212 );
213 await expect(deleteRevokedKey(issued.key.id, actor, fake.deps)).rejects.toThrow(
214 McpKeyNotRevokedError,
215 );
216 expect(fake.keys.has(issued.key.id)).toBe(true);
217 expect(fake.audits.some((a) => a.action === 'mcp.key.deleted')).toBe(false);
218 });
219
220 test('deletes an already-revoked key (row gone) and audits mcp.key.deleted', async () => {
221 const fake = makeFake({ proj_pro: 'pro' });
222 const issued = await issueKey(
223 { projectId: 'proj_pro', name: 'agent-1', scope: 'read' },
224 actor,
225 fake.deps,
226 );
227 await revokeKey(issued.key.id, actor, fake.deps);
228
229 const res = await deleteRevokedKey(issued.key.id, actor, fake.deps);
230 expect(res).toEqual({ keyId: issued.key.id, deleted: true });
231 expect(fake.keys.has(issued.key.id)).toBe(false);
232 expect(fake.audits.some((a) => a.action === 'mcp.key.deleted')).toBe(true);
233 });
234
235 test('throws NotFound for an unknown key id', async () => {
236 const fake = makeFake({});
237 await expect(deleteRevokedKey('mck_nope', actor, fake.deps)).rejects.toThrow();
238 });
239});
240
241describe('global kill-switch + disable', () => {
242 test('setGlobalEnabled persists + audits mcp.global.toggle', async () => {
243 const fake = makeFake({});
244 const res = await setGlobalEnabled(true, actor, fake.deps);
245 expect(res).toEqual({ enabled: true });
246 expect(fake.globalOn).toBe(true);
247 expect(fake.audits[0]!.action).toBe('mcp.global.toggle');
248 });
249
250 test('disableForProject flips off + audits (no plan gate)', async () => {
251 const fake = makeFake({ proj_free: 'free' });
252 const res = await disableForProject('proj_free', actor, fake.deps);
253 expect(res).toEqual({ projectId: 'proj_free', enabled: false });
254 expect(fake.projectEnabled.get('proj_free')).toBe(false);
255 expect(fake.audits[0]!.action).toBe('mcp.project.disable');
256 });
257});
258
259describe('generateMcpKey', () => {
260 test('hash is sha-256 hex of the plaintext, suffix is its last 4', () => {
261 const k = generateMcpKey();
262 expect(k.prefix).toBe(MCP_KEY_PREFIX);
263 expect(k.hash).toMatch(/^[0-9a-f]{64}$/);
264 expect(k.suffix).toBe(k.plaintext.slice(-4));
265 expect(k.plaintext).not.toBe(k.hash);
266 });
267});