|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { |
| 5 | + V2_OPERATION_RATE_LIMIT_ALLOWED, |
| 6 | + V2_PREAUTH_RATE_LIMIT_ALLOWED, |
| 7 | + v2ApiKeyAuthModuleMock, |
| 8 | + v2RateLimiterModuleMock, |
| 9 | + v2RouteMocks, |
| 10 | +} from '@sim/testing' |
| 11 | +import { NextRequest } from 'next/server' |
| 12 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 13 | + |
| 14 | +const mocks = vi.hoisted(() => ({ |
| 15 | + getWorkspace: vi.fn(), |
| 16 | + listMembers: vi.fn(), |
| 17 | +})) |
| 18 | + |
| 19 | +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) |
| 20 | +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) |
| 21 | + |
| 22 | +vi.mock('@/lib/workspaces/application/get-public-workspace', () => ({ |
| 23 | + getPublicWorkspace: { |
| 24 | + operation: { id: 'workspaces.read_public_detail' }, |
| 25 | + execute: mocks.getWorkspace, |
| 26 | + }, |
| 27 | +})) |
| 28 | + |
| 29 | +vi.mock('@/lib/workspaces/application/list-public-workspace-members', () => ({ |
| 30 | + listPublicWorkspaceMembers: { |
| 31 | + operation: { id: 'workspaces.members.list_public' }, |
| 32 | + execute: mocks.listMembers, |
| 33 | + }, |
| 34 | +})) |
| 35 | + |
| 36 | +import { |
| 37 | + InsufficientWorkspacePermissionsError, |
| 38 | + NoWorkspaceAccessError, |
| 39 | + WorkspaceApiKeyScopeAuthorizationError, |
| 40 | +} from '@/lib/core/application' |
| 41 | +import { OrchestrationError } from '@/lib/core/orchestration/types' |
| 42 | +import { GET as listMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' |
| 43 | +import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route' |
| 44 | + |
| 45 | +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' |
| 46 | +const auth = { |
| 47 | + principal: { |
| 48 | + kind: 'workspace_api_key' as const, |
| 49 | + workspaceId: WORKSPACE_ID, |
| 50 | + keyId: 'key-1', |
| 51 | + }, |
| 52 | + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, |
| 53 | + rateLimitSubscription: null, |
| 54 | + keyType: 'workspace' as const, |
| 55 | +} |
| 56 | + |
| 57 | +/** |
| 58 | + * The two reads a workspace id is addressable through. Both must conceal the |
| 59 | + * same way, or the pair that still answers `403` is the oracle. |
| 60 | + */ |
| 61 | +const routes = [ |
| 62 | + { |
| 63 | + name: 'workspace detail', |
| 64 | + spy: mocks.getWorkspace, |
| 65 | + call: () => |
| 66 | + getWorkspace(new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), { |
| 67 | + params: Promise.resolve({ workspaceId: WORKSPACE_ID }), |
| 68 | + }), |
| 69 | + }, |
| 70 | + { |
| 71 | + name: 'member roster', |
| 72 | + spy: mocks.listMembers, |
| 73 | + call: () => |
| 74 | + listMembers( |
| 75 | + new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members`), |
| 76 | + { params: Promise.resolve({ workspaceId: WORKSPACE_ID }) } |
| 77 | + ), |
| 78 | + }, |
| 79 | +] as const |
| 80 | + |
| 81 | +describe.each(routes)('v2 $name workspace concealment', ({ spy, call }) => { |
| 82 | + beforeEach(() => { |
| 83 | + vi.clearAllMocks() |
| 84 | + v2RouteMocks.authenticate.mockResolvedValue(auth) |
| 85 | + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) |
| 86 | + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) |
| 87 | + }) |
| 88 | + |
| 89 | + /** |
| 90 | + * Asserted as equality between the two responses rather than against a |
| 91 | + * literal: the leak is the DIFFERENCE, so a future rewording of either leg |
| 92 | + * must not be able to reintroduce it while the test still passes. |
| 93 | + */ |
| 94 | + it.each([ |
| 95 | + ['a workspace key scoped elsewhere', () => new WorkspaceApiKeyScopeAuthorizationError()], |
| 96 | + ['a non-member personal key', () => new NoWorkspaceAccessError()], |
| 97 | + ])('answers an unreachable workspace exactly as an absent one for %s', async (_label, raise) => { |
| 98 | + spy.mockRejectedValueOnce(new OrchestrationError('not_found', 'Workspace not found')) |
| 99 | + const absent = await call() |
| 100 | + const absentBody = await absent.json() |
| 101 | + |
| 102 | + spy.mockRejectedValueOnce(raise()) |
| 103 | + const unreachable = await call() |
| 104 | + |
| 105 | + expect(unreachable.status).toBe(absent.status) |
| 106 | + expect(await unreachable.json()).toEqual(absentBody) |
| 107 | + expect(absent.status).toBe(404) |
| 108 | + expect(absentBody).toEqual({ |
| 109 | + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, |
| 110 | + }) |
| 111 | + }) |
| 112 | + |
| 113 | + /** |
| 114 | + * The negative leg. A caller already inside the workspace knows it exists, so |
| 115 | + * a role refusal stays an actionable `403` — concealing it too would widen the |
| 116 | + * policy past what it is for. |
| 117 | + */ |
| 118 | + it('still refuses an in-workspace role denial with 403', async () => { |
| 119 | + spy.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) |
| 120 | + |
| 121 | + const response = await call() |
| 122 | + |
| 123 | + expect(response.status).toBe(403) |
| 124 | + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) |
| 125 | + }) |
| 126 | +}) |
0 commit comments