63fd5311ed
* fix(tic): allow BankID link/unlink without a company context /bankid/link and /bankid/unlink are user-level actions, but the extension dispatcher resolved an active company for them, so a zero-company user (fresh BankID signup, pre-onboarding) got a 500 'No company context' when managing the connection from /settings/account. Mark both routes skipCompanyContext and resolve the caller in-handler via requireAuth(), which preserves the dispatcher's MFA/AAL2 enforcement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tic): return 409 account_exists instead of 500 on BankID signup with taken email The signup guard pre-checked profiles.email, but the authoritative store is auth.users: anonymized account tombstones (and any profile drift) hold the email in auth.users while profiles.email is NULL. The guard missed, createUser failed with email_exists (422), and the route surfaced a dead-end 500 'Kunde inte skapa kontot. Forsok igen.' where retrying can never succeed. Drop the profiles pre-check and let createUser's own uniqueness check be the guard: map email_exists to the existing 409 account_exists response (Swedish message), which the register page already handles with a toast and a redirect to login. Also removes the TOCTOU window between the old pre-check and createUser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(account): actually scrub auth.users metadata on account deletion The delete route passed user_metadata: {} / app_metadata: {} to auth.admin.updateUserById assuming replace semantics, but GoTrue MERGES metadata maps, so the wipe was a silent no-op: the ~100-year tombstone kept the user's full name in raw_user_meta_data (verified on production 2026-07-24). Move the scrub into anonymize_user_account (migration 20260724150000): raw_user_meta_data is cleared entirely, raw_app_meta_data drops the app-specific keys (bankid_linked, has_password) while GoTrue's provider/providers stay, and auth.users.email is still retained as the documented legitimate-interest tombstone. The migration also repairs existing tombstones (guarded by profiles.anonymized_at). The route keeps only the ban, which the DB function cannot set. Migration content already applied to staging; pg-real test extended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log BankID signup guard decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(account): address PR review findings on anonymize scrub - anonymize_user_account now rejects repeat invocations against an already-anonymized tombstone (SQLSTATE P0002) instead of re-churning the scrubbed row - note that the tombstone repair UPDATE runs atomically inside the migration transaction - tic signup failure log hashes the email (sha256 prefix, matching the pnrHashPrefix pattern) instead of logging the raw address - pg-real test for the double-invocation guard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(anonymization): ensure raw_app_meta_data is not null before scrubbing keys --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
156 lines
4.8 KiB
TypeScript
156 lines
4.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
import { eventBus } from '@/lib/events'
|
|
|
|
vi.mock('@/lib/init', () => ({
|
|
ensureInitialized: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
createServiceClient: vi.fn(),
|
|
}))
|
|
|
|
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
|
import { POST } from '../route'
|
|
|
|
const mockCreateClient = vi.mocked(createClient)
|
|
const mockCreateServiceClient = vi.mocked(createServiceClient)
|
|
|
|
function mockAuth(
|
|
user: { id: string; email: string | null } | null,
|
|
rpcResult: { data?: unknown; error?: unknown } = { data: null, error: null }
|
|
) {
|
|
const signOut = vi.fn().mockResolvedValue({ error: null })
|
|
const rpc = vi.fn().mockResolvedValue(rpcResult)
|
|
|
|
mockCreateClient.mockResolvedValue({
|
|
auth: {
|
|
getUser: vi.fn().mockResolvedValue({ data: { user } }),
|
|
signOut,
|
|
},
|
|
rpc,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any)
|
|
|
|
return { rpc, signOut }
|
|
}
|
|
|
|
function mockServiceClient(blockers: { id: string; name: string }[] = []) {
|
|
const updateUserById = vi.fn().mockResolvedValue({ data: {}, error: null })
|
|
const adminSignOut = vi.fn().mockResolvedValue({ data: null, error: null })
|
|
|
|
const chain = {
|
|
select: vi.fn().mockReturnThis(),
|
|
eq: vi.fn().mockReturnThis(),
|
|
is: vi.fn().mockResolvedValue({
|
|
data: blockers.map((b) => ({ companies: { id: b.id, name: b.name } })),
|
|
error: null,
|
|
}),
|
|
}
|
|
|
|
mockCreateServiceClient.mockReturnValue({
|
|
from: vi.fn().mockReturnValue(chain),
|
|
auth: {
|
|
admin: {
|
|
updateUserById,
|
|
signOut: adminSignOut,
|
|
},
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any)
|
|
|
|
return { updateUserById, adminSignOut }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
describe('POST /api/account/delete', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
mockAuth(null)
|
|
|
|
const req = createMockRequest('/api/account/delete', {
|
|
method: 'POST',
|
|
body: { confirm_email: 'u@example.com' },
|
|
})
|
|
const { status } = await parseJsonResponse(await POST(req))
|
|
expect(status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when confirm_email does not match', async () => {
|
|
mockAuth({ id: 'user-1', email: 'right@example.com' })
|
|
|
|
const req = createMockRequest('/api/account/delete', {
|
|
method: 'POST',
|
|
body: { confirm_email: 'wrong@example.com' },
|
|
})
|
|
const { status, body } = await parseJsonResponse(await POST(req))
|
|
expect(status).toBe(400)
|
|
expect(body).toHaveProperty('error')
|
|
})
|
|
|
|
it('returns 409 with blockers when RPC raises P0001', async () => {
|
|
mockAuth(
|
|
{ id: 'user-1', email: 'u@example.com' },
|
|
{ data: null, error: { code: 'P0001', message: 'blocked' } }
|
|
)
|
|
mockServiceClient([{ id: 'c1', name: 'Acme AB' }])
|
|
|
|
const req = createMockRequest('/api/account/delete', {
|
|
method: 'POST',
|
|
body: { confirm_email: 'u@example.com' },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{
|
|
error: string
|
|
blockers: { id: string; name: string }[]
|
|
}>(await POST(req))
|
|
|
|
expect(status).toBe(409)
|
|
expect(body.blockers).toEqual([{ id: 'c1', name: 'Acme AB' }])
|
|
})
|
|
|
|
it('anonymizes, bans, signs out, and emits event on happy path', async () => {
|
|
const { rpc } = mockAuth({ id: 'user-1', email: 'u@example.com' })
|
|
const { updateUserById, adminSignOut } = mockServiceClient()
|
|
|
|
const emitted: unknown[] = []
|
|
eventBus.on('account.deleted', (payload) => {
|
|
emitted.push(payload)
|
|
})
|
|
|
|
const req = createMockRequest('/api/account/delete', {
|
|
method: 'POST',
|
|
body: { confirm_email: 'u@example.com' },
|
|
})
|
|
const { status, body } = await parseJsonResponse<{ success: boolean }>(
|
|
await POST(req)
|
|
)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
|
|
expect(rpc).toHaveBeenCalledWith('anonymize_user_account', {
|
|
target_user_id: 'user-1',
|
|
})
|
|
expect(updateUserById).toHaveBeenCalledWith(
|
|
'user-1',
|
|
expect.objectContaining({ ban_duration: expect.any(String) })
|
|
)
|
|
const updatePayload = updateUserById.mock.calls[0][1]
|
|
// Email must NOT be scrubbed: retaining it is what blocks re-signup
|
|
// with the same address. Recovery goes through support instead.
|
|
expect(updatePayload).not.toHaveProperty('email')
|
|
// Metadata is NOT wiped here: GoTrue merges metadata maps, so passing {}
|
|
// was a no-op. The anonymize_user_account RPC scrubs auth.users directly
|
|
// (migration 20260724150000).
|
|
expect(updatePayload).not.toHaveProperty('user_metadata')
|
|
expect(updatePayload).not.toHaveProperty('app_metadata')
|
|
expect(adminSignOut).toHaveBeenCalledWith('user-1', 'global')
|
|
expect(emitted).toHaveLength(1)
|
|
expect(emitted[0]).toMatchObject({ userId: 'user-1' })
|
|
})
|
|
})
|