370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
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_biometric_consents',
|
||
'staff_batch_notifications',
|
||
'staff_payroll_batch_lines',
|
||
'staff_payroll_batches',
|
||
'staff_access_grants',
|
||
'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();
|
||
|
||
// Access grants tenant isolation
|
||
const grantIdA = newUlid();
|
||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||
await client.query(
|
||
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
||
VALUES ($1, $2, 'point_manager', 'Manager A', '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hint', $3)`,
|
||
[grantIdA, tenantA, actorA],
|
||
);
|
||
});
|
||
|
||
const grantVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||
client.query('SELECT id FROM staff_access_grants WHERE id = $1', [grantIdA]),
|
||
);
|
||
expect(grantVisibleToB.rowCount).toBe(0);
|
||
|
||
await expect(
|
||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||
client.query(
|
||
`INSERT INTO staff_access_grants (id, tenant_id, role, display_name, token_hash, token_hint, created_by)
|
||
VALUES ($1, $2, 'point_manager', 'Manager B Fail', 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', 'hint', $3)`,
|
||
[newUlid(), tenantA, actorB],
|
||
),
|
||
),
|
||
).rejects.toThrow();
|
||
|
||
// Biometric consents tenant isolation
|
||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||
await client.query(
|
||
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
||
VALUES ($1, $2, 'granted', 'doc-ref-a', $3)`,
|
||
[tenantA, employeeIdA, actorA],
|
||
);
|
||
});
|
||
|
||
const consentVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||
client.query('SELECT status FROM staff_biometric_consents WHERE employee_id = $1', [employeeIdA]),
|
||
);
|
||
expect(consentVisibleToB.rowCount).toBe(0);
|
||
|
||
await expect(
|
||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||
client.query(
|
||
`INSERT INTO staff_biometric_consents (tenant_id, employee_id, status, document_ref, updated_by)
|
||
VALUES ($1, $2, 'revoked', 'doc-ref-b', $3)`,
|
||
[tenantA, employeeIdA, actorB],
|
||
),
|
||
),
|
||
).rejects.toThrow();
|
||
});
|
||
});
|
||
|