c31933b15b
* perf(api): write routes stop re-resolving the active company
withRouteContext resolves the active company (one resolve_active_company
RPC, ~40 ms p50 on prod) and then, for the 256 routes that pass
requireWrite: true, called requireWritePermission(), which resolved it a
second time before its role select. Two sequential round trips repeating
work the wrapper had just done, on every mutating request.
requireWritePermission() and getCompanyRole() now accept an optional
`known` context; the wrapper passes { companyId }, so the helper goes
straight to the membership select. Callers that pass nothing behave
exactly as before, and the shared selectRole() keeps both helpers on the
same query. The role is still looked up, never trusted from the caller.
Tests: known companyId skips resolution, known role skips the select, a
known viewer is still 403, a known company without a membership row is
still 403, legacy calls unchanged; new lib/api/__tests__/with-route-
context.test.ts pins that the wrapper resolves the company exactly once,
hands it to the guard, never calls the guard on read routes, passes the
guard's 403 through with a request id, and emits Server-Timing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(customers): viewer gate expects the wrapper to hand over the resolved company
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import { createMockSupabase } from '@/tests/helpers'
|
|
|
|
const authState = vi.hoisted(() => ({
|
|
user: { id: 'user-1' } as { id: string } | null,
|
|
}))
|
|
|
|
const requireWriteMock = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: vi.fn(async () => {
|
|
if (!authState.user) {
|
|
return { error: NextResponse.json({ error: 'unauthorized' }, { status: 401 }) }
|
|
}
|
|
return { user: authState.user, supabase: supabaseRef.supabase }
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
|
}))
|
|
|
|
const supabaseRef = vi.hoisted(() => ({ supabase: null as unknown }))
|
|
|
|
import { withRouteContext } from '../with-route-context'
|
|
import { getActiveCompanyId } from '@/lib/company/context'
|
|
|
|
const EMPTY_PARAMS = { params: Promise.resolve({}) }
|
|
|
|
describe('withRouteContext', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
authState.user = { id: 'user-1' }
|
|
supabaseRef.supabase = createMockSupabase().supabase
|
|
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
it('resolves the company once and hands it to the write guard on write routes', async () => {
|
|
const handler = vi.fn(async () => NextResponse.json({ ok: true }))
|
|
const route = withRouteContext('test.write', handler, { requireWrite: true })
|
|
|
|
const res = await route(new Request('http://localhost/api/test', { method: 'POST' }), EMPTY_PARAMS)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(getActiveCompanyId).toHaveBeenCalledTimes(1)
|
|
expect(requireWriteMock).toHaveBeenCalledTimes(1)
|
|
expect(requireWriteMock).toHaveBeenCalledWith(supabaseRef.supabase, 'user-1', {
|
|
companyId: 'company-1',
|
|
})
|
|
expect(handler).toHaveBeenCalledWith(
|
|
expect.any(Request),
|
|
expect.objectContaining({ companyId: 'company-1', user: { id: 'user-1' } }),
|
|
EMPTY_PARAMS,
|
|
)
|
|
})
|
|
|
|
it('never invokes the write guard on read routes', async () => {
|
|
const route = withRouteContext('test.read', async () => NextResponse.json({ ok: true }))
|
|
|
|
const res = await route(new Request('http://localhost/api/test'), EMPTY_PARAMS)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(getActiveCompanyId).toHaveBeenCalledTimes(1)
|
|
expect(requireWriteMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('passes the guard 403 through with a request id and skips the handler', async () => {
|
|
requireWriteMock.mockResolvedValue({
|
|
ok: false,
|
|
response: NextResponse.json({ error: 'viewer' }, { status: 403 }),
|
|
})
|
|
const handler = vi.fn(async () => NextResponse.json({ ok: true }))
|
|
const route = withRouteContext('test.write', handler, { requireWrite: true })
|
|
|
|
const res = await route(new Request('http://localhost/api/test', { method: 'POST' }), EMPTY_PARAMS)
|
|
|
|
expect(res.status).toBe(403)
|
|
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
|
|
expect(handler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns COMPANY_CONTEXT_MISSING before the guard when no company resolves', async () => {
|
|
vi.mocked(getActiveCompanyId).mockResolvedValue(null)
|
|
const route = withRouteContext('test.write', async () => NextResponse.json({ ok: true }), {
|
|
requireWrite: true,
|
|
})
|
|
|
|
const res = await route(new Request('http://localhost/api/test', { method: 'POST' }), EMPTY_PARAMS)
|
|
|
|
expect(res.status).toBe(400)
|
|
expect(requireWriteMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 401 from requireAuth untouched except for the request id', async () => {
|
|
authState.user = null
|
|
const route = withRouteContext('test.read', async () => NextResponse.json({ ok: true }))
|
|
|
|
const res = await route(new Request('http://localhost/api/test'), EMPTY_PARAMS)
|
|
|
|
expect(res.status).toBe(401)
|
|
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
|
|
expect(getActiveCompanyId).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('emits a Server-Timing header with the auth, company and handler phases', async () => {
|
|
const route = withRouteContext('test.read', async () => NextResponse.json({ ok: true }))
|
|
|
|
const res = await route(new Request('http://localhost/api/test'), EMPTY_PARAMS)
|
|
|
|
expect(res.headers.get('Server-Timing')).toMatch(
|
|
/^auth;dur=\d+, company;dur=\d+, handler;dur=\d+$/,
|
|
)
|
|
})
|
|
})
|