* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy Multiple people in one company becomes a paid capability (multi_user, the eighth PAID key). Derived at access time from capability_grants, no status column, no enforcement cron: - entitled: active grant (trial/stripe/team/manual/comp), everyone works - grace: newest grant expired < 20 days ago; countdown banner for everyone in companies with > 1 user; invites still allowed - frozen: only role=owner resolves; other memberships go dormant (rows untouched, paying reactivates instantly); invites 403 with paid-plan upsell Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin untouched: they also run on self-hosts, where the gate never bites), gated query fallback for service-role/API-key paths, setActiveCompany guard, MCP company-access check, invite route. Middleware routes all-frozen users to a new /paused page; the switcher greys locked companies. Migration 20260901081417 (applied to staging): trial trigger seeds multi_user, backfills for mid-trial companies, active Stripe subs, team agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20 days) for existing unpaid multi-member companies. Daily cron mails owners at grace start and last day. Strings in sv+en; pg-real + unit tests included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): multi-user seat gate hardening from skeptic review - Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting it: the 20-day grace window hangs on an expired row, so a deleted one froze churned payers' staff instantly with no banner and no mail. Other stripe grants keep the freeze-and-retain delete. - New SECURITY DEFINER company_multi_user_state() RPC (migration 20260901083726, applied to staging) and RPC-first getMultiUserState: capability_grants RLS hides team-scoped rows from non-team users, so user-client reads misread byra-covered companies as frozen (switch refusal, wrong switcher locks). - Byra-kind teams get a standing team-scoped multi_user grant (backfill + teams trigger): byra client companies have no company-scoped trial by design, so a grantless byra team would freeze every consultant and client user. - Comped/manual companies with active PAID-key grants extend to multi_user (a comped company must not read as paying while locking out user two). - /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403). - PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user rows; the gated fallback would have frozen every non-owner mid-deploy). - Grace cron: covers team-scoped lapses (byra agreement ending) and skips the start mail for the hand-mailed grandfather cohort. - Tests updated/added across all touched surfaces; pg tests for the new RPC and byra trigger; trial-suppression pg test extended to 8 keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): decouple seat-gate env check and fail open on gate read throws CI round 1 on #2099: - isMultiUserEnforced no longer imports has-capability: several route test suites partially mock that module and the vitest mock guard threw from inside the v1 seat gate, turning expected 4xx responses into 500s. multi_user is never a connector capability, so the bypass reduces to the same env reads, now inlined. - getMultiUserState wraps its resolution in a fail-open try/catch: a client without .rpc or a thrown network error must never lock users out. - no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or() scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's timestamp .or(); all columns in both strings are literals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3) company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and were granted to authenticated with a caller-supplied company UUID: any logged-in user could probe an arbitrary company's billing state and grace deadline across tenants. Migration 20260901091752 (applied to staging) requires an auth.uid() membership in the target company when a JWT is present, keeps service-role/definer contexts unrestricted, and clamps the grace window to [0, 20] days. pg tests: stranger gets false/NULL, member reads normally, oversized p_grace_days cannot widen the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
760 lines
24 KiB
TypeScript
760 lines
24 KiB
TypeScript
/**
|
|
* 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<typeof import('@/lib/auth/api-keys')>('@/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<typeof import('@supabase/supabase-js')>('@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<typeof import('@/lib/entitlements/multi-user')>(
|
|
'@/lib/entitlements/multi-user',
|
|
)
|
|
return {
|
|
...actual,
|
|
getMultiUserState: (...args: unknown[]) => getMultiUserStateMock(...args),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/api/idempotency', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
|
|
'@/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<typeof vi.fn>
|
|
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
|
const mockCheckIdempotency = checkIdempotencyKey as ReturnType<typeof vi.fn>
|
|
const mockStoreIdempotency = storeIdempotencyResponse as ReturnType<typeof vi.fn>
|
|
|
|
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<Record<string, never>> }
|
|
}
|
|
|
|
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
|