From 96a28f575fdc07721e6b899dcbce2ea57c67e419 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 19:11:45 +0700 Subject: [PATCH] test(tenant): fix payroll ledger isolation query and verify migration rls --- tests/integration/tenant-isolation.test.ts | 13 ++---- tests/unit/payroll-auto-calc.test.ts | 10 +++++ tests/unit/tenant-sql.test.ts | 51 ++++++++++++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/tests/integration/tenant-isolation.test.ts b/tests/integration/tenant-isolation.test.ts index a815ccd..4ca441b 100644 --- a/tests/integration/tenant-isolation.test.ts +++ b/tests/integration/tenant-isolation.test.ts @@ -47,13 +47,6 @@ describeDatabase('PostgreSQL tenant isolation', () => { ); expect(visibleToB.rowCount).toBe(0); - // Test staff_pay_assignments isolation - await database.tenantTransaction(tenantA, actorA, async () => { - // Need related records to satisfy foreign keys if they exist, or if we can bypass them. - // Or maybe we can just catch RLS errors on INSERT if we use the wrong tenant_id - // Let's just check that RLS prevents INSERT with wrong tenant_id - }); - await expect( database.tenantTransaction(tenantB, actorB, (client) => client.query( @@ -80,9 +73,9 @@ describeDatabase('PostgreSQL tenant isolation', () => { database.tenantTransaction(tenantB, actorB, (client) => client.query( `INSERT INTO staff_payroll_ledger - (id, tenant_id, employee_id, amount_minor, currency_code, business_date, run_id, created_by) - VALUES ($1, $2, $3, 1000, 'RUB', '2026-01-01', $4, $5)`, - [newUlid(), tenantA, newUlid(), newUlid(), actorB], + (id, tenant_id, employee_id, business_date, kind, amount_minor, currency_code, ref_type, ref_id, correlation_id, note, created_by, external_key) + VALUES ($1, $2, $3, '2026-01-01', 'accrual', 1000, 'RUB', 'payroll_run', $4, $5, 'test', $6, $7)`, + [newUlid(), tenantA, newUlid(), newUlid(), newUlid(), actorB, newUlid()], ), ), ).rejects.toThrow(); diff --git a/tests/unit/payroll-auto-calc.test.ts b/tests/unit/payroll-auto-calc.test.ts index ba743f7..1d0eef2 100644 --- a/tests/unit/payroll-auto-calc.test.ts +++ b/tests/unit/payroll-auto-calc.test.ts @@ -619,4 +619,14 @@ describe('Tenant and employee isolation guarantees', () => { expect(operationsSrc).toContain('!canManage && emp.core_user_id !== context.actorId && emp.id !== context.actorId'); }); + + it('confirms external_key and unique index are declared in 0008_employee_access migration', () => { + const migrationSql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8'); + expect(migrationSql).toContain('ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;'); + expect(migrationSql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx'); + expect(migrationSql).toContain( + 'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;', + ); + }); }); + diff --git a/tests/unit/tenant-sql.test.ts b/tests/unit/tenant-sql.test.ts index 3aca4dc..5468429 100644 --- a/tests/unit/tenant-sql.test.ts +++ b/tests/unit/tenant-sql.test.ts @@ -78,6 +78,32 @@ describe('payroll foundation migration', () => { ); }); + const payrollTables = [ + 'staff_pay_rules', + 'staff_pay_assignments', + 'staff_payroll_runs', + 'staff_payroll_lines', + 'staff_payroll_ledger', + ]; + + it.each(payrollTables)('verifies %s has tenant_id column', (table) => { + const create = sql.slice( + sql.indexOf(`CREATE TABLE ${table}`), + sql.indexOf(';', sql.indexOf(`CREATE TABLE ${table}`)), + ); + expect(create).toContain('tenant_id VARCHAR(26) NOT NULL'); + }); + + it('enforces tenant isolation and RLS on staff_payroll_ledger with select and insert policies', () => { + expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY'); + expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY'); + expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger'); + expect(sql).toContain("FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))"); + expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger'); + expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)"); + expect(sql).toContain("created_by = current_setting('app.user_id', true)"); + }); + it('keeps the money ledger append-only for the runtime role', () => { expect(sql).toContain('CREATE TABLE staff_payroll_ledger'); expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY'); @@ -88,3 +114,28 @@ describe('payroll foundation migration', () => { ); }); }); + +describe('employee access migration', () => { + const sql = readFileSync('migrations/0008_employee_access.up.sql', 'utf8'); + + it('declares external_key and staff_payroll_ledger_external_key_idx index on staff_payroll_ledger', () => { + expect(sql).toContain('ALTER TABLE staff_payroll_ledger ADD COLUMN external_key TEXT;'); + expect(sql).toContain('CREATE UNIQUE INDEX staff_payroll_ledger_external_key_idx'); + expect(sql).toContain( + 'ON staff_payroll_ledger (tenant_id, external_key) WHERE external_key IS NOT NULL;', + ); + }); + + it('protects standalone employee access with tenant isolation and RLS', () => { + const create = sql.slice( + sql.indexOf('CREATE TABLE staff_standalone_employee_access'), + sql.indexOf(';', sql.indexOf('CREATE TABLE staff_standalone_employee_access')), + ); + expect(create).toContain('tenant_id VARCHAR(26) NOT NULL'); + expect(sql).toContain('ALTER TABLE staff_standalone_employee_access ENABLE ROW LEVEL SECURITY'); + expect(sql).toContain('ALTER TABLE staff_standalone_employee_access FORCE ROW LEVEL SECURITY'); + expect(sql).toContain('CREATE POLICY staff_standalone_employee_access_tenant_isolation'); + expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)"); + }); +}); +