krona-staff/tests/integration/tenant-isolation.test.ts
grok 19a028970d test(integration): очистка tenant-isolation ролью мигратора (N5)
Зачем: afterAll удалял строки соединением роли приложения, а у
krona_staff_app намеренно нет DELETE на staff_employees. На реальной
PostgreSQL 17 тест проходил, но файл падал в afterAll ("permission denied
for table staff_employees", exit 1), поэтому любой CI с DATABASE_TEST_URL
был бы красным не из-за дефекта продукта, а из-за самого теста.
Выдавать app-роли DELETE ради теста нельзя: это ослабило бы модель прав.

Что сделано:
- очистка идёт отдельным соединением роли мигратора
  (DATABASE_TEST_MIGRATION_URL или DATABASE_MIGRATION_URL, который CI уже
  задаёт); так как FORCE RLS действует и на владельца таблиц, в транзакции
  выставляется app.tenant_id; удаляются строки обоих тестовых tenant;
- очистка выполняется и в beforeAll, чтобы остатки прошлого упавшего
  прогона не ломали уникальные ключи (EMP-PUSH-1, DEV-A);
- без URL мигратора тест падает с понятной ошибкой, а не оставляет мусор.

До: tests/e2e/evidence/n5-before.txt (1 passed, suite FAIL, exit=1).
После: tests/e2e/evidence/n5-after.txt (два прогона подряд, exit=0).
2026-09-26 15:18:31 +07:00

313 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Database } from '../../src/infrastructure/database.js';
import { newUlid, parseUlid } from '../../src/core/ids.js';
const connectionString = process.env.DATABASE_TEST_URL;
const describeDatabase = connectionString ? describe : describe.skip;
// Роль приложения намеренно не имеет DELETE на staff_employees и ряде других таблиц,
// поэтому очистка идёт соединением роли мигратора (владельца таблиц). FORCE RLS действует
// и на владельца, поэтому внутри транзакции выставляется app.tenant_id.
const migrationConnectionString =
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
const cleanupTables = [
'staff_sync_device_checkpoints',
'staff_sync_command_inbox',
'staff_sync_change_log',
'staff_sync_devices',
'staff_notification_deliveries',
'staff_push_subscriptions',
'staff_outbox',
'staff_professions',
'staff_employees',
] as const;
describeDatabase('PostgreSQL tenant isolation', () => {
const database = new Database(connectionString!);
const tenantA = parseUlid('01J0000000000000000000000A');
const tenantB = parseUlid('01J0000000000000000000000B');
const actorA = parseUlid('01J0000000000000000000001A');
const actorB = parseUlid('01J0000000000000000000001B');
const eventId = newUlid();
const professionId = newUlid();
const employeeIdA = newUlid();
const subscriptionIdA = newUlid();
const deliveryIdA = newUlid();
const deviceIdA = newUlid();
const changeIdA = newUlid();
const commandIdA = newUlid();
async function cleanupAsMigrator() {
if (!migrationConnectionString) {
throw new Error(
'DATABASE_TEST_MIGRATION_URL (или DATABASE_MIGRATION_URL) обязателен вместе с DATABASE_TEST_URL: очистка выполняется ролью мигратора',
);
}
const migrator = new pg.Client({
connectionString: migrationConnectionString,
application_name: 'krona-staff-test-cleanup',
});
await migrator.connect();
try {
for (const tenant of [tenantA, tenantB]) {
await migrator.query('BEGIN');
try {
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenant]);
for (const table of cleanupTables) {
await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenant]);
}
await migrator.query('COMMIT');
} catch (error) {
await migrator.query('ROLLBACK');
throw error;
}
}
} finally {
await migrator.end();
}
}
beforeAll(async () => {
const role = await database.pool.query(
'SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user',
);
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
// Остатки прошлого упавшего прогона иначе ломают уникальные ключи (EMP-PUSH-1, DEV-A).
await cleanupAsMigrator();
});
afterAll(async () => {
try {
await cleanupAsMigrator();
} finally {
await database.close();
}
});
it('prevents reads and writes across two tenants', async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at)
VALUES ($1, $2, 'staff.Test', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`,
[eventId, tenantA, newUlid(), actorA, newUlid()],
);
});
const visibleToA = await database.tenantTransaction(tenantA, actorA, (client) =>
client.query('SELECT id FROM staff_outbox WHERE id = $1', [eventId]),
);
expect(visibleToA.rowCount).toBe(1);
const visibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_outbox WHERE id = $1', [eventId]),
);
expect(visibleToB.rowCount).toBe(0);
await database.tenantTransaction(tenantA, actorA, (client) =>
client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name)
VALUES ($1, $2, 'cook', 'Повар')`,
[professionId, tenantA],
),
);
const professionsVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_professions WHERE id = $1', [professionId]),
);
expect(professionsVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_professions (id, tenant_id, code, name)
VALUES ($1, $2, 'foreign', 'Чужая профессия')`,
[newUlid(), tenantA],
),
),
).rejects.toThrow();
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_outbox
(id, tenant_id, event_type, event_version, subject_type, subject_id, actor_type, actor_id, payload, correlation_id, occurred_at)
VALUES ($1, $2, 'staff.Test', 1, 'staff.Employee', $3, 'user', $4, '{}'::jsonb, $5, now())`,
[newUlid(), tenantA, newUlid(), actorB, newUlid()],
),
),
).rejects.toThrow();
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_pay_assignments
(id, tenant_id, employee_id, pay_rule_id, effective_from, params, pool_weight_bp, created_by)
VALUES ($1, $2, $3, $4, '2026-01-01', '{}'::jsonb, 0, $5)`,
[newUlid(), tenantA, newUlid(), newUlid(), actorB],
),
),
).rejects.toThrow();
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_payroll_ledger
(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();
// Push subscriptions tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name)
VALUES ($1, $2, 'EMP-PUSH-1', 'Тестовый сотрудник')`,
[employeeIdA, tenantA],
);
await client.query(
`INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, 'https://push.example.com/subA', 'test-p256dh', 'test-auth')`,
[subscriptionIdA, tenantA, employeeIdA],
);
});
const subVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]),
);
expect(subVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, 'https://push.example.com/subB', 'key', 'auth')`,
[newUlid(), tenantA, employeeIdA],
),
),
).rejects.toThrow();
// Notification deliveries tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status)
VALUES ($1, $2, $3, $4, 'https://push.example.com/subA', 'delivered')`,
[deliveryIdA, tenantA, employeeIdA, eventId],
);
});
const delVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]),
);
expect(delVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status)
VALUES ($1, $2, $3, $4, 'https://push.example.com/subB', 'pending')`,
[newUlid(), tenantA, employeeIdA, eventId],
),
),
).rejects.toThrow();
// Offline sync devices tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, created_by)
VALUES ($1, $2, 'DEV-A', 'Terminal A', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'kstaff_dev_test', 'active', $3)`,
[deviceIdA, tenantA, actorA],
);
});
const devVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_sync_devices WHERE id = $1', [deviceIdA]),
);
expect(devVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, created_by)
VALUES ($1, $2, 'DEV-B-FAIL', 'Fail Terminal', 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', 'hint', 'active', $3)`,
[newUlid(), tenantA, actorB],
),
),
).rejects.toThrow();
// Offline sync change log tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation, revision, payload)
VALUES ($1, $2, 'employee', $3, 'upsert', 1, '{}'::jsonb)`,
[changeIdA, tenantA, employeeIdA],
);
});
const changeVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_sync_change_log WHERE id = $1', [changeIdA]),
);
expect(changeVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_change_log (id, tenant_id, entity_type, entity_id, operation, revision, payload)
VALUES ($1, $2, 'employee', $3, 'upsert', 1, '{}'::jsonb)`,
[newUlid(), tenantA, employeeIdA],
),
),
).rejects.toThrow();
// Offline sync command inbox tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_command_inbox
(id, tenant_id, device_id, command_type, schema_version, idempotency_key, correlation_id, payload, status, created_at)
VALUES ($1, $2, $3, 'attendance_event', 1, 'idemp-cmd-a', $4, '{}'::jsonb, 'applied', now())`,
[commandIdA, tenantA, deviceIdA, newUlid()],
);
});
const cmdVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT id FROM staff_sync_command_inbox WHERE id = $1', [commandIdA]),
);
expect(cmdVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_command_inbox
(id, tenant_id, device_id, command_type, schema_version, idempotency_key, correlation_id, payload, status, created_at)
VALUES ($1, $2, $3, 'attendance_event', 1, 'idemp-cmd-b', $4, '{}'::jsonb, 'applied', now())`,
[newUlid(), tenantA, deviceIdA, newUlid()],
),
),
).rejects.toThrow();
// Offline sync checkpoints tenant isolation
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query(
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor)
VALUES ($1, $2, 10, 'cur_10_test')`,
[tenantA, deviceIdA],
);
});
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]),
);
expect(cpVisibleToB.rowCount).toBe(0);
await expect(
database.tenantTransaction(tenantB, actorB, (client) =>
client.query(
`INSERT INTO staff_sync_device_checkpoints (tenant_id, device_id, last_acked_cursor_seq, last_acked_cursor)
VALUES ($1, $2, 10, 'cur_10_fail')`,
[tenantA, deviceIdA],
),
),
).rejects.toThrow();
});
});