Files
accounted/app/api/settings/api-keys/__tests__/route.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

247 lines
8.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
// ── Mocks ──────────────────────────────────────────────────────────
// withRouteContext resolves auth via requireAuth (createClient under the hood),
// the active company via getActiveCompanyId, and the write gate via
// requireWritePermission. Mock all three so we can drive each branch.
const mockSupabase = {
auth: { getUser: vi.fn() },
from: vi.fn(),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
const getActiveCompanyIdMock = vi.fn()
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args),
}))
const requireWritePermissionMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
}))
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
// Records the payload passed to .insert(), and lets us program the count
// returned by the quota pre-check and the row returned by the insert.
function setupFrom(opts: {
count?: number | null
insertResult?: { data?: unknown; error?: unknown }
}) {
const insertSpy = vi.fn()
mockSupabase.from.mockImplementation(() => {
// The quota pre-check: .select(..., { head: true }).eq().is() → resolves
// to { count }. The insert: .insert().select().single() → resolves to the
// row. We expose both via a single chainable proxy whose terminal value
// depends on whether insert() was called.
let isInsert = false
const result = () =>
isInsert
? Promise.resolve({
data: opts.insertResult?.data ?? null,
error: opts.insertResult?.error ?? null,
})
: Promise.resolve({ count: opts.count ?? 0, data: null, error: null })
const chain: Record<string, unknown> = {}
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result() as unknown)
}
if (prop === 'insert') {
return (payload: unknown) => {
isInsert = true
insertSpy(payload)
return new Proxy(chain, handler)
}
}
if (prop === 'single' || prop === 'maybeSingle') {
return () => result()
}
return () => new Proxy(chain, handler)
},
}
return new Proxy(chain, handler)
})
return { insertSpy }
}
beforeEach(() => {
vi.clearAllMocks()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
getActiveCompanyIdMock.mockResolvedValue('company-1')
requireWritePermissionMock.mockResolvedValue({ ok: true })
})
describe('POST /api/settings/api-keys', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: { name: 'k', scopes: ['reports:read'] },
}),
)
expect(res.status).toBe(401)
})
it('returns 400 for an invalid scope', async () => {
setupFrom({ count: 0 })
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: { name: 'k', scopes: ['totally:bogus'] },
}),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('API_KEY_SCOPE_INVALID')
})
it('returns 400 for the reserved OAuth marker name (would fake a Claude connection)', async () => {
const { insertSpy } = setupFrom({ count: 0 })
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: { name: ' MCP-klient (OAuth) ', scopes: ['reports:read'] },
}),
{ params: Promise.resolve({}) },
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { field: string; reason: string } }
}>(res)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details).toMatchObject({ field: 'name', reason: 'reserved' })
expect(insertSpy).not.toHaveBeenCalled()
})
it('returns 409 API_KEY_SOD_CONFLICT for stage+approve without acknowledgement', async () => {
setupFrom({ count: 0 })
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: {
name: 'k',
scopes: ['invoices:write', 'pending_operations:approve'],
},
}),
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { conflicting_scope: string; approve_scope: string } }
}>(res)
expect(status).toBe(409)
expect(body.error.code).toBe('API_KEY_SOD_CONFLICT')
expect(body.error.details.conflicting_scope).toBe('invoices:write')
expect(body.error.details.approve_scope).toBe('pending_operations:approve')
})
it('records sod_acknowledged_at/by in the insert when acknowledge_sod is true', async () => {
const { insertSpy } = setupFrom({
count: 0,
insertResult: {
data: {
id: 'ak-1',
key_prefix: 'gnubok_sk_abcd',
name: 'k',
scopes: ['invoices:write', 'pending_operations:approve'],
created_at: '2026-06-05T10:00:00Z',
},
},
})
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: {
name: 'k',
scopes: ['invoices:write', 'pending_operations:approve'],
acknowledge_sod: true,
},
}),
)
const { status, body } = await parseJsonResponse<{ data: { key: string } }>(res)
expect(status).toBe(200)
expect(body.data.key).toMatch(/^gnubok_sk_/)
expect(insertSpy).toHaveBeenCalledTimes(1)
const payload = insertSpy.mock.calls[0][0] as Record<string, unknown>
expect(payload.sod_acknowledged_by).toBe('user-1')
expect(typeof payload.sod_acknowledged_at).toBe('string')
// ISO timestamp
expect(payload.sod_acknowledged_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
})
it('creates a clean key without approve scope and does not set SoD fields', async () => {
const { insertSpy } = setupFrom({
count: 0,
insertResult: {
data: {
id: 'ak-2',
key_prefix: 'gnubok_sk_efgh',
name: 'reader',
scopes: ['reports:read'],
created_at: '2026-06-05T10:00:00Z',
},
},
})
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: { name: 'reader', scopes: ['reports:read'] },
}),
)
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
const payload = insertSpy.mock.calls[0][0] as Record<string, unknown>
expect(payload).not.toHaveProperty('sod_acknowledged_at')
expect(payload).not.toHaveProperty('sod_acknowledged_by')
expect(payload.scopes).toEqual(['reports:read'])
// Default mode is live, bound to the active company.
expect(payload.mode).toBe('live')
expect(payload.company_id).toBe('company-1')
})
it('creates a test key bound to the active company with mode=test', async () => {
const { insertSpy } = setupFrom({
count: 0,
insertResult: {
data: {
id: 'ak-3',
key_prefix: 'gnubok_sk_test_abc',
name: 'pilot',
scopes: ['reports:read'],
mode: 'test',
created_at: '2026-06-05T10:00:00Z',
},
},
})
const res = await POST(
createMockRequest('/api/settings/api-keys', {
method: 'POST',
body: { name: 'pilot', scopes: ['reports:read'], mode: 'test' },
}),
)
const { status, body } = await parseJsonResponse<{ data: { key: string } }>(res)
expect(status).toBe(200)
// Real generateApiKey('test') runs: the returned secret carries the infix.
expect(body.data.key).toMatch(/^gnubok_sk_test_/)
const payload = insertSpy.mock.calls[0][0] as Record<string, unknown>
expect(payload.mode).toBe('test')
// Test keys are simulation-only: they bind to the active company (the v1
// wrapper forces dry-run so they never persist).
expect(payload.company_id).toBe('company-1')
})
})