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

59 lines
3.1 KiB
TypeScript

// apps/hq/test/migrate-plaintext.test.ts — D31/D32: keyless plaintext crypto + legacy cleanup.
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, getClientModule } from '../src/repos-modules'
import { decrypt, encrypt, isLegacyCiphertext } from '../src/crypto'
import { clearLegacyCiphertext } from '../src/migrate-plaintext'
const KEY = 'ab'.repeat(32) // 64 hex chars — only used to fabricate a legacy value
/** Reproduce the pre-D31 AES-256-GCM writer, to plant an (now unreadable) legacy value. */
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('D32 keyless crypto', () => {
it('encrypt tags plaintext and decrypt untags it — no key involved', () => {
expect(encrypt('hunter2')).toBe('plain:hunter2')
expect(decrypt('plain:hunter2')).toBe('hunter2')
expect(decrypt('plain:hunter2', 'ignored-key')).toBe('hunter2')
})
it('a leftover legacy AES value is unreadable and reads back as empty (never ciphertext)', () => {
const legacy = legacyEncrypt('old-secret', KEY)
expect(isLegacyCiphertext(legacy)).toBe(true)
expect(isLegacyCiphertext('plain:old-secret')).toBe(false)
expect(decrypt(legacy)).toBe('') // key removed → unreadable → treated as unset, not garbage
})
})
describe('clearLegacyCiphertext (D32)', () => {
it('nulls unreadable legacy passwords, leaves plaintext alone, is idempotent', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const legacyC = await createClient(db, 'u1', { name: 'Legacy Bank', stateCode: '32' })
const plainC = await createClient(db, 'u1', { name: 'Fresh Bank', stateCode: '32' })
const legacyCm = await assignModule(db, 'u1', { clientId: legacyC.id, moduleId: m.id, kind: 'yearly' })
const plainCm = await assignModule(db, 'u1', { clientId: plainC.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, legacyEncrypt('gone', KEY), legacyCm.id)
await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, encrypt('keepme'), plainCm.id)
const r = await clearLegacyCiphertext(db)
expect(r.modulePasswords).toBe(1) // only the legacy one
expect((await getClientModule(db, legacyCm.id))!.hasPassword).toBe(false) // cleared → not set
// the plaintext one survives and still reads back
expect((await getClientModule(db, plainCm.id))!.hasPassword).toBe(true)
expect(await revealModulePassword(db, 'u1', plainCm.id, '')).toBe('keepme')
// idempotent: nothing legacy left
expect((await clearLegacyCiphertext(db)).modulePasswords).toBe(0)
})
})