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 | |
| 8 | import { describe, expect, it } from 'bun:test'; |
| 9 | import type { MiddlewareHandler } from 'hono'; |
| 10 | |
| 11 | import { errorHandler } from '../middleware/error.js'; |
| 12 | import { requireProjectRole } from '../middleware/project-auth.js'; |
| 13 | import { buildProjectMcpRouter, type ProjectMcpReadDeps } from './project-mcp.js'; |
| 14 | import type { McpAccessDeps } from '../services/mcp-access.js'; |
| 15 | import type { McpKey, MemberRole, NewMcpKey, ProjectTier } from '../db/schema.js'; |
| 16 | |
| 17 | /* ─── fakes ──────────────────────────────────────────────────────────────── */ |
| 18 | |
| 19 | interface 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 | |
| 28 | function 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. */ |
| 109 | function 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)`. |
| 121 | function 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. |
| 131 | const POST = { method: 'POST', headers: { 'x-forwarded-for': '203.0.113.7' } } as const; |
| 132 | |
| 133 | function 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. */ |
| 151 | function makeRevokedKey(id: string, projectId: string): McpKey { |
| 152 | return { ...makeKey(id, projectId), enabled: false, revokedAt: new Date() }; |
| 153 | } |
| 154 | |
| 155 | /* ─── 1. plan gate ─────────────────────────────────────────────────────────── */ |
| 156 | |
| 157 | describe('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 | |
| 203 | describe('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 | |
| 231 | describe('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 | |
| 284 | describe('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 | }); |