Files
accounted/tests/pg/account-deletion-rpcs.pg.test.ts
T
MattssonandClaude Fable 5 63fd5311ed Bug/tic unlink (#1153)
* 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>
2026-07-24 16:36:35 +02:00

184 lines
7.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getClient, getPool, withUserContext } from './setup'
import { insertAuthUser, seedCompany } from './fixtures'
/**
* Account deletion RPCs (issue #342):
*
* Migration 20260706100000 drops public.delete_user_account, the SECURITY
* DEFINER RPC that disabled the BFL retention triggers, deleted audit_log
* rows, and cascaded auth.users, destroying rakenskapsinformation that
* BFL 7 kap 2 paragraf requires us to retain for 7 years. These tests pin
* that the function stays gone and that the surviving anonymize-only flow
* (public.anonymize_user_account) is present, SECURITY DEFINER, and not
* callable by anon/PUBLIC.
*/
describe('account deletion RPCs (pg)', () => {
it('delete_user_account no longer exists in pg_proc', async () => {
const { rows } = await getPool().query<{ n: number }>(
`SELECT count(*)::int AS n
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'delete_user_account'`,
)
expect(rows[0]!.n).toBe(0)
})
it('anonymize_user_account exists and is SECURITY DEFINER', async () => {
const { rows } = await getPool().query<{ prosecdef: boolean }>(
`SELECT p.prosecdef
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'anonymize_user_account'`,
)
expect(rows).toHaveLength(1)
expect(rows[0]!.prosecdef).toBe(true)
})
it('anonymize_user_account has no EXECUTE grant for anon or PUBLIC, but authenticated has it', async () => {
// anon inherits any PUBLIC grant, so anon=false also proves PUBLIC=false;
// the aclexplode check makes the PUBLIC assertion explicit (grantee oid 0).
const { rows } = await getPool().query<{
anon_can_execute: boolean
authenticated_can_execute: boolean
public_grant_count: number
}>(
`SELECT
has_function_privilege('anon', 'public.anonymize_user_account(uuid)', 'EXECUTE')
AS anon_can_execute,
has_function_privilege('authenticated', 'public.anonymize_user_account(uuid)', 'EXECUTE')
AS authenticated_can_execute,
(SELECT count(*)::int
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace,
LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) AS acl
WHERE n.nspname = 'public'
AND p.proname = 'anonymize_user_account'
AND acl.grantee = 0
AND acl.privilege_type = 'EXECUTE') AS public_grant_count`,
)
expect(rows[0]!.anon_can_execute).toBe(false)
expect(rows[0]!.public_grant_count).toBe(0)
expect(rows[0]!.authenticated_can_execute).toBe(true)
})
it('denies EXECUTE to the anon role at call time', async () => {
const client = await getClient()
try {
await client.query('BEGIN')
await client.query('SET LOCAL ROLE anon')
await expect(
client.query('SELECT public.anonymize_user_account($1)', [randomUUID()]),
).rejects.toThrow(/permission denied/i)
} finally {
await client.query('ROLLBACK').catch(() => {})
client.release()
}
})
it('refuses to anonymize while the user still owns an active company', async () => {
const { userId } = await seedCompany()
await withUserContext(userId, async (client) => {
await expect(
client.query('SELECT public.anonymize_user_account($1)', [userId]),
).rejects.toThrow(/still owns/i)
})
})
it('refuses to anonymize another user', async () => {
const userId = await insertAuthUser()
const victimId = await insertAuthUser()
await withUserContext(userId, async (client) => {
await expect(
client.query('SELECT public.anonymize_user_account($1)', [victimId]),
).rejects.toThrow(/only delete your own account/i)
})
})
it('anonymizes a company-less user: scrubs profile PII, sets tombstones', async () => {
const userId = await insertAuthUser()
// insertAuthUser may or may not fire a profiles trigger depending on the
// harness image; make the profile row deterministic.
await getPool().query(
`INSERT INTO public.profiles (id, email, full_name)
VALUES ($1, $2, 'PG Real')
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, full_name = EXCLUDED.full_name`,
[userId, `pg-real-${userId}@test.invalid`],
)
await withUserContext(userId, async (client) => {
await client.query('SELECT public.anonymize_user_account($1)', [userId])
const { rows } = await client.query<{
email: string | null
full_name: string | null
deleted_at: string | null
anonymized_at: string | null
}>(
`SELECT email, full_name, deleted_at, anonymized_at
FROM public.profiles WHERE id = $1`,
[userId],
)
expect(rows).toHaveLength(1)
expect(rows[0]!.email).toBeNull()
expect(rows[0]!.full_name).toBeNull()
expect(rows[0]!.deleted_at).not.toBeNull()
expect(rows[0]!.anonymized_at).not.toBeNull()
})
// withUserContext rolls back, so the seeded rows do not leak.
})
it('scrubs auth.users metadata: user_metadata wiped, app keys dropped, provider kept', async () => {
// Migration 20260724150000: the route-level updateUserById "wipe" was a
// silent no-op (GoTrue merges metadata maps), so the tombstone kept the
// user's full name. The RPC now scrubs auth.users directly. Email must
// survive: it is the documented legitimate-interest tombstone.
const userId = await insertAuthUser()
await getPool().query(
`UPDATE auth.users
SET raw_user_meta_data = '{"full_name": "PG Real Person", "email_verified": true}'::jsonb,
raw_app_meta_data = '{"provider": "email", "providers": ["email"], "bankid_linked": true, "has_password": false}'::jsonb
WHERE id = $1`,
[userId],
)
await withUserContext(userId, async (client) => {
await client.query('SELECT public.anonymize_user_account($1)', [userId])
// The authenticated role has no SELECT on auth.users; drop back to the
// superuser session user to verify. Still inside the same transaction,
// so the rolled-back writes remain visible.
await client.query('RESET ROLE')
const { rows } = await client.query<{
email: string | null
user_meta: Record<string, unknown>
app_meta: Record<string, unknown>
}>(
`SELECT email, raw_user_meta_data AS user_meta, raw_app_meta_data AS app_meta
FROM auth.users WHERE id = $1`,
[userId],
)
expect(rows).toHaveLength(1)
expect(rows[0]!.email).toBe(`pg-real-${userId}@test.invalid`)
expect(rows[0]!.user_meta).toEqual({})
expect(rows[0]!.app_meta).toEqual({ provider: 'email', providers: ['email'] })
})
})
it('rejects a repeat invocation against an already-anonymized tombstone', async () => {
const userId = await insertAuthUser()
await getPool().query(
`INSERT INTO public.profiles (id, email, full_name)
VALUES ($1, $2, 'PG Real')
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, full_name = EXCLUDED.full_name`,
[userId, `pg-real-${userId}@test.invalid`],
)
await withUserContext(userId, async (client) => {
await client.query('SELECT public.anonymize_user_account($1)', [userId])
await expect(
client.query('SELECT public.anonymize_user_account($1)', [userId]),
).rejects.toThrow(/already deleted/i)
})
})
})