import { describe, it, expect, beforeAll } from 'vitest'; import { execFileSync } from 'child_process'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import Database from 'better-sqlite3'; // Migration idempotency for the Better Auth backfill: seed legacy-shaped user // rows, run migrate repeatedly, and assert the backfill is correct and never // duplicates. const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-')); const dbPath = join(dir, 'migrate.db'); function runMigrate() { execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env, DB_TYPE: 'sqlite', DATABASE_URL: dbPath, REDIS_URL: '', }, stdio: 'pipe', }); } let db: Database.Database; beforeAll(() => { // First run creates the schema runMigrate(); db = new Database(dbPath); // Seed legacy-shaped users (pre-Better-Auth): password lives on users, // google_id links Google, '' marks guest accounts, suspended via status. const now = new Date().toISOString(); const insert = db.prepare( `INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)` ); insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now); insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now); insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now); insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now); insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now); insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now); // Second run performs the backfill against the seeded rows runMigrate(); }, 240_000); describe('Better Auth migration backfill', () => { it('creates credential accounts for users with real passwords only', () => { const rows = db .prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id") .all() as any[]; const byUser = new Map(rows.map((r) => [r.user_id, r.password])); expect(byUser.get('legacy-argon')).toContain('$argon2id$'); expect(byUser.get('legacy-bcrypt')).toContain('$2a$'); expect(byUser.get('legacy-both')).toContain('$argon2id$'); expect(byUser.get('legacy-suspended')).toBeTruthy(); // Guests ('' password) and Google-only users get no credential account expect(byUser.has('legacy-guest')).toBe(false); expect(byUser.has('legacy-google')).toBe(false); }); it('creates google accounts from google_id', () => { const rows = db .prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id") .all() as any[]; expect(rows).toEqual([ { user_id: 'legacy-both', account_id: 'google-sub-456' }, { user_id: 'legacy-google', account_id: 'google-sub-123' }, ]); }); it('marks claimed legacy users email-verified, guests not', () => { const verified = (email: string) => (db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified; expect(verified('argon@old.py')).toBe(1); expect(verified('google@old.py')).toBe(1); expect(verified('guest@old.py')).toBe(0); }); it('mirrors suspended accounts to banned', () => { const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any; expect(row.banned).toBe(1); expect(row.ban_reason).toContain('suspended'); const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any; expect(active.banned).toBe(0); }); it('is idempotent: a third run adds nothing', () => { const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n; const before = count(); runMigrate(); expect(count()).toBe(before); }, 60_000); });