/** * Integration tests for the v1 wrapper. * * Mocks `validateApiKey` and `createServiceClientNoCookies` so we can exercise * the wrapper's auth / scope / company-membership / idempotency / dry-run * branches deterministically. */ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { NextResponse } from 'next/server' beforeAll(() => { // The wrapper's public-scope path now fails closed if these env vars are // missing; tests don't run against a real Supabase instance so we stub // values just to clear the guard. process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' }) vi.mock('@/lib/auth/api-keys', async () => { const actual = await vi.importActual('@/lib/auth/api-keys') return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn(), } }) // The wrapper's public-scope path calls @supabase/supabase-js#createClient // directly to obtain an anon-key client (no service-role privilege). // Stub it so tests don't need real SUPABASE env vars. vi.mock('@supabase/supabase-js', async () => { const actual = await vi.importActual('@supabase/supabase-js') return { ...actual, createClient: vi.fn().mockReturnValue({}), } }) // Multi-user seat gate: mocked so the membership stub stays single-purpose; // the gate's own logic is covered in lib/entitlements/__tests__/multi-user.test.ts. const getMultiUserStateMock = vi.hoisted(() => vi.fn()) vi.mock('@/lib/entitlements/multi-user', async () => { const actual = await vi.importActual( '@/lib/entitlements/multi-user', ) return { ...actual, getMultiUserState: (...args: unknown[]) => getMultiUserStateMock(...args), } }) vi.mock('@/lib/api/idempotency', async () => { const actual = await vi.importActual( '@/lib/api/idempotency', ) return { ...actual, checkIdempotencyKey: vi.fn(), storeIdempotencyResponse: vi.fn(), } }) import { validateApiKey, createServiceClientNoCookies, RATE_LIMIT_RETRY_AFTER_SECONDS, } from '@/lib/auth/api-keys' import { checkIdempotencyKey, storeIdempotencyResponse, } from '@/lib/api/idempotency' import { truncateIp, withApiV1 } from '../with-api-v1' import { ok } from '../response' const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType const mockCheckIdempotency = checkIdempotencyKey as ReturnType const mockStoreIdempotency = storeIdempotencyResponse as ReturnType function makeSupabaseStub(membership: { company_id: string; role: string } | null) { return { from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }), }), }), }), }), } } function makeRequest(url: string, init?: RequestInit): Request { return new Request(url, init) } // Helper that wraps an empty params promise for non-dynamic routes. function emptyParams() { return { params: Promise.resolve({}) } } // What Next.js 16 ACTUALLY passes to a STATIC route's handler: `{ params: // undefined }` (app-route module: `params: context.params ? ... : undefined`). // `emptyParams()` above is NOT what the runtime hands a static route, so it // masked #781. Use this for the real static-route contract. function staticRouteContext() { return { params: undefined } as unknown as { params: Promise> } } function companyParams(companyId: string) { return { params: Promise.resolve({ companyId }) } } beforeEach(() => { vi.clearAllMocks() mockServiceClient.mockReturnValue(makeSupabaseStub(null)) getMultiUserStateMock.mockResolvedValue({ state: 'entitled', graceEndsAt: null }) }) describe('withApiV1: auth', () => { it('returns 401 when Authorization header is missing', async () => { const handler = withApiV1('companies.list', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), ) const res = await handler(makeRequest('https://x.test/api/v1/companies'), emptyParams()) expect(res.status).toBe(401) const body = await res.json() expect(body.error.code).toBe('UNAUTHORIZED') expect(body.error.request_id).toMatch(/^req_/) }) it('returns 401 when validateApiKey rejects the token', async () => { mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 }) const handler = withApiV1('companies.list', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/companies', { headers: { Authorization: 'Bearer gnubok_sk_invalid' }, }), emptyParams(), ) expect(res.status).toBe(401) }) it('returns 429 when the underlying key is rate-limited', async () => { mockValidate.mockResolvedValue({ error: 'Rate limit exceeded', status: 429 }) const handler = withApiV1('companies.list', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/companies', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), emptyParams(), ) expect(res.status).toBe(429) const body = await res.json() expect(body.error.code).toBe('RATE_LIMITED') // The published skill instructs agents to honor Retry-After on a 429. // Before this header existed that instruction pointed at nothing, so an // unattended client had to back off blindly. expect(res.headers.get('Retry-After')).toBe(String(RATE_LIMIT_RETRY_AFTER_SECONDS)) }) it('does not advertise Retry-After on a non-throttle error', async () => { mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 }) const handler = withApiV1('companies.list', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/companies', { headers: { Authorization: 'Bearer gnubok_sk_invalid' }, }), emptyParams(), ) expect(res.status).toBe(401) expect(res.headers.get('Retry-After')).toBeNull() }) }) describe('withApiV1: scope', () => { it('returns 403 INSUFFICIENT_SCOPE when the key lacks the required scope', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', apiKeyId: 'ak_1', apiKeyName: 'test key', scopes: ['invoices:read'], // wrong scope mode: 'live', }) const handler = withApiV1('companies.list', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/companies', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), emptyParams(), ) expect(res.status).toBe(403) const body = await res.json() expect(body.error.code).toBe('INSUFFICIENT_SCOPE') expect(body.error.details.required_scope).toBe('companies:read') }) it('returns 404 NOT_FOUND for unregistered endpoints (no leak)', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['companies:read'], mode: 'live', }) const handler = withApiV1('mystery.endpoint', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/mystery', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), emptyParams(), ) expect(res.status).toBe(404) }) }) describe('withApiV1: company membership', () => { it('returns 404 when the URL companyId is not a company the user belongs to', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['companies:read'], mode: 'live', }) // No membership → null mockServiceClient.mockReturnValue(makeSupabaseStub(null)) const handler = withApiV1( 'companies.get', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), { requireScope: 'companies:read' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/other-company', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('other-company'), ) expect(res.status).toBe(404) const body = await res.json() expect(body.error.code).toBe('NOT_FOUND') }) it('allows the request when the user has membership in the URL company', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['companies:read'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) const handler = withApiV1( 'companies.get', async (_req, ctx) => ok({ companyId: ctx.companyId }, { requestId: ctx.requestId }), { requireScope: 'companies:read' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(200) const body = await res.json() expect(body.data.companyId).toBe('company-1') }) it('refuses a NON-OWNER membership in a frozen company with 403 (multi-user seat gate)', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['companies:read'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'admin' })) getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null }) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'companies.get', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), { requireScope: 'companies:read' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(403) const body = await res.json() expect(body.error.code).toBe('FORBIDDEN') expect(body.error.details.capability).toBe('multi_user') }) it('OWNERS pass the seat gate without a state read; grace passes for non-owners', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['companies:read'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null }) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'companies.get', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), { requireScope: 'companies:read' }, ) const ownerRes = await handler( makeRequest('https://x.test/api/v1/companies/company-1', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(ownerRes.status).toBe(200) expect(getMultiUserStateMock).not.toHaveBeenCalled() mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'member' })) getMultiUserStateMock.mockResolvedValue({ state: 'grace', graceEndsAt: new Date(Date.now() + 5 * 86_400_000).toISOString(), }) const graceRes = await handler( makeRequest('https://x.test/api/v1/companies/company-1', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(graceRes.status).toBe(200) }) }) describe('withApiV1: static (non-dynamic) route params', () => { // Regression for #781: GET /api/v1/companies is the only authenticated // static route. Next.js 16 hands it `{ params: undefined }`. The wrapper // must not null-deref on `params.params` after auth succeeds. it('does not 500 when Next passes { params: undefined } for a static route', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: undefined, scopes: ['companies:read'], mode: 'live', }) let observedCompanyId: string | undefined = 'sentinel' let handlerCalled = false const handler = withApiV1('companies.list', async (_req, ctx) => { handlerCalled = true observedCompanyId = ctx.companyId return ok({ ok: true }, { requestId: ctx.requestId }) }) const res = await handler( makeRequest('https://x.test/api/v1/companies', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), staticRouteContext(), ) expect(res.status).toBe(200) expect(handlerCalled).toBe(true) expect(observedCompanyId).toBeUndefined() }) }) describe('withApiV1: idempotency', () => { it('replays a cached response when the idempotency key matches', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['invoices:write'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) mockCheckIdempotency.mockResolvedValue({ status: 'success', body: { data: { id: 'inv-cached' } }, }) const handler = withApiV1( 'invoices.create', async () => { return NextResponse.json({ data: { id: 'inv-fresh' } }, { status: 201 }) }, { requireScope: 'invoices:write' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/invoices', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x', 'Idempotency-Key': 'key-1', 'Content-Type': 'application/json', }, body: JSON.stringify({ customer_id: 'cust-1' }), }), companyParams('company-1'), ) expect(res.headers.get('Idempotent-Replayed')).toBe('true') const body = await res.json() expect(body.data.id).toBe('inv-cached') }) it('honors the require-idempotency-key option', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['invoices:write'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) const handler = withApiV1( 'invoices.create', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), { requireScope: 'invoices:write', requireIdempotencyKey: true }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/invoices', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(400) const body = await res.json() expect(body.error.code).toBe('VALIDATION_ERROR') }) it('hashes a cloned body and leaves the original readable by the handler', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['invoices:write'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) mockCheckIdempotency.mockResolvedValue(null) let observedBody: unknown const handler = withApiV1( 'invoices.create', async (request, ctx) => { observedBody = await request.json() return ok({ ok: true }, { requestId: ctx.requestId }) }, { requireScope: 'invoices:write' }, ) const requestBody = { customer_id: 'cust-1', additional_cc: ['copy@example.test'] } const response = await handler( makeRequest('https://x.test/api/v1/companies/company-1/invoices', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x', 'Idempotency-Key': 'key-body-readable', 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }), companyParams('company-1'), ) expect(response.status).toBe(200) expect(observedBody).toEqual(requestBody) expect(mockCheckIdempotency).toHaveBeenCalledWith( expect.anything(), 'user-1', 'company-1', 'key-body-readable', expect.any(String), ) }) }) describe('withApiV1: dry-run', () => { it('threads dry_run=true from query string into context', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['invoices:write'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) let observedDryRun: boolean | null = null const handler = withApiV1( 'invoices.create', async (_req, ctx) => { observedDryRun = ctx.dryRun return ok({ ok: true }, { requestId: ctx.requestId, dryRun: ctx.dryRun }) }, { requireScope: 'invoices:write' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/invoices?dry_run=true', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json', }, body: JSON.stringify({}), }), companyParams('company-1'), ) expect(observedDryRun).toBe(true) expect(res.headers.get('X-Dry-Run')).toBe('true') }) it('threads X-Dry-Run header into context', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['invoices:write'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) let observedDryRun: boolean | null = null const handler = withApiV1( 'invoices.create', async (_req, ctx) => { observedDryRun = ctx.dryRun return ok({ ok: true }, { requestId: ctx.requestId }) }, { requireScope: 'invoices:write' }, ) await handler( makeRequest('https://x.test/api/v1/companies/company-1/invoices', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x', 'X-Dry-Run': 'true', 'Content-Type': 'application/json', }, body: '{}', }), companyParams('company-1'), ) expect(observedDryRun).toBe(true) }) }) describe('withApiV1: test mode', () => { it('blocks a test-key write on a non-simulatable endpoint (403 TEST_KEY_WRITE_BLOCKED)', async () => { // No route modules are imported here, so the endpoint registry is empty → // getEndpointByConcretePath returns undefined → the wrapper must refuse the // write rather than let a test key mutate real data. mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['invoices:write'], mode: 'test', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) let handlerCalled = false const handler = withApiV1( 'invoices.create', async (_req, ctx) => { handlerCalled = true return ok({ ok: true }, { requestId: ctx.requestId }) }, { requireScope: 'invoices:write' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/invoices', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' }, body: '{}', }), companyParams('company-1'), ) expect(res.status).toBe(403) const body = await res.json() expect(body.error.code).toBe('TEST_KEY_WRITE_BLOCKED') expect(handlerCalled).toBe(false) }) it('allows a test-key READ unchanged: no forced dry-run, real data, X-Gnubok-Mode header', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['companies:read'], mode: 'test', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) let observedDryRun: boolean | null = null const handler = withApiV1( 'companies.get', async (_req, ctx) => { observedDryRun = ctx.dryRun return ok({ ok: true }, { requestId: ctx.requestId }) }, { requireScope: 'companies:read' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(200) expect(observedDryRun).toBe(false) expect(res.headers.get('X-Gnubok-Mode')).toBe('test') }) }) describe('withApiV1: public endpoints', () => { it('invokes the handler without authentication for /api/v1/health', async () => { let observedUserId: string | null = null const handler = withApiV1('health.check', async (_req, ctx) => { observedUserId = ctx.userId return ok({ status: 'ok' }, { requestId: ctx.requestId }) }) const res = await handler(makeRequest('https://x.test/api/v1/health'), emptyParams()) expect(res.status).toBe(200) expect(observedUserId).toBe('anonymous') }) it('opportunistically attributes a valid Bearer token on a public route', async () => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', apiKeyId: 'ak_1', apiKeyName: 'CI key', scopes: ['companies:read'], mode: 'live', }) let observedUserId: string | null = null let observedApiKeyId: string | undefined const handler = withApiV1('health.check', async (_req, ctx) => { observedUserId = ctx.userId observedApiKeyId = ctx.apiKeyId return ok({ status: 'ok' }, { requestId: ctx.requestId }) }) const res = await handler( makeRequest('https://x.test/api/v1/health', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), emptyParams(), ) expect(res.status).toBe(200) expect(observedUserId).toBe('user-1') expect(observedApiKeyId).toBe('ak_1') }) it('silently downgrades an invalid Bearer token to anon on a public route', async () => { mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 }) let observedUserId: string | null = null const handler = withApiV1('health.check', async (_req, ctx) => { observedUserId = ctx.userId return ok({ status: 'ok' }, { requestId: ctx.requestId }) }) const res = await handler( makeRequest('https://x.test/api/v1/health', { headers: { Authorization: 'Bearer gnubok_sk_invalid' }, }), emptyParams(), ) expect(res.status).toBe(200) expect(observedUserId).toBe('anonymous') }) }) describe('withApiV1: stable headers', () => { it('always stamps X-Request-Id and Gnubok-Version', async () => { const handler = withApiV1('health.check', async (_req, ctx) => ok({ status: 'ok' }, { requestId: ctx.requestId }), ) const res = await handler(makeRequest('https://x.test/api/v1/health'), emptyParams()) expect(res.headers.get('X-Request-Id')).toMatch(/^req_/) expect(res.headers.get('Gnubok-Version')).toMatch(/^\d{4}-\d{2}-\d{2}$/) }) }) describe('truncateIp: privacy-preserving IP logging', () => { it('truncates IPv4 to /24', () => { expect(truncateIp('203.0.113.42')).toBe('203.0.113.0/24') }) it('truncates IPv6 to /48', () => { expect(truncateIp('2001:db8:abcd:1234::1')).toBe('2001:db8:abcd::/48') }) it('returns undefined for an empty IP', () => { expect(truncateIp(undefined)).toBeUndefined() expect(truncateIp('')).toBeUndefined() }) it('returns undefined for malformed input rather than leaking it raw', () => { expect(truncateIp('not-an-ip')).toBeUndefined() }) it('rejects IPv4 with out-of-range octets to avoid pseudo-IPs in audit logs', () => { expect(truncateIp('999.999.999.999')).toBeUndefined() expect(truncateIp('256.0.0.1')).toBeUndefined() expect(truncateIp('192.168.1.300')).toBeUndefined() }) it('accepts edge IPv4 octets (0 and 255)', () => { expect(truncateIp('0.0.0.0')).toBe('0.0.0.0/24') expect(truncateIp('255.255.255.255')).toBe('255.255.255.0/24') }) }) // Suppress unused-import warning: we re-export to keep the type chain visible. void mockStoreIdempotency describe('withApiV1: read-only role gate (viewer)', () => { // The v1 surface runs as the service role, so RLS never sees the viewer. // This wrapper is the only place the read-only role is enforced for API // keys; the DB triggers that block viewer writes apply to cookie sessions. function viewerKey(scopes: string[]) { mockValidate.mockResolvedValue({ userId: 'user-viewer', companyId: 'company-1', scopes, mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'viewer' })) } it('refuses a viewer key on POST journal-entries with 403 ROLE_READ_ONLY before the handler runs', async () => { viewerKey(['bookkeeping:write']) const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) => ok({ id: 'je-1' }, { requestId: ctx.requestId }), ) // No requireScope override: the real catalogue entry for the route is what // classifies the request ('POST .../journal-entries' -> bookkeeping:write). const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'journal-entries.create', handlerSpy, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'x', lines: [] }), }), companyParams('company-1'), ) expect(res.status).toBe(403) const body = await res.json() expect(body.error.code).toBe('FORBIDDEN') expect(body.error.details.code).toBe('ROLE_READ_ONLY') expect(body.error.details.role).toBe('viewer') expect(body.error.details.required_scope).toBe('bookkeeping:write') // Swedish user-facing message from the registry entry. expect(body.error.message).toBe('Du har inte behörighet att utföra denna åtgärd.') expect(body.error.request_id).toMatch(/^req_/) expect(handlerSpy).not.toHaveBeenCalled() // Refused before the seat gate: no extra read for a refused write. expect(getMultiUserStateMock).not.toHaveBeenCalled() }) it('refuses a viewer even when the write is a dry-run (nothing to simulate for a read-only role)', async () => { viewerKey(['invoices:write']) const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) => ok({ ok: true }, { requestId: ctx.requestId }), ) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'invoices.create', handlerSpy, { requireScope: 'invoices:write' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/invoices?dry_run=true', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(403) const body = await res.json() expect(body.error.details.code).toBe('ROLE_READ_ONLY') expect(handlerSpy).not.toHaveBeenCalled() }) it('refuses a viewer on a GET whose scope is an elevated grant (webhooks:manage)', async () => { viewerKey(['webhooks:manage']) const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) => ok({ webhooks: [] }, { requestId: ctx.requestId }), ) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'webhooks.list', handlerSpy, { requireScope: 'webhooks:manage' }, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/webhooks', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(403) const body = await res.json() expect(body.error.details.code).toBe('ROLE_READ_ONLY') expect(body.error.details.required_scope).toBe('webhooks:manage') expect(handlerSpy).not.toHaveBeenCalled() }) it('leaves a viewer GET on a :read scope untouched (200, seat gate still consulted)', async () => { viewerKey(['reports:read']) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'journal-entries.list', async (_req, ctx) => ok({ entries: [], companyId: ctx.companyId }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(200) const body = await res.json() expect(body.data.companyId).toBe('company-1') // A viewer is a non-owner: the dormant-seat logic still runs for reads. expect(getMultiUserStateMock).toHaveBeenCalledTimes(1) }) it('still applies the seat gate to a viewer read in a frozen company', async () => { viewerKey(['reports:read']) getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null }) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'journal-entries.list', async (_req, ctx) => ok({ entries: [] }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', { headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(403) const body = await res.json() expect(body.error.details.capability).toBe('multi_user') expect(body.error.details.code).toBeUndefined() }) it.each(['member', 'admin', 'owner'])('lets a %s key through the role gate on POST journal-entries', async (role) => { mockValidate.mockResolvedValue({ userId: 'user-1', companyId: 'company-1', scopes: ['bookkeeping:write'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role })) const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) => NextResponse.json({ data: { id: 'je-1', requestId: ctx.requestId } }, { status: 201 }), ) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'journal-entries.create', handlerSpy, ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'x', lines: [] }), }), companyParams('company-1'), ) expect(res.status).toBe(201) expect(handlerSpy).toHaveBeenCalledTimes(1) }) it('keeps the non-member answer unchanged: 404, no role information leaks', async () => { mockValidate.mockResolvedValue({ userId: 'user-outsider', companyId: 'company-2', scopes: ['bookkeeping:write'], mode: 'live', }) mockServiceClient.mockReturnValue(makeSupabaseStub(null)) const handler = withApiV1<{ params: Promise<{ companyId: string }> }>( 'journal-entries.create', async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }), ) const res = await handler( makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', { method: 'POST', headers: { Authorization: 'Bearer gnubok_sk_x' }, }), companyParams('company-1'), ) expect(res.status).toBe(404) const body = await res.json() expect(body.error.code).toBe('NOT_FOUND') expect(body.error.details.role).toBeUndefined() }) })