import { describe, it, expect } from 'vitest' import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { writeAdminEntry, removeAdminFromAccount, computeAdminStoreDivergence, logUsersAudit, formatAuditRowIds, } from '../src/index' interface Fixture { usersFile: string accountDir: string accountsDir: string logFile: string } /** One account under accountsDir//account.json plus the persistent users.json. */ function fixture(opts: { users: Array<{ userId: string; pin?: string }> admins: Array<{ userId: string; role: 'owner' | 'admin' }> accountId?: string }): Fixture { const root = mkdtempSync(join(tmpdir(), 'admw-')) const accountsDir = join(root, 'accounts') const accountId = opts.accountId ?? 'acct0001' const accountDir = join(accountsDir, accountId) mkdirSync(accountDir, { recursive: true }) const usersFile = join(root, 'users.json') writeFileSync(usersFile, JSON.stringify(opts.users.map(u => ({ pin: 'p', ...u })), null, 2) + '\n') writeFileSync(join(accountDir, 'account.json'), JSON.stringify({ accountId, admins: opts.admins }, null, 2) + '\n') return { usersFile, accountDir, accountsDir, logFile: join(root, 'users-audit.log') } } function readAudit(logFile: string): string[] { if (!existsSync(logFile)) return [] return readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean) } function accountAdmins(accountDir: string): string[] { const cfg = JSON.parse(readFileSync(join(accountDir, 'account.json'), 'utf-8')) return (cfg.admins as Array<{ userId: string }>).map(a => a.userId) } describe('admins-write — users-audit emission', () => { it('writeAdminEntry on a NEW userId emits action=add field=row with the grown row-set', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }] }) writeAdminEntry({ userId: 'bbbbbbbb-2', pin: 'hash', role: 'admin', usersFile: f.usersFile, accountDir: f.accountDir, caller: 'admin-add', audit: { actor: 'aaaaaaaa-1', logFile: f.logFile }, }) const lines = readAudit(f.logFile) expect(lines).toHaveLength(1) expect(lines[0]).toContain('action=add') expect(lines[0]).toContain('field=row') expect(lines[0]).toContain('actor=aaaaaaaa') expect(lines[0]).toContain('rowsBefore=aaaaaaaa') expect(lines[0]).toContain('rowsAfter=aaaaaaaa,bbbbbbbb') }) it('writeAdminEntry on an EXISTING userId emits action=set-pin field=pin with an unchanged row-set', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }, { userId: 'bbbbbbbb-2' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }, { userId: 'bbbbbbbb-2', role: 'admin' }], }) writeAdminEntry({ userId: 'bbbbbbbb-2', pin: 'newhash', role: 'admin', usersFile: f.usersFile, accountDir: f.accountDir, caller: 'admin-update-pin', audit: { actor: 'bbbbbbbb-2', session: 'dddddddd-7', logFile: f.logFile }, }) const lines = readAudit(f.logFile) expect(lines).toHaveLength(1) expect(lines[0]).toContain('action=set-pin') expect(lines[0]).toContain('field=pin') expect(lines[0]).toContain('session=dddddddd') expect(lines[0]).toContain('rowsBefore=aaaaaaaa,bbbbbbbb') expect(lines[0]).toContain('rowsAfter=aaaaaaaa,bbbbbbbb') }) it('removeAdminFromAccount on a present admin emits action=remove field=row on the account set', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }, { userId: 'bbbbbbbb-2' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }, { userId: 'bbbbbbbb-2', role: 'admin' }], }) removeAdminFromAccount({ userId: 'bbbbbbbb-2', accountDir: f.accountDir, caller: 'admin-remove', audit: { actor: 'aaaaaaaa-1', logFile: f.logFile }, }) const lines = readAudit(f.logFile) expect(lines).toHaveLength(1) expect(lines[0]).toContain('action=remove') expect(lines[0]).toContain('field=row') expect(lines[0]).toContain('rowsBefore=aaaaaaaa,bbbbbbbb') expect(lines[0]).toContain('rowsAfter=aaaaaaaa') expect(accountAdmins(f.accountDir)).toEqual(['aaaaaaaa-1']) }) it('removeAdminFromAccount on an absent admin is a noop and emits nothing', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }] }) removeAdminFromAccount({ userId: 'zzzzzzzz-9', accountDir: f.accountDir, caller: 'admin-remove', audit: { actor: 'aaaaaaaa-1', logFile: f.logFile }, }) expect(readAudit(f.logFile)).toHaveLength(0) }) }) describe('admins-write — public audit helper (for direct users.json writers)', () => { it('formatAuditRowIds joins 8-char short ids', () => { expect(formatAuditRowIds(['aaaaaaaa-1', 'bbbbbbbb-2'])).toBe('aaaaaaaa,bbbbbbbb') }) it('logUsersAudit writes one canonical set-pin line', () => { const root = mkdtempSync(join(tmpdir(), 'admwlog-')) const logFile = join(root, 'users-audit.log') logUsersAudit( { actor: 'cccccccc-9', session: 'dddddddd-7', logFile }, { action: 'set-pin', field: 'pin', rowsBefore: 'aaaaaaaa', rowsAfter: 'aaaaaaaa' }, ) const lines = readAudit(logFile) expect(lines).toHaveLength(1) expect(lines[0]).toContain('action=set-pin') expect(lines[0]).toContain('field=pin') expect(lines[0]).toContain('actor=cccccccc') expect(lines[0]).toContain('session=dddddddd') }) }) describe('admins-write — computeAdminStoreDivergence', () => { it('flags an admin in account.json with no users.json row (the incident shape)', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }, { userId: 'bbbbbbbb-2', role: 'admin' }], }) const r = computeAdminStoreDivergence({ usersFile: f.usersFile, accountsDir: f.accountsDir }) expect(r.divergences).toBe(1) expect(r.accountWithoutUsers.map(x => x.userId)).toEqual(['bbbbbbbb-2']) expect(r.usersWithoutAccount).toEqual([]) }) it('flags a users.json row no account.json references', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }, { userId: 'cccccccc-3' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }], }) const r = computeAdminStoreDivergence({ usersFile: f.usersFile, accountsDir: f.accountsDir }) expect(r.divergences).toBe(1) expect(r.usersWithoutAccount.map(x => x.userId)).toEqual(['cccccccc-3']) expect(r.accountWithoutUsers).toEqual([]) }) it('clean parity → zero divergences', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }, { userId: 'bbbbbbbb-2' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }, { userId: 'bbbbbbbb-2', role: 'admin' }], }) const r = computeAdminStoreDivergence({ usersFile: f.usersFile, accountsDir: f.accountsDir }) expect(r.divergences).toBe(0) }) it('an audited remove of the diverging row clears the account-side mismatch', () => { const f = fixture({ users: [{ userId: 'aaaaaaaa-1' }], admins: [{ userId: 'aaaaaaaa-1', role: 'owner' }, { userId: 'bbbbbbbb-2', role: 'admin' }], }) // account-without-users divergence present expect(computeAdminStoreDivergence({ usersFile: f.usersFile, accountsDir: f.accountsDir }).divergences).toBe(1) // audited delete of the orphan account row removeAdminFromAccount({ userId: 'bbbbbbbb-2', accountDir: f.accountDir, caller: 'admin-remove', audit: { actor: 'aaaaaaaa-1', logFile: f.logFile }, }) expect(readAudit(f.logFile)[0]).toContain('action=remove') expect(readAudit(f.logFile)[0]).toContain('actor=aaaaaaaa') // account no longer lists it → parity restored expect(computeAdminStoreDivergence({ usersFile: f.usersFile, accountsDir: f.accountsDir }).divergences).toBe(0) void dirname }) }) describe('users-audit target attribution (Task 1573)', () => { it('emits target and explicitUserId when a cross-identity write is logged', () => { const root = mkdtempSync(join(tmpdir(), 'auditctx-')) const logFile = join(root, 'users-audit.log') logUsersAudit( { actor: 'aaaaaaaa-1111', target: 'bbbbbbbb-2222', explicitUserId: true, logFile }, { action: 'set-pin', field: 'pin', rowsBefore: 'aaaaaaaa', rowsAfter: 'aaaaaaaa' }, ) const line = readFileSync(logFile, 'utf-8') 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', () => { const root = mkdtempSync(join(tmpdir(), 'auditctx-')) const logFile = join(root, 'users-audit.log') logUsersAudit( { actor: 'aaaaaaaa-1111', logFile }, { action: 'set-pin', field: 'pin', rowsBefore: 'aaaaaaaa', rowsAfter: 'aaaaaaaa' }, ) const line = readFileSync(logFile, 'utf-8') expect(line).not.toContain('target=') expect(line).not.toContain('explicitUserId=') }) })