diff --git a/server/src/routes/account-linking.ts b/server/src/routes/account-linking.ts index cfcf3a65b0d..ab283d29b75 100644 --- a/server/src/routes/account-linking.ts +++ b/server/src/routes/account-linking.ts @@ -298,7 +298,8 @@ export function handleEmailLinkVerification(app: { if (new Date(tokenRecord.expires_at) < new Date()) { await query( - `UPDATE email_link_tokens SET status = 'expired' WHERE id = $1`, + `UPDATE email_link_tokens SET status = 'expired' + WHERE id = $1 AND status = 'pending' AND expires_at < NOW()`, [tokenRecord.id] ); return renderVerifyPage(res, { success: false, message: 'This verification link has expired. Please request a new one from your dashboard settings.' }); @@ -332,7 +333,7 @@ export function handleEmailLinkVerification(app: { // Lock the token row to prevent concurrent verification const tokenResult = await client.query( - `SELECT id, primary_workos_user_id, target_email, target_workos_user_id, status, expires_at + `SELECT *, expires_at::text AS expires_at_exact FROM email_link_tokens WHERE token = $1 FOR UPDATE`, [token] ); @@ -343,86 +344,142 @@ export function handleEmailLinkVerification(app: { return renderVerifyPage(res, { success: false, message: 'This verification link is invalid.' }); } - if (tokenRecord.status !== 'pending' && tokenRecord.status !== 'processing') { + if (tokenResult.rowCount !== 1 || tokenResult.rows.length !== 1) { + throw new Error('Unexpected verification token cardinality'); + } + + const replay = tokenRecord.status === 'verified'; + if (!replay && tokenRecord.status !== 'pending' && tokenRecord.status !== 'processing') { await client.query('ROLLBACK'); return renderVerifyPage(res, { success: false, message: 'This verification link has already been used.' }); } - if (new Date(tokenRecord.expires_at) < new Date()) { - await client.query( - `UPDATE email_link_tokens SET status = 'expired' WHERE id = $1`, - [tokenRecord.id] + // Legacy processing is retryable only with no terminal evidence. Never + // reset a token after failure: rollback restores its locked entry state. + if ((!replay && tokenRecord.verified_at !== null) || tokenRecord.merge_summary !== null) { + throw new Error('Unexpected verification token evidence'); + } + + const finishToken = async (status: 'verified' | 'expired' | 'revoked') => { + const updated = await client.query( + `UPDATE email_link_tokens SET status = $7::varchar, + verified_at = CASE WHEN $7 = 'verified' THEN NOW() ELSE verified_at END + WHERE id = $1 AND token = $2 AND primary_workos_user_id = $3 + AND target_email = $4 AND target_workos_user_id IS NOT DISTINCT FROM $5 + AND status = $6 AND verified_at IS NULL AND merge_summary IS NULL + AND expires_at = $8 + RETURNING *`, + [tokenRecord.id, token, tokenRecord.primary_workos_user_id, tokenRecord.target_email, + tokenRecord.target_workos_user_id, tokenRecord.status, status, tokenRecord.expires_at_exact] ); + const assertTerminal = (result: typeof updated) => { + const row = result.rows[0]; + if (result.rowCount !== 1 || result.rows.length !== 1 || row.id !== tokenRecord.id + || row.token !== token || row.primary_workos_user_id !== tokenRecord.primary_workos_user_id + || row.target_email !== tokenRecord.target_email + || row.target_workos_user_id !== tokenRecord.target_workos_user_id + || row.status !== status || row.merge_summary !== null + || (status === 'verified' ? !row.verified_at : row.verified_at !== null) + || new Date(row.expires_at).getTime() !== new Date(tokenRecord.expires_at).getTime()) { + throw new Error('Verification token transition failed'); + } + }; + assertTerminal(updated); + // RETURNING precedes AFTER triggers; check the persisted state too. + assertTerminal(await client.query('SELECT * FROM email_link_tokens WHERE id = $1', [tokenRecord.id])); + }; + + if (!replay && new Date(tokenRecord.expires_at) < new Date()) { + await finishToken('expired'); await client.query('COMMIT'); return renderVerifyPage(res, { success: false, message: 'This verification link has expired. Please request a new one from your dashboard settings.' }); } + // Use migration 592's existing nonblocking credential fence throughout + // the rechecks, including replay. No new cross-email writer protocol. + await client.query('SELECT lock_email_writer($1)', [tokenRecord.primary_workos_user_id]); const mutationStatus = await getEmailMutationStatus(tokenRecord.primary_workos_user_id, client); if (mutationStatus.reconciliation_required) { await client.query('ROLLBACK'); return renderVerifyPage(res, { success: false, message: mutationStatus.message! }); } - // Mark token as processing (prevents concurrent attempts, but allows retry on failure) - await client.query( - `UPDATE email_link_tokens SET status = 'processing' WHERE id = $1`, - [tokenRecord.id] - ); - - await client.query('COMMIT'); + if (tokenRecord.target_workos_user_id !== null) { + // Defense in depth: initiation now refuses tokens with a target + // WorkOS user (see POST /api/me/linked-emails), but in-flight + // tokens issued before that block landed must also be refused + // here. The merge-existing-accounts path is admin-only. + if (replay) throw new Error('Conflicting verification replay'); + await finishToken('revoked'); + await client.query('COMMIT'); + logger.info( + { + tokenId: tokenRecord.id, + primaryUserId: tokenRecord.primary_workos_user_id, + targetWorkosUserId: tokenRecord.target_workos_user_id, + }, + 'Refused self-service merge at verify time; admin tool required' + ); + return renderVerifyPage(res, { + success: false, + message: 'This email already has an AAO account. Combining accounts requires admin assistance — please contact support.', + }); + } - try { - if (tokenRecord.target_workos_user_id) { - // Defense in depth: initiation now refuses tokens with a target - // WorkOS user (see POST /api/me/linked-emails), but in-flight - // tokens issued before that block landed must also be refused - // here. The merge-existing-accounts path is admin-only. - await query( - `UPDATE email_link_tokens SET status = 'revoked' WHERE id = $1`, - [tokenRecord.id] - ); - logger.info( - { - tokenId: tokenRecord.id, - primaryUserId: tokenRecord.primary_workos_user_id, - targetWorkosUserId: tokenRecord.target_workos_user_id, - }, - 'Refused self-service merge at verify time; admin tool required' - ); - return renderVerifyPage(res, { - success: false, - message: 'This email already has an AAO account. Combining accounts requires admin assistance — please contact support.', - }); - } + const principal = await client.query( + 'SELECT workos_user_id FROM users WHERE workos_user_id = $1 FOR KEY SHARE', + [tokenRecord.primary_workos_user_id] + ); + if (principal.rowCount !== 1 || principal.rows.length !== 1 + || principal.rows[0].workos_user_id !== tokenRecord.primary_workos_user_id) { + throw new Error('Verification principal is unavailable'); + } + // This sees credentials created since issuance. A different credential + // inserted AFTER this check is a residual cross-table writer race. + const credentials = await client.query( + 'SELECT workos_user_id FROM users WHERE LOWER(email) = LOWER($1)', [tokenRecord.target_email] + ); + if (credentials.rowCount !== 0 || credentials.rows.length !== 0) { + throw new Error('Verification email already has a credential'); + } - // Record the email alias (use bare ON CONFLICT to handle both the - // composite UNIQUE(workos_user_id, email) and the LOWER(email) index) - await query( + if (!replay) { + const inserted = await client.query( `INSERT INTO user_email_aliases (workos_user_id, email) VALUES ($1, $2) - ON CONFLICT DO NOTHING`, + ON CONFLICT DO NOTHING RETURNING workos_user_id, email, verified_at`, [tokenRecord.primary_workos_user_id, tokenRecord.target_email] ); + if (inserted.rowCount !== inserted.rows.length || ![0, 1].includes(inserted.rows.length) + || (inserted.rows.length === 1 && (inserted.rows[0].workos_user_id !== tokenRecord.primary_workos_user_id + || inserted.rows[0].email !== tokenRecord.target_email || !inserted.rows[0].verified_at))) { + throw new Error('Verification alias insert failed'); + } + } - // Mark token as verified - await query( - `UPDATE email_link_tokens SET status = 'verified', verified_at = NOW() WHERE id = $1`, - [tokenRecord.id] + // Check ownership even after INSERT, and lock existing aliases through + // commit. Zero rows (including a suppressed INSERT) is never success. + const checkOwner = async () => { + const owner = await client.query( + `SELECT workos_user_id, email, verified_at FROM user_email_aliases + WHERE LOWER(email) = LOWER($1) FOR UPDATE`, [tokenRecord.target_email] ); - } catch (verifyError) { - // Reset token to pending so the user can retry - await query( - `UPDATE email_link_tokens SET status = 'pending' WHERE id = $1`, - [tokenRecord.id] - ).catch(() => { - logger.error({ tokenId: tokenRecord.id }, 'Failed to reset token status after verify error — user cannot retry'); - }); - throw verifyError; + if (owner.rowCount !== 1 || owner.rows.length !== 1 + || owner.rows[0].workos_user_id !== tokenRecord.primary_workos_user_id + || !owner.rows[0].verified_at || (replay && !tokenRecord.verified_at)) { + throw new Error('Verification alias ownership conflict'); + } + }; + await checkOwner(); + if (!replay) { + await finishToken('verified'); + await checkOwner(); } + await client.query('COMMIT'); const message = `Your email ${escapeHtml(tokenRecord.target_email)} is now linked to your account.`; - logger.info( + if (!replay) logger.info( { primaryUserId: tokenRecord.primary_workos_user_id, targetEmail: tokenRecord.target_email }, 'Email link verification completed' ); diff --git a/server/tests/integration/alias-verification-atomicity.test.ts b/server/tests/integration/alias-verification-atomicity.test.ts new file mode 100644 index 00000000000..0cef113161f --- /dev/null +++ b/server/tests/integration/alias-verification-atomicity.test.ts @@ -0,0 +1,363 @@ +import { randomUUID } from 'node:crypto'; +import express from 'express'; +import request from 'supertest'; +import { Client, type Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; + +const provider = vi.hoisted(() => ({ getUser: vi.fn(), updateUser: vi.fn() })); +vi.mock('../../src/auth/workos-client.js', () => ({ getEmailMutationWorkos: () => ({ userManagement: provider }) })); +vi.mock('../../src/middleware/auth.js', () => ({ requireAuth: vi.fn(), invalidateSessionsForUsers: vi.fn() })); +vi.mock('express-rate-limit', () => ({ default: () => (_req: any, _res: any, next: any) => next() })); +vi.mock('../../src/middleware/pg-rate-limit-store.js', () => ({ CachedPostgresStore: class {} })); +vi.mock('../../src/notifications/email.js', () => ({ sendEmailLinkVerification: vi.fn() })); +import { handleEmailLinkVerification } from '../../src/routes/account-linking.js'; + +const users = ['user_alias_atomicity_a', 'user_alias_atomicity_b', 'user_alias_atomicity_c']; +const email = 'alias-atomicity@example.test'; +const app = express(); +handleEmailLinkVerification(app); +const submit = (token: string) => request(app).post('/verify-email-link').type('form').send({ token }).then(response => response); +const success = (response: { text: string }) => response.text.includes('is now linked to your account.'); +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +describe('alias verification transaction through the public HTTP callback', () => { + let pool: Pool; + let token: string; + let identities: string[] = []; + async function issue(owner = users[0], target = email, status = 'pending') { + const value = randomUUID(); + await pool.query(`INSERT INTO email_link_tokens(token,primary_workos_user_id,target_email,status,expires_at) + VALUES($1,$2,$3,$4,NOW()+INTERVAL '1 hour')`, [value, owner, target, status]); + return value; + } + async function alias(owner = users[0], target = email) { + await pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [owner, target]); + } + async function clearFault() { await pool.query('DROP FUNCTION IF EXISTS test_alias_atomicity_fault() CASCADE'); } + async function fault(table: string, event: string, body: string, timing = 'BEFORE') { + await pool.query(`CREATE FUNCTION test_alias_atomicity_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN ${body} RETURN NEW; END $$`); + await pool.query(`CREATE TRIGGER zz_test_alias_atomicity_fault ${timing} ${event} ON ${table} + FOR EACH ROW EXECUTE FUNCTION test_alias_atomicity_fault()`); + } + async function state() { + const result: Record = {}; + // Whole-table snapshots prove the seam cannot move unrelated person or + // membership/leadership authority. This suite runs serially in a local DB. + for (const table of ['users', 'user_email_aliases', 'email_link_tokens', 'authorization_epochs', + 'identity_workos_users', 'identities', 'organization_memberships', 'working_group_memberships', 'working_group_leaders', + 'person_relationships', 'registry_audit_log', 'email_mutations', 'test_alias_atomicity_effects']) { + result[table] = (await pool.query(`SELECT row_to_json(t)::text AS row FROM ${table} t ORDER BY row_to_json(t)::text`)).rows; + } + return result; + } + async function cleanup() { + await clearFault(); + await pool.query('DELETE FROM email_mutations WHERE workos_user_id=ANY($1)', [users]); + await pool.query('DELETE FROM person_relationships WHERE workos_user_id=ANY($1)', [users]); + await pool.query('DELETE FROM organization_memberships WHERE workos_user_id=ANY($1)', [users]); + const bindings = await pool.query('SELECT identity_id FROM identity_workos_users WHERE workos_user_id=ANY($1)', [users]); + identities = [...new Set([...identities, ...bindings.rows.map(row => row.identity_id)])]; + await pool.query('DELETE FROM users WHERE workos_user_id=ANY($1)', [users]); + await pool.query('DELETE FROM identities WHERE id=ANY($1)', [identities]); + await pool.query('TRUNCATE test_alias_atomicity_effects'); + } + beforeAll(async () => { + pool = initializeDatabase({ connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test' }); + await runMigrations(); + // Test-only transactional observations, not production audit/provenance. + await pool.query('CREATE TABLE test_alias_atomicity_effects(event text NOT NULL)'); + await pool.query(`CREATE FUNCTION test_alias_atomicity_observe() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + INSERT INTO test_alias_atomicity_effects VALUES(TG_TABLE_NAME || ':' || TG_OP); RETURN NEW; END $$`); + await pool.query(`CREATE TRIGGER test_alias_atomicity_observe AFTER INSERT ON user_email_aliases + FOR EACH ROW EXECUTE FUNCTION test_alias_atomicity_observe()`); + await pool.query(`CREATE TRIGGER test_alias_atomicity_observe AFTER UPDATE ON email_link_tokens + FOR EACH ROW EXECUTE FUNCTION test_alias_atomicity_observe()`); + }, 60_000); + beforeEach(async () => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + await cleanup(); + for (const [index, id] of users.entries()) { + await pool.query(`INSERT INTO users(workos_user_id,email,email_verified,workos_created_at,workos_updated_at) + VALUES($1,$2,true,NOW(),NOW())`, [id, `alias-owner-${index}@example.test`]); + } + identities = (await pool.query('SELECT identity_id FROM identity_workos_users WHERE workos_user_id=ANY($1)', [users])).rows.map(row => row.identity_id); + await pool.query(`INSERT INTO organization_memberships(workos_user_id,workos_organization_id,workos_membership_id,email,role) + VALUES($1,'org_alias_atomicity','om_alias_atomicity','alias-owner-0@example.test','owner')`, [users[0]]); + await pool.query('INSERT INTO person_relationships(workos_user_id,email) VALUES($1,$2)', [users[0], 'alias-owner-0@example.test']); + token = await issue(); + }); + afterAll(async () => { + vi.restoreAllMocks(); + if (pool) { + await cleanup(); + await pool.query('DROP FUNCTION IF EXISTS test_alias_atomicity_observe() CASCADE'); + await pool.query('DROP TABLE IF EXISTS test_alias_atomicity_effects'); + await closeDatabase(); + } + }); + async function assertRollback(value = token) { + const before = await state(); + const response = await submit(value); + expect(response.status).toBe(200); + expect(success(response)).toBe(false); + expect(response.text).not.toContain('fault-secret'); + expect(await state()).toEqual(before); + expect(provider.getUser).not.toHaveBeenCalled(); + expect(provider.updateUser).not.toHaveBeenCalled(); + } + + it('commits only the alias and token; replay has no new effects even after expiry', async () => { + const before = await state(); + expect(success(await submit(token))).toBe(true); + const after = await state(); + for (const table of Object.keys(before).filter(table => !['user_email_aliases', 'email_link_tokens', 'test_alias_atomicity_effects'].includes(table))) { + expect(after[table], table).toEqual(before[table]); + } + expect((after.test_alias_atomicity_effects as unknown[]).length).toBe(2); + await pool.query("UPDATE email_link_tokens SET expires_at=NOW()-INTERVAL '1 hour' WHERE token=$1", [token]); + const replayBefore = await state(); + expect(success(await submit(token))).toBe(true); + expect(await state()).toEqual(replayBefore); + expect(provider.getUser).not.toHaveBeenCalled(); + expect(provider.updateUser).not.toHaveBeenCalled(); + }); + + it('serializes independent same-token sessions and gives both the stable successful result', async () => { + const locked = deferred(); + const release = deferred(); + const dispatched = deferred(); + let firstPid = 0; + let secondPid = 0; + let reads = 0; + const query = Client.prototype.query; + vi.spyOn(Client.prototype, 'query').mockImplementation(function (this: Client, ...args: any[]) { + const matching = typeof args[0] === 'string' && args[0].includes('WHERE token = $1 FOR UPDATE'); + const index = matching ? ++reads : 0; + const result = (query as any).apply(this, args); + if (index === 1) { + firstPid = (this as any).processID; + return Promise.resolve(result).then(async response => { locked.resolve(); await release.promise; return response; }); + } + if (index === 2) { secondPid = (this as any).processID; dispatched.resolve(); } + return result; + }); + const first = submit(token); + await locked.promise; + const second = submit(token); + try { + await dispatched.promise; + expect(secondPid).not.toBe(firstPid); + await vi.waitFor(async () => { + const blocked = await pool.query('SELECT pg_blocking_pids($1) AS pids', [secondPid]); + expect(blocked.rows[0].pids).toContain(firstPid); + }); + } finally { release.resolve(); } + const responses = await Promise.all([first, second]); + expect(responses.map(success)).toEqual([true, true]); + expect((await pool.query('SELECT status FROM email_link_tokens WHERE token=$1', [token])).rows).toEqual([{ status: 'verified' }]); + expect((await pool.query('SELECT * FROM test_alias_atomicity_effects')).rowCount).toBe(2); + }); + + it('races two credentials after both user rechecks; only the case-folded alias winner succeeds', async () => { + const otherToken = await issue(users[1], email.toUpperCase()); + const ready = deferred(); + const release = deferred(); + const pids = new Set(); + let checks = 0; + const query = Client.prototype.query; + vi.spyOn(Client.prototype, 'query').mockImplementation(function (this: Client, ...args: any[]) { + const result = (query as any).apply(this, args); + if (typeof args[0] === 'string' && args[0].includes('FROM users WHERE LOWER(email)')) { + pids.add((this as any).processID); + return Promise.resolve(result).then(async response => { + expect(response.rowCount).toBe(0); + if (++checks === 2) ready.resolve(); + await release.promise; + return response; + }); + } + return result; + }); + const submissions = [submit(token), submit(otherToken)]; + try { await ready.promise; expect(pids.size).toBe(2); } finally { release.resolve(); } + const responses = await Promise.all(submissions); + expect(responses.filter(success)).toHaveLength(1); + const winner = success(responses[0]) ? 0 : 1; + expect((await pool.query('SELECT workos_user_id FROM user_email_aliases WHERE LOWER(email)=LOWER($1)', [email])).rows).toEqual([{ workos_user_id: users[winner] }]); + const records = await pool.query('SELECT token,status FROM email_link_tokens ORDER BY token'); + expect(records.rows.find(row => row.token === [token, otherToken][winner]).status).toBe('verified'); + expect(records.rows.find(row => row.token === [token, otherToken][1 - winner]).status).toBe('pending'); + expect((await pool.query('SELECT * FROM test_alias_atomicity_effects')).rowCount).toBe(2); + await assertRollback([token, otherToken][1 - winner]); + }); + + it.each(['pending', 'processing'])('recovers %s with the same existing owner without inserting another alias', async status => { + await pool.query('UPDATE email_link_tokens SET status=$2 WHERE token=$1', [token, status]); + await alias(users[0], email.toUpperCase()); + await pool.query('TRUNCATE test_alias_atomicity_effects'); + expect(success(await submit(token))).toBe(true); + expect((await pool.query('SELECT * FROM test_alias_atomicity_effects')).rows).toEqual([{ event: 'email_link_tokens:UPDATE' }]); + }); + it('recovers legacy processing without an alias', async () => { + await pool.query("UPDATE email_link_tokens SET status='processing' WHERE token=$1", [token]); + expect(success(await submit(token))).toBe(true); + }); + it.each(['pending', 'processing', 'verified'])('fails closed for a conflicting %s owner, preserving historic state', async status => { + await alias(users[1]); + await pool.query("UPDATE email_link_tokens SET status=$2::varchar,verified_at=CASE WHEN $2='verified' THEN NOW() END WHERE token=$1", [token, status]); + await assertRollback(); + }); + it('rejects replay after the alias is removed without recreating it', async () => { + expect(success(await submit(token))).toBe(true); + await pool.query('DELETE FROM user_email_aliases WHERE workos_user_id=$1', [users[0]]); + await assertRollback(); + }); + it.each(['verified_at', 'merge_summary'])('rejects legacy processing with ambiguous %s evidence', async field => { + await pool.query(`UPDATE email_link_tokens SET status='processing',${field}=${field === 'verified_at' ? 'NOW()' : "'{}'::jsonb"} WHERE token=$1`, [token]); + await assertRollback(); + }); + it('rejects an unverified historic alias instead of inventing provenance', async () => { + await alias(); + await pool.query('UPDATE user_email_aliases SET verified_at=NULL WHERE workos_user_id=$1', [users[0]]); + await assertRollback(); + }); + it('rejects a users credential created since token issuance', async () => { + await pool.query('UPDATE users SET email=$2 WHERE workos_user_id=$1', [users[1], email.toUpperCase()]); + await assertRollback(); + }); + it('rejects a newly inserted users row for the token email', async () => { + await pool.query('DELETE FROM users WHERE workos_user_id=$1', [users[2]]); + await pool.query(`INSERT INTO users(workos_user_id,email,email_verified,workos_created_at,workos_updated_at) + VALUES($1,$2,true,NOW(),NOW())`, [users[2], email.toUpperCase()]); + await assertRollback(); + }); + it.each([users[0], users[1]])('rejects physically ambiguous historic alias rows with second owner %s', async secondOwner => { + // Simulate historic corruption in this disposable test DB, restoring the + // production uniqueness index even when the assertion fails. + const definition = (await pool.query("SELECT pg_get_indexdef('idx_user_email_aliases_email_unique'::regclass) AS sql")).rows[0].sql; + await pool.query('DROP INDEX idx_user_email_aliases_email_unique'); + try { + await alias(); + await alias(secondOwner, email.toUpperCase()); + await assertRollback(); + } finally { + await pool.query('DELETE FROM user_email_aliases WHERE LOWER(email)=LOWER($1)', [email]); + await pool.query(definition); + } + }); + it('revokes a historic existing-account token without merging any accounts', async () => { + await pool.query('UPDATE email_link_tokens SET target_workos_user_id=$2 WHERE token=$1', [token, users[1]]); + const before = await state(); + const response = await submit(token); + expect(success(response)).toBe(false); + expect(response.text).toContain('admin assistance'); + expect((await pool.query('SELECT status FROM email_link_tokens WHERE token=$1', [token])).rows).toEqual([{ status: 'revoked' }]); + const after = await state(); + for (const table of Object.keys(before).filter(table => !['email_link_tokens', 'test_alias_atomicity_effects'].includes(table))) expect(after[table]).toEqual(before[table]); + }); + it.each(['expired', 'revoked'])('leaves terminal %s tokens untouched', async status => { + await pool.query('UPDATE email_link_tokens SET status=$2 WHERE token=$1', [token, status]); + await assertRollback(); + }); + it('expires a pending token without inserting an alias', async () => { + await pool.query("UPDATE email_link_tokens SET expires_at=NOW()-INTERVAL '1 hour' WHERE token=$1", [token]); + expect(success(await submit(token))).toBe(false); + expect((await pool.query('SELECT status FROM email_link_tokens WHERE token=$1', [token])).rows).toEqual([{ status: 'expired' }]); + expect((await pool.query('SELECT * FROM user_email_aliases WHERE workos_user_id=$1', [users[0]])).rowCount).toBe(0); + }); + + it.each([ + ['user_email_aliases', 'INSERT', "RAISE EXCEPTION 'fault-secret';"], + ['user_email_aliases', 'INSERT', 'RETURN NULL;'], + ['email_link_tokens', 'UPDATE', "RAISE EXCEPTION 'fault-secret';"], + ['email_link_tokens', 'UPDATE', 'RETURN NULL;'], + ['email_link_tokens', 'UPDATE', "NEW.status := 'pending';"], + ['email_link_tokens', 'UPDATE', "NEW.target_email := 'tampered@example.test';"], + ['email_link_tokens', 'UPDATE', "NEW.primary_workos_user_id := 'user_alias_atomicity_b';"], + ['user_email_aliases', 'INSERT', "NEW.workos_user_id := 'user_alias_atomicity_b';"], + ['user_email_aliases', 'INSERT', "NEW.email := 'tampered@example.test';"], + ])('rolls back alias/token and all effects on %s %s fault: %s', async (table, event, body) => { + await fault(table, event, body); + await assertRollback(); + }); + it('rolls back legacy processing on terminal failure and can retry afterward', async () => { + await pool.query("UPDATE email_link_tokens SET status='processing' WHERE token=$1", [token]); + await fault('email_link_tokens', 'UPDATE', 'RETURN NULL;'); + await assertRollback(); + await clearFault(); + expect(success(await submit(token))).toBe(true); + }); + it('rolls back an audit failure', async () => { + await fault('test_alias_atomicity_effects', 'INSERT', "RAISE EXCEPTION 'fault-secret';"); + await assertRollback(); + }); + it('rolls back even epoch and audit effects made by a failing trigger', async () => { + await fault('email_link_tokens', 'UPDATE', `INSERT INTO authorization_epochs(workos_user_id,epoch) VALUES(NEW.primary_workos_user_id,1) + ON CONFLICT(workos_user_id) DO UPDATE SET epoch=authorization_epochs.epoch+1; RAISE EXCEPTION 'fault-secret';`); + await assertRollback(); + }); + it('rejects an AFTER trigger tampering with the persisted terminal row', async () => { + await fault('email_link_tokens', 'UPDATE', `IF pg_trigger_depth()=1 THEN + UPDATE email_link_tokens SET status='pending' WHERE id=NEW.id; END IF;`, 'AFTER'); + await assertRollback(); + }); + it('rejects a token intent changed by the alias INSERT trigger', async () => { + await fault('user_email_aliases', 'INSERT', `UPDATE email_link_tokens SET target_email='tampered@example.test' + WHERE primary_workos_user_id=NEW.workos_user_id;`, 'AFTER'); + await assertRollback(); + }); + it('rejects a terminal trigger that removes the verified alias', async () => { + await fault('email_link_tokens', 'UPDATE', `DELETE FROM user_email_aliases + WHERE workos_user_id=NEW.primary_workos_user_id;`, 'AFTER'); + await assertRollback(); + }); + it('a stale GET expiry read cannot regress a terminal token', async () => { + await pool.query("UPDATE email_link_tokens SET expires_at=NOW()-INTERVAL '1 hour' WHERE token=$1", [token]); + const read = deferred(); + const release = deferred(); + const query = Client.prototype.query; + vi.spyOn(Client.prototype, 'query').mockImplementation(function (this: Client, ...args: any[]) { + // pool.query uses the callback overload; pause delivery of its stale read. + if (typeof args[0] === 'string' && args[0].includes('FROM email_link_tokens WHERE token = $1') + && !args[0].includes('FOR UPDATE') && typeof args[2] === 'function') { + const callback = args[2]; + args[2] = (error: unknown, result: unknown) => { + read.resolve(); + void release.promise.then(() => callback(error, result)); + }; + } + return (query as any).apply(this, args); + }); + const page = request(app).get('/verify-email-link').query({ token }).then(response => response); + try { + await read.promise; + await pool.query("UPDATE email_link_tokens SET status='verified',verified_at=NOW() WHERE token=$1", [token]); + } finally { release.resolve(); } + expect(success(await page)).toBe(false); + expect((await pool.query('SELECT status FROM email_link_tokens WHERE token=$1', [token])).rows).toEqual([{ status: 'verified' }]); + }); + it.each(['alias cardinality', 'token rowCount', 'principal cardinality'])('rejects inconsistent driver evidence: %s', async kind => { + if (kind === 'alias cardinality') await alias(); + const query = Client.prototype.query; + vi.spyOn(Client.prototype, 'query').mockImplementation(function (this: Client, ...args: any[]) { + const result = (query as any).apply(this, args); + const sql = typeof args[0] === 'string' ? args[0] : ''; + if ((kind === 'alias cardinality' && sql.includes('FROM user_email_aliases') && sql.includes('FOR UPDATE')) + || (kind === 'token rowCount' && sql.startsWith('UPDATE email_link_tokens SET status = $7')) + || (kind === 'principal cardinality' && sql.includes('FOR KEY SHARE'))) { + return Promise.resolve(result).then(response => kind === 'token rowCount' + ? { ...response, rowCount: 0 } + : { ...response, rowCount: 2, rows: [response.rows[0], response.rows[0]] }); + } + return result; + }); + await assertRollback(); + }); +}); diff --git a/server/tests/unit/set-primary-email.test.ts b/server/tests/unit/set-primary-email.test.ts index ac61fc83346..344a6b1eee2 100644 --- a/server/tests/unit/set-primary-email.test.ts +++ b/server/tests/unit/set-primary-email.test.ts @@ -133,7 +133,9 @@ describe('Primary email credential boundary', () => { it('checks verification reconciliation with the already-held transaction client', async () => { mocks.clientQuery.mockImplementation(async (sql: string) => ({ + rowCount: sql.includes('FROM email_link_tokens') ? 1 : 0, rows: sql.includes('FROM email_link_tokens') ? [{ + verified_at: null, merge_summary: null, target_workos_user_id: null, id: 'token-id', primary_workos_user_id: 'user_credential', target_email: 'alias@example.test', status: 'pending', expires_at: new Date(Date.now() + 60_000), }] : [],