4f33184a9a
* fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133) Lazy auth is by design: Claude lists the tools before any sign-in and the first company-scoped call answers 401, which opens the Accounted sign-in. Nothing told the user, so a "connected" status with an unanswered first question read as a broken connection (Axel, Discord). - Settings -> API & MCP: one sentence of expectation under the button, and the step-by-step guide link moved from under two disclosures to directly under the button. - Docs (connect-claude / anslut-claude): new "What happens after you click" section for Path A covering the connector dialog, the tools appearing before sign-in, the first-call login + consent screen, "ask again", and the "Required when the server asks" auth setting that only the manual path mentioned. - Hem checklist step "Anslut till Claude": deep link now carries client=claude-connector like the settings button (claudeConnectorLink), the footnote carries the same expectation line plus the guide link, and the done-signal is an unrevoked api_keys row minted by the MCP OAuth token route (OAUTH_MCP_KEY_NAME) instead of the in-app AI-profile flag, which never meant "connected to Claude". - Tests: claudeStepDone with/without a key row, deep-link snapshot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L * fix(mcp): correct consent-page claims, stop the completion PATCH loop, count OAuth keys past RLS (#2133) Three skeptic refutations on PR #2147, fixed in one pass: - Docs (EN + SV): the consent page shows the company active in the app and pre-selects every scope for Claude's connector (founder decision 2026-08-26); it has no company picker and nothing to tick. Steps 3-4 of the new section, the "Read-only by default" paragraph above it, the sandbox note and the 10-minute test now describe Endast läs under Behörigheter instead. - Checklist completion: users with initial_setup_path NULL (skipped the books question, then imported) hit the route's "Välj först hur du vill komma igång" 400 and, with saving as an effect dependency, retried it forever with a toast. completionPatchBody() records path=migration when none was chosen, and a rejected PATCH is not retried within the session. - hasMcpKey: api_keys' SELECT policy is company-scoped, so the user client could not see companyless (NULL company_id) or archived-company keys and the step stayed open for the user who had just connected. The head count now runs through the service client with an explicit user_id filter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L * fix(mcp): surface a failed OAuth-key count and reserve the marker name (#2133) CodeRabbit round on PR #2147: - app/(dashboard)/page.tsx: a failed api_keys count answered count null, which claudeStepDone read as "never connected". Throw to the error boundary like the settings fetch does instead of guessing. - app/api/settings/api-keys: reject a hand-minted key named MCP-klient (OAuth) (400 VALIDATION_ERROR): that name is the marker the Hem checklist reads as "connected to Claude", so a manual key with it would tick the step without any connection. Test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
247 lines
8.5 KiB
TypeScript
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')
|
|
})
|
|
})
|