// Security regression test for resolveGoogleUser: a Google sign-in must NEVER // adopt a pre-existing local account by matching email (that would be account // takeover). It links only by google_sub, otherwise provisions a fresh viewer. // Skips without TEST_DATABASE_URL. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; import { hashPassword } from '../../src/auth/passwords.js'; const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set'; async function loadResolve() { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; return (await import('../../src/routes/auth.js?v=' + Date.now())).resolveGoogleUser; } test('a Google login with an email matching a local admin does NOT take over that account', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { // Pre-existing local admin with a password and the same email the attacker controls. const adminId = (await pool.query( `INSERT INTO users (username, password_hash, role, email) VALUES ('boss', $1, 'admin', 'boss@wilddragon.net') RETURNING id`, [await hashPassword('a-real-password-12')])).rows[0].id; const resolveGoogleUser = await loadResolve(); const user = await resolveGoogleUser({ sub: 'google-attacker-sub', email: 'boss@wilddragon.net', name: 'Not The Boss' }); // Must be a brand-new account, NOT the admin. assert.notEqual(user.id, adminId, 'Google login must not resolve to the existing admin'); const row = (await pool.query(`SELECT role, google_sub FROM users WHERE id = $1`, [user.id])).rows[0]; assert.equal(row.role, 'viewer', 'auto-provisioned account must be a viewer'); assert.equal(row.google_sub, 'google-attacker-sub'); // The admin row is untouched (no google_sub linked onto it). const admin = (await pool.query(`SELECT google_sub FROM users WHERE id = $1`, [adminId])).rows[0]; assert.equal(admin.google_sub, null, 'the existing admin must not have been linked'); } finally { await pool.end(); } }); test('a returning Google user resolves to the same account by google_sub', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { const resolveGoogleUser = await loadResolve(); const first = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' }); const second = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' }); assert.equal(first.id, second.id, 'same google_sub must map to the same user'); const count = (await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE google_sub = 'sub-123'`)).rows[0].n; assert.equal(count, 1, 'must not create a duplicate on second login'); } finally { await pool.end(); } }); test('username collisions get a numeric suffix', { skip: SKIP }, async () => { const pool = await setupTestDb(); process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; try { await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('sam', 'x', 'viewer')`); const resolveGoogleUser = await loadResolve(); const u = await resolveGoogleUser({ sub: 'sub-sam', email: 'sam@wilddragon.net', name: 'Sam' }); assert.match(u.username, /^sam\d+$/, 'colliding username should get a numeric suffix'); } finally { await pool.end(); } });