Run the configured formatter across the repository before audit remediation so later security fixes produce reviewable, conflict-resistant diffs.
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
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;
|
|
|
|
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();
|
|
|
|
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 });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
|
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [
|
|
tenantA,
|
|
]);
|
|
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]);
|
|
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]);
|
|
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]);
|
|
await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]);
|
|
await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]);
|
|
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
|
|
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]);
|
|
await client.query('DELETE FROM staff_employees WHERE id = $1', [employeeIdA]);
|
|
});
|
|
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();
|
|
});
|
|
});
|