import { describe, it, expect } from 'vitest' import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import type { AuditContext } from '../src/index' import { hashAccessPassword, setAccessPassword, clearAccessPassword, verifyAccessPassword, anyAccessPasswordConfigured, accessStoreUnreadable, accessPasswordCollides, migrateLegacyRemotePassword, } from '../src/index' // Throwaway audit context for the behaviour tests that do not assert on the // audit line. The log lands beside the users.json fixture and is ignored. function aud(usersFile: string, over: Partial = {}): AuditContext { return { actor: 'test', logFile: join(dirname(usersFile), 'users-audit.log'), ...over } } function readAudit(logFile: string): string[] { if (!existsSync(logFile)) return [] return readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean) } function tmpUsers(content: unknown): string { const dir = mkdtempSync(join(tmpdir(), 'aap-')) const f = join(dir, 'users.json') writeFileSync(f, JSON.stringify(content, null, 2)) return f } describe('admin-access-password', () => { it('sets accessHash on the target record only, preserving siblings', async () => { const f = tmpUsers([ { userId: 'a', pin: 'pinA', phone: '+1' }, { userId: 'b', pin: 'pinB' }, ]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) const users = JSON.parse(readFileSync(f, 'utf-8')) expect(users[0].accessHash).toMatch(/^[0-9a-f]+:[0-9a-f]+$/) expect(users[0].pin).toBe('pinA') expect(users[0].phone).toBe('+1') expect(users[1].accessHash).toBeUndefined() }) it('verifyAccessPassword returns the matching userId, null on miss', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }]) await setAccessPassword('b', 'Sw0rdfish!', f, aud(f)) expect(await verifyAccessPassword('Sw0rdfish!', f)).toBe('b') expect(await verifyAccessPassword('wrong', f)).toBeNull() }) it("clearAccessPassword removes only that record's hash", async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) clearAccessPassword('a', f, aud(f)) const users = JSON.parse(readFileSync(f, 'utf-8')) expect(users[0].accessHash).toBeUndefined() expect(users[0].pin).toBe('x') }) it('anyAccessPasswordConfigured reflects presence', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }]) expect(anyAccessPasswordConfigured(f)).toBe(false) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) expect(anyAccessPasswordConfigured(f)).toBe(true) }) it('verifyAccessPassword returns null when no record has accessHash', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }]) expect(await verifyAccessPassword('anything', f)).toBeNull() }) it('accessPasswordCollides: true when a DIFFERENT admin already has that password', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) // b is choosing the same password a already uses expect(await accessPasswordCollides('Sw0rdfish!', f, 'b')).toBe(true) }) it('accessPasswordCollides: false when only the excluded admin holds that password', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) // a rotating to the same value it already has is not a cross-admin collision expect(await accessPasswordCollides('Sw0rdfish!', f, 'a')).toBe(false) }) it('accessPasswordCollides: false for a password no other admin uses', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) expect(await accessPasswordCollides('Different1!', f, 'b')).toBe(false) }) it('accessPasswordCollides: false when the store is unreadable', async () => { const dir = mkdtempSync(join(tmpdir(), 'aapc-')) const corrupt = join(dir, 'users.json') writeFileSync(corrupt, '{ not json') expect(await accessPasswordCollides('anything', corrupt, 'b')).toBe(false) }) it('setAccessPassword throws and writes nothing when the password collides with another admin', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) const before = readFileSync(f, 'utf-8') await expect(setAccessPassword('b', 'Sw0rdfish!', f, aud(f))).rejects.toThrow(/in use by another admin/) // b gained no hash; a's hash is untouched; the file is byte-identical expect(readFileSync(f, 'utf-8')).toBe(before) // the rejected write emits no audit line (only a's earlier set-access is logged) expect(readAudit(aud(f).logFile)).toHaveLength(1) }) it('setAccessPassword allows a password no other admin holds', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) await setAccessPassword('b', 'Different1!', f, aud(f)) expect(await verifyAccessPassword('Different1!', f)).toBe('b') }) it('setAccessPassword allows the same admin to re-set a password only they hold', async () => { const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }]) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) await setAccessPassword('a', 'Sw0rdfish!', f, aud(f)) // self-rotation, not a collision expect(await verifyAccessPassword('Sw0rdfish!', f)).toBe('a') }) it('accessStoreUnreadable: false for absent/clean, true for corrupt (fail-closed signal)', () => { const dir = mkdtempSync(join(tmpdir(), 'aapu-')) const absent = join(dir, 'users.json') expect(accessStoreUnreadable(absent)).toBe(false) // absent → not unreadable writeFileSync(absent, JSON.stringify([{ userId: 'a', pin: 'x' }])) expect(accessStoreUnreadable(absent)).toBe(false) // clean → readable writeFileSync(absent, '{ this is not json') expect(accessStoreUnreadable(absent)).toBe(true) // corrupt → unreadable → caller fails closed }) }) describe('migrateLegacyRemotePassword', () => { function setup(usersContent: unknown, legacy?: string): { usersFile: string; legacyFile: string } { const dir = mkdtempSync(join(tmpdir(), 'aapm-')) const usersFile = join(dir, 'users.json') const legacyFile = join(dir, '.remote-password') writeFileSync(usersFile, JSON.stringify(usersContent, null, 2)) if (legacy !== undefined) writeFileSync(legacyFile, legacy) return { usersFile, legacyFile } } it('moves the legacy hash into the owner record and deletes the file', async () => { const legacyHash = await hashAccessPassword('OwnerPass1!') const { usersFile, legacyFile } = setup([{ userId: 'owner', pin: 'x' }], legacyHash) const r = migrateLegacyRemotePassword(usersFile, legacyFile, 'owner', aud(usersFile)) expect(r).toBe('migrated') expect(existsSync(legacyFile)).toBe(false) const users = JSON.parse(readFileSync(usersFile, 'utf-8')) expect(users[0].accessHash).toBe(legacyHash) }) it('is idempotent — second run is noop', async () => { const legacyHash = await hashAccessPassword('OwnerPass1!') const { usersFile, legacyFile } = setup([{ userId: 'owner', pin: 'x' }], legacyHash) migrateLegacyRemotePassword(usersFile, legacyFile, 'owner', aud(usersFile)) expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner', aud(usersFile))).toBe('noop-no-legacy') }) it('no owner record → leaves the legacy file in place', async () => { const legacyHash = await hashAccessPassword('OwnerPass1!') const { usersFile, legacyFile } = setup([{ userId: 'someoneelse', pin: 'x' }], legacyHash) expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner', aud(usersFile))).toBe('noop-no-owner') expect(existsSync(legacyFile)).toBe(true) }) it('owner already has accessHash → deletes stale legacy file, noop', async () => { const { usersFile, legacyFile } = setup([{ userId: 'owner', pin: 'x', accessHash: 'aa:bb' }], 'cc:dd') expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner', aud(usersFile))).toBe('noop-owner-has-hash') expect(existsSync(legacyFile)).toBe(false) }) }) describe('users-audit emission', () => { it('setAccessPassword emits one set-access line with actor and unchanged row-set', async () => { const f = tmpUsers([{ userId: 'aaaaaaaa-1', pin: 'x' }, { userId: 'bbbbbbbb-2', pin: 'y' }]) const logFile = join(dirname(f), 'users-audit.log') await setAccessPassword('aaaaaaaa-1', 'Sw0rdfish!', f, { actor: 'cccccccc-9', logFile }) const lines = readAudit(logFile) expect(lines).toHaveLength(1) expect(lines[0]).toContain('[users-audit]') expect(lines[0]).toContain('action=set-access') expect(lines[0]).toContain('field=accessHash') expect(lines[0]).toContain('actor=cccccccc') expect(lines[0]).toContain('rowsBefore=aaaaaaaa,bbbbbbbb') expect(lines[0]).toContain('rowsAfter=aaaaaaaa,bbbbbbbb') expect(lines[0]).toMatch(/ts=\d{4}-\d\d-\d\dT/) expect(lines[0]).not.toContain('session=') }) it('a non-uuid sentinel actor is written whole (not truncated to 8 chars)', async () => { const f = tmpUsers([{ userId: 'aaaaaaaa-1', pin: 'x' }]) const logFile = join(dirname(f), 'users-audit.log') await setAccessPassword('aaaaaaaa-1', 'Sw0rdfish!', f, { actor: 'onboarding', logFile }) expect(readAudit(logFile)[0]).toContain('actor=onboarding') }) it('session= appears only when supplied', async () => { const f = tmpUsers([{ userId: 'aaaaaaaa-1', pin: 'x' }]) const logFile = join(dirname(f), 'users-audit.log') await setAccessPassword('aaaaaaaa-1', 'Sw0rdfish!', f, { actor: 'cccccccc-9', session: 'dddddddd-7', logFile }) expect(readAudit(logFile)[0]).toContain('session=dddddddd') }) it('clearAccessPassword on a record with a hash emits clear-access', async () => { const f = tmpUsers([{ userId: 'aaaaaaaa-1', pin: 'x' }]) const logFile = join(dirname(f), 'users-audit.log') await setAccessPassword('aaaaaaaa-1', 'Sw0rdfish!', f, { actor: 'a', logFile }) clearAccessPassword('aaaaaaaa-1', f, { actor: 'cccccccc-9', logFile }) const lines = readAudit(logFile) expect(lines).toHaveLength(2) expect(lines[1]).toContain('action=clear-access') expect(lines[1]).toContain('field=accessHash') expect(lines[1]).toContain('actor=cccccccc') }) it('clearAccessPassword on a record with no hash is a noop and emits nothing', () => { const f = tmpUsers([{ userId: 'aaaaaaaa-1', pin: 'x' }]) const logFile = join(dirname(f), 'users-audit.log') clearAccessPassword('aaaaaaaa-1', f, { actor: 'cccccccc-9', logFile }) expect(readAudit(logFile)).toHaveLength(0) }) it('migrate emits one migrate line; the noop variants emit nothing', async () => { const legacyHash = await hashAccessPassword('OwnerPass1!') const dir = mkdtempSync(join(tmpdir(), 'aapmaudit-')) const usersFile = join(dir, 'users.json') const legacyFile = join(dir, '.remote-password') const logFile = join(dir, 'users-audit.log') writeFileSync(usersFile, JSON.stringify([{ userId: 'owner-aaa', pin: 'x' }], null, 2)) writeFileSync(legacyFile, legacyHash) expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner-aaa', { actor: 'boot', logFile })).toBe('migrated') let lines = readAudit(logFile) expect(lines).toHaveLength(1) expect(lines[0]).toContain('action=migrate') expect(lines[0]).toContain('field=accessHash') expect(lines[0]).toContain('actor=boot') // second run is a noop → no new line expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner-aaa', { actor: 'boot', logFile })).toBe('noop-no-legacy') expect(readAudit(logFile)).toHaveLength(1) }) }) describe('users-audit target attribution (Task 1573)', () => { it('emits target and explicitUserId on a set-access write when the audit carries them', async () => { const root = mkdtempSync(join(tmpdir(), 'aap-tgt-')) const usersFile = join(root, 'users.json') const logFile = join(root, 'users-audit.log') writeFileSync(usersFile, JSON.stringify([{ userId: 'bbbbbbbb-2222', pin: 'x' }])) await setAccessPassword('bbbbbbbb-2222', 'Sw0rdfish!', usersFile, { actor: 'aaaaaaaa-1111', target: 'bbbbbbbb-2222', explicitUserId: true, logFile, }) const line = readAudit(logFile)[0] expect(line).toContain('action=set-access') expect(line).toContain('actor=aaaaaaaa') expect(line).toContain('target=bbbbbbbb') expect(line).toContain('explicitUserId=true') }) it('omits target fields for a self write with no target set', async () => { const root = mkdtempSync(join(tmpdir(), 'aap-self-')) const usersFile = join(root, 'users.json') const logFile = join(root, 'users-audit.log') writeFileSync(usersFile, JSON.stringify([{ userId: 'aaaaaaaa-1111', pin: 'x' }])) await setAccessPassword('aaaaaaaa-1111', 'Sw0rdfish!', usersFile, { actor: 'aaaaaaaa-1111', logFile }) const line = readAudit(logFile)[0] expect(line).not.toContain('target=') expect(line).not.toContain('explicitUserId=') }) })