You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/test/migrate-plaintext.test.ts

63 lines
3.1 KiB
TypeScript

// apps/hq/test/migrate-plaintext.test.ts — D31: plaintext crypto + one-time legacy migration.
import { describe, it, expect } from 'vitest'
import { createCipheriv, randomBytes } from 'node:crypto'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule, revealModulePassword } from '../src/repos-modules'
import { decrypt, isLegacyCiphertext } from '../src/crypto'
import { migrateLegacyCiphertext } from '../src/migrate-plaintext'
const KEY = 'ab'.repeat(32) // 64 hex chars
/** Reproduce the pre-D31 AES-256-GCM writer, to plant legacy ciphertext in the DB. */
function legacyEncrypt(plain: string, keyHex: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), iv)
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
return [iv.toString('hex'), cipher.getAuthTag().toString('hex'), ct.toString('hex')].join('.')
}
describe('D31 crypto', () => {
it('reads plaintext with no key, but a legacy AES payload still needs the key (loud)', () => {
const legacy = legacyEncrypt('hunter2', KEY)
expect(isLegacyCiphertext(legacy)).toBe(true)
expect(isLegacyCiphertext('plain:hunter2')).toBe(false)
expect(decrypt('plain:hunter2', '')).toBe('hunter2') // plaintext, no key
expect(decrypt(legacy, KEY)).toBe('hunter2') // legacy, with key
expect(() => decrypt(legacy, '')).toThrow(/HQ_SECRET_KEY/) // legacy, no key → loud
})
})
describe('migrateLegacyCiphertext (D31)', () => {
it('converts a legacy portal password to plaintext, then it reads keylessly; idempotent', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const c = await createClient(db, 'u1', { name: 'Bank', stateCode: '32' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Plant a legacy AES password directly (as the old importer wrote it).
await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, legacyEncrypt('gw-pass', KEY), cm.id)
// Before migration: no key ⇒ reveal fails loudly.
await expect(revealModulePassword(db, 'u1', cm.id, '')).rejects.toThrow(/HQ_SECRET_KEY/)
const r = await migrateLegacyCiphertext(db, KEY)
expect(r.modulePasswords).toBe(1)
expect(r.errors).toHaveLength(0)
// After migration: reads with NO key.
expect(await revealModulePassword(db, 'u1', cm.id, '')).toBe('gw-pass')
// Stored value is now plaintext (tagged), not the AES shape.
const stored = (await db.get<{ password_enc: string }>(`SELECT password_enc FROM client_module WHERE id=?`, cm.id))!
expect(isLegacyCiphertext(stored.password_enc)).toBe(false)
// Idempotent: a second pass migrates nothing.
expect((await migrateLegacyCiphertext(db, KEY)).modulePasswords).toBe(0)
})
it('requires a key to run at all', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
await expect(migrateLegacyCiphertext(db, '')).rejects.toThrow(/HQ_SECRET_KEY/)
})
})