project-mcp.test.ts357 lines · main
1// Route-level tests for the USER-scoped per-project MCP router.
2//
3// The router is built with INJECTED deps + a fake auth middleware so the
4// gating + cross-project-isolation logic is exercised without a live DB.
5// The one role-enforcement test wires the REAL `requireProjectRole('admin')`
6// to prove the chain refuses a non-admin.
7
8import { describe, expect, it } from 'bun:test';
9import type { MiddlewareHandler } from 'hono';
10
11import { errorHandler } from '../middleware/error.js';
12import { requireProjectRole } from '../middleware/project-auth.js';
13import { buildProjectMcpRouter, type ProjectMcpReadDeps } from './project-mcp.js';
14import type { McpAccessDeps } from '../services/mcp-access.js';
15import type { McpKey, MemberRole, NewMcpKey, ProjectTier } from '../db/schema.js';
16
17/* ─── fakes ──────────────────────────────────────────────────────────────── */
18
19interface FakeState {
20 accessDeps: McpAccessDeps;
21 readDeps: ProjectMcpReadDeps;
22 enabled: Map<string, boolean>;
23 keys: Map<string, McpKey>;
24 revoked: string[];
25 deleted: string[];
26}
27
28function makeFakes(opts: {
29 planByProject?: Record<string, ProjectTier | null>;
30 globalOn?: boolean;
31 enabledProjects?: string[];
32 seedKeys?: McpKey[];
33}): FakeState {
34 const planByProject = opts.planByProject ?? {};
35 const enabled = new Map<string, boolean>((opts.enabledProjects ?? []).map((p) => [p, true]));
36 const keys = new Map<string, McpKey>((opts.seedKeys ?? []).map((k) => [k.id, k]));
37 const revoked: string[] = [];
38 const deleted: string[] = [];
39
40 const accessDeps: McpAccessDeps = {
41 async setGlobalSetting() {},
42 async getProjectPlanTier(projectId) {
43 return projectId in planByProject ? planByProject[projectId]! : null;
44 },
45 async setProjectEnabled(projectId, on) {
46 enabled.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 revoked.push(keyId);
71 const row = keys.get(keyId);
72 if (row) keys.set(keyId, { ...row, revokedAt: new Date(), enabled: false });
73 },
74 async deleteKey(keyId) {
75 deleted.push(keyId);
76 keys.delete(keyId);
77 },
78 async audit() {},
79 };
80
81 const readDeps: ProjectMcpReadDeps = {
82 async getGlobalEnabled() {
83 return opts.globalOn ?? true;
84 },
85 async isProjectEnabled(projectId) {
86 return enabled.get(projectId) === true;
87 },
88 async listKeysForProject(projectId) {
89 return [...keys.values()]
90 .filter((k) => k.projectId === projectId)
91 .map((k) => ({
92 id: k.id,
93 name: k.name,
94 prefix: k.prefix,
95 suffix: k.suffix,
96 scope: k.scope,
97 enabled: k.enabled,
98 createdAt: k.createdAt,
99 lastUsedAt: k.lastUsedAt,
100 revokedAt: k.revokedAt,
101 }));
102 },
103 };
104
105 return { accessDeps, readDeps, enabled, keys, revoked, deleted };
106}
107
108/** Fake auth middleware: sets an acting user + a project role on the request. */
109function fakeAuth(role: MemberRole): MiddlewareHandler {
110 return async (c, next) => {
111 c.set('user', { id: 'usr_admin', email: 'a@b.c', name: 'A' } as never);
112 c.set('apiKeyId', null);
113 c.set('projectRole', role);
114 await next();
115 };
116}
117
118// Build the router AND attach the production error handler, so thrown domain
119// errors (e.g. ForbiddenError from requireProjectRole) map to their HTTP codes
120// exactly as they do under the real app's `app.onError(errorHandler)`.
121function mountApp(opts: Parameters<typeof buildProjectMcpRouter>[0]) {
122 const app = buildProjectMcpRouter(opts);
123 app.onError(errorHandler);
124 return app;
125}
126
127// projectRateLimit() rejects direct (proxy-less) requests outside development
128// with 403 origin_direct_rejected before the handler runs. A forwarded-for
129// header satisfies that guard; with no Redis in the unit env the limiter then
130// fails open and the real handler runs.
131const POST = { method: 'POST', headers: { 'x-forwarded-for': '203.0.113.7' } } as const;
132
133function makeKey(id: string, projectId: string): McpKey {
134 return {
135 id,
136 projectId,
137 name: 'agent',
138 hash: 'h'.repeat(64),
139 prefix: 'pk_briven_mcp_',
140 suffix: 'abcd',
141 scope: 'read',
142 enabled: true,
143 createdBy: 'usr_admin',
144 createdAt: new Date(),
145 lastUsedAt: null,
146 revokedAt: null,
147 };
148}
149
150/** Like makeKey, but already revoked + disabled — ready to be deleted. */
151function makeRevokedKey(id: string, projectId: string): McpKey {
152 return { ...makeKey(id, projectId), enabled: false, revokedAt: new Date() };
153}
154
155/* ─── 1. plan gate ─────────────────────────────────────────────────────────── */
156
157describe('plan gate (enable)', () => {
158 it('free project → 403 mcp_plan_required', async () => {
159 const fakes = makeFakes({ planByProject: { proj_free: 'free' }, globalOn: true });
160 const app = mountApp({
161 middleware: [fakeAuth('admin')],
162 accessDeps: fakes.accessDeps,
163 readDeps: fakes.readDeps,
164 });
165 const res = await app.request('/v1/projects/proj_free/mcp/enable', POST);
166 expect(res.status).toBe(403);
167 const body = (await res.json()) as { code?: string };
168 expect(body.code).toBe('mcp_plan_required');
169 // gate fired before any state change
170 expect(fakes.enabled.has('proj_free')).toBe(false);
171 });
172
173 it('team project → enables OK', async () => {
174 const fakes = makeFakes({ planByProject: { proj_team: 'team' }, globalOn: true });
175 const app = mountApp({
176 middleware: [fakeAuth('admin')],
177 accessDeps: fakes.accessDeps,
178 readDeps: fakes.readDeps,
179 });
180 const res = await app.request('/v1/projects/proj_team/mcp/enable', POST);
181 expect(res.status).toBe(200);
182 const body = (await res.json()) as { enabled?: boolean };
183 expect(body.enabled).toBe(true);
184 expect(fakes.enabled.get('proj_team')).toBe(true);
185 });
186
187 it('global off → 403 mcp_global_disabled even for a team project', async () => {
188 const fakes = makeFakes({ planByProject: { proj_team: 'team' }, globalOn: false });
189 const app = mountApp({
190 middleware: [fakeAuth('admin')],
191 accessDeps: fakes.accessDeps,
192 readDeps: fakes.readDeps,
193 });
194 const res = await app.request('/v1/projects/proj_team/mcp/enable', POST);
195 expect(res.status).toBe(403);
196 const body = (await res.json()) as { code?: string };
197 expect(body.code).toBe('mcp_global_disabled');
198 });
199});
200
201/* ─── 2. project-role enforcement (REAL requireProjectRole) ─────────────────── */
202
203describe('project-role enforcement', () => {
204 it('a non-admin (viewer) is refused by requireProjectRole(admin)', async () => {
205 const fakes = makeFakes({ planByProject: { proj_team: 'team' }, globalOn: true });
206 const app = mountApp({
207 // fakeAuth sets the role; the REAL requireProjectRole('admin') gates it.
208 middleware: [fakeAuth('viewer'), requireProjectRole('admin')],
209 accessDeps: fakes.accessDeps,
210 readDeps: fakes.readDeps,
211 });
212 const res = await app.request('/v1/projects/proj_team/mcp/enable', POST);
213 expect(res.status).toBe(403);
214 expect(fakes.enabled.has('proj_team')).toBe(false);
215 });
216
217 it('an admin passes the same chain', async () => {
218 const fakes = makeFakes({ planByProject: { proj_team: 'team' }, globalOn: true });
219 const app = mountApp({
220 middleware: [fakeAuth('admin'), requireProjectRole('admin')],
221 accessDeps: fakes.accessDeps,
222 readDeps: fakes.readDeps,
223 });
224 const res = await app.request('/v1/projects/proj_team/mcp/enable', POST);
225 expect(res.status).toBe(200);
226 });
227});
228
229/* ─── 3. cross-project key-revoke isolation ─────────────────────────────────── */
230
231describe('cross-project key-revoke isolation', () => {
232 it('a key from project B cannot be revoked via project A’s URL → 403 cross_project', async () => {
233 const keyB = makeKey('mck_b', 'proj_B');
234 const fakes = makeFakes({
235 planByProject: { proj_A: 'team', proj_B: 'team' },
236 globalOn: true,
237 seedKeys: [keyB],
238 });
239 const app = mountApp({
240 middleware: [fakeAuth('admin')],
241 accessDeps: fakes.accessDeps,
242 readDeps: fakes.readDeps,
243 });
244 const res = await app.request('/v1/projects/proj_A/mcp/keys/mck_b/revoke', POST);
245 expect(res.status).toBe(403);
246 const body = (await res.json()) as { code?: string };
247 expect(body.code).toBe('cross_project');
248 // never revoked
249 expect(fakes.revoked).toHaveLength(0);
250 expect(fakes.keys.get('mck_b')!.revokedAt).toBeNull();
251 });
252
253 it('the owning project CAN revoke its own key', async () => {
254 const keyB = makeKey('mck_b', 'proj_B');
255 const fakes = makeFakes({
256 planByProject: { proj_B: 'team' },
257 globalOn: true,
258 seedKeys: [keyB],
259 });
260 const app = mountApp({
261 middleware: [fakeAuth('admin')],
262 accessDeps: fakes.accessDeps,
263 readDeps: fakes.readDeps,
264 });
265 const res = await app.request('/v1/projects/proj_B/mcp/keys/mck_b/revoke', POST);
266 expect(res.status).toBe(200);
267 expect(fakes.revoked).toContain('mck_b');
268 });
269
270 it('an unknown key → 404', async () => {
271 const fakes = makeFakes({ planByProject: { proj_A: 'team' }, globalOn: true });
272 const app = mountApp({
273 middleware: [fakeAuth('admin')],
274 accessDeps: fakes.accessDeps,
275 readDeps: fakes.readDeps,
276 });
277 const res = await app.request('/v1/projects/proj_A/mcp/keys/mck_nope/revoke', POST);
278 expect(res.status).toBe(404);
279 });
280});
281
282/* ─── 4. delete (revoke-then-delete) ────────────────────────────────────────── */
283
284describe('delete key (revoke-then-delete rule)', () => {
285 it('deleting an ACTIVE key is refused → 409 mcp_key_not_revoked, row still present', async () => {
286 const activeKey = makeKey('mck_active', 'proj_B');
287 const fakes = makeFakes({
288 planByProject: { proj_B: 'team' },
289 globalOn: true,
290 seedKeys: [activeKey],
291 });
292 const app = mountApp({
293 middleware: [fakeAuth('admin')],
294 accessDeps: fakes.accessDeps,
295 readDeps: fakes.readDeps,
296 });
297 const res = await app.request('/v1/projects/proj_B/mcp/keys/mck_active/delete', POST);
298 expect(res.status).toBe(409);
299 const body = (await res.json()) as { code?: string };
300 expect(body.code).toBe('mcp_key_not_revoked');
301 // never deleted — the row survives
302 expect(fakes.deleted).toHaveLength(0);
303 expect(fakes.keys.has('mck_active')).toBe(true);
304 });
305
306 it('deleting a REVOKED key removes the row → 200, row gone', async () => {
307 const revokedKey = makeRevokedKey('mck_revoked', 'proj_B');
308 const fakes = makeFakes({
309 planByProject: { proj_B: 'team' },
310 globalOn: true,
311 seedKeys: [revokedKey],
312 });
313 const app = mountApp({
314 middleware: [fakeAuth('admin')],
315 accessDeps: fakes.accessDeps,
316 readDeps: fakes.readDeps,
317 });
318 const res = await app.request('/v1/projects/proj_B/mcp/keys/mck_revoked/delete', POST);
319 expect(res.status).toBe(200);
320 const body = (await res.json()) as { deleted?: boolean };
321 expect(body.deleted).toBe(true);
322 expect(fakes.deleted).toContain('mck_revoked');
323 expect(fakes.keys.has('mck_revoked')).toBe(false);
324 });
325
326 it('a revoked key from project B cannot be deleted via project A’s URL → 403 cross_project', async () => {
327 const revokedKey = makeRevokedKey('mck_b', 'proj_B');
328 const fakes = makeFakes({
329 planByProject: { proj_A: 'team', proj_B: 'team' },
330 globalOn: true,
331 seedKeys: [revokedKey],
332 });
333 const app = mountApp({
334 middleware: [fakeAuth('admin')],
335 accessDeps: fakes.accessDeps,
336 readDeps: fakes.readDeps,
337 });
338 const res = await app.request('/v1/projects/proj_A/mcp/keys/mck_b/delete', POST);
339 expect(res.status).toBe(403);
340 const body = (await res.json()) as { code?: string };
341 expect(body.code).toBe('cross_project');
342 // never deleted — the row survives
343 expect(fakes.deleted).toHaveLength(0);
344 expect(fakes.keys.has('mck_b')).toBe(true);
345 });
346
347 it('an unknown key → 404', async () => {
348 const fakes = makeFakes({ planByProject: { proj_A: 'team' }, globalOn: true });
349 const app = mountApp({
350 middleware: [fakeAuth('admin')],
351 accessDeps: fakes.accessDeps,
352 readDeps: fakes.readDeps,
353 });
354 const res = await app.request('/v1/projects/proj_A/mcp/keys/mck_nope/delete', POST);
355 expect(res.status).toBe(404);
356 });
357});