mcp-access.test.ts267 lines · main
| 1 | import { describe, expect, test } from 'bun:test'; |
| 2 | |
| 3 | import type { AuditEntry } from './audit.js'; |
| 4 | import { |
| 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'; |
| 20 | import type { McpKey, NewMcpKey, ProjectTier } from '../db/schema.js'; |
| 21 | |
| 22 | /* ─── in-memory deps so the mutators are testable without a DB ──────────── */ |
| 23 | |
| 24 | interface Fake { |
| 25 | deps: McpAccessDeps; |
| 26 | audits: AuditEntry[]; |
| 27 | keys: Map<string, McpKey>; |
| 28 | projectEnabled: Map<string, boolean>; |
| 29 | globalOn: boolean | null; |
| 30 | } |
| 31 | |
| 32 | function 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 | |
| 92 | const actor: McpActor = { id: 'usr_admin', ipHash: null, userAgent: null }; |
| 93 | |
| 94 | describe('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 | |
| 106 | describe('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 | |
| 143 | describe('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 | |
| 180 | describe('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 | |
| 205 | describe('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 | |
| 241 | describe('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 | |
| 259 | describe('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 | }); |