krona-staff/tests/integration/tenant-isolation.test.ts
grok 29fefc1933 test(db): стенд PostgreSQL с мигратором без SUPERUSER
Зачем: аудит показал, что 156 зелёных тестов на fakeDatabase не заметили,
что создание сотрудника, подтверждение смены и расчёт зарплаты всегда
падают на реальной PostgreSQL. Чтобы тесты и CI проверяли реальный SQL,
нужен воспроизводимый стенд с ролями как в pilot, но с минимальными
привилегиями:

- scripts/test/provision-pg.mjs: bootstrap-админ (только как DBA/entrypoint)
  создаёт krona_migrator (NOSUPERUSER NOCREATEDB NOCREATEROLE, владелец БД и
  схемы public) и krona_staff_app (NOSUPERUSER NOBYPASSRLS NOINHERIT); пароли
  crypto.randomBytes, пишутся только в env-файл 0600, в вывод не попадают.
  scripts/create-app-role.mjs здесь не используется, т.к. без SUPERUSER он
  падает (находка N6, продуктовый код не трогаем).
- scripts/test/migrate-roundtrip.mjs: up -> откат всех миграций -> up ->
  повторный up должен быть no-op; ролью мигратора, как требует AGENTS.md.

Проверено на PostgreSQL 17.11: 11 миграций применены, откачены, применены
повторно, третий up пустой.
2026-09-26 14:21:47 +07:00

315 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();
});
});