Предотвращение несанкционированного создания подтверждённых смен и начислений с устройств синхронизации. Команды upsert_work_session и upsert_timesheet_entry отклоняются сервером со статусом rejected и кодом COMMAND_NOT_SUPPORTED без записи в базу данных. Очистка интеграционного теста выполняется соединением мигратора, вставка в staff_employees согласована со схемой миграций.
208 lines
7.3 KiB
TypeScript
208 lines
7.3 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';
|
|
import { SyncService } from '../../src/domain/sync/service.js';
|
|
import type { SyncCommand } from '../../src/domain/sync/types.js';
|
|
|
|
const connectionString = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_TEST_URL;
|
|
const migrationConnectionString =
|
|
process.env.DATABASE_TEST_MIGRATION_URL ?? process.env.DATABASE_MIGRATION_URL;
|
|
const describeDatabase = connectionString ? describe : describe.skip;
|
|
|
|
const cleanupTables = [
|
|
'staff_sync_command_inbox',
|
|
'staff_sync_change_log',
|
|
'staff_sync_devices',
|
|
'staff_work_sessions',
|
|
'staff_timesheet_entries',
|
|
'staff_employees',
|
|
] as const;
|
|
|
|
describeDatabase('Device Sync Command Rejection (Live PostgreSQL)', () => {
|
|
const database = new Database(connectionString!);
|
|
const tenantId = parseUlid('01J0000000000000000000000A');
|
|
const actorId = parseUlid('01J00000000000000000000001');
|
|
const deviceId = newUlid();
|
|
const employeeId = newUlid();
|
|
const sessionId = newUlid();
|
|
const timesheetId = newUlid();
|
|
const rawToken = `kstaff_dev_${tenantId}_test_rejection_token_secret`;
|
|
const tokenHash = SyncService.hashToken(rawToken);
|
|
|
|
const syncService = new SyncService(database, actorId);
|
|
|
|
const testDevice = {
|
|
id: deviceId,
|
|
tenant_id: tenantId,
|
|
device_code: 'TERM-REJECT-TEST',
|
|
name: 'Rejection Test Terminal',
|
|
token_hash: tokenHash,
|
|
token_hint: 'kstaff_dev_...',
|
|
status: 'active' as const,
|
|
allowed_scopes: ['attendance:sync'],
|
|
org_unit_id: null,
|
|
registered_at: new Date().toISOString(),
|
|
last_seen_at: null,
|
|
revoked_at: null,
|
|
created_by: actorId,
|
|
};
|
|
|
|
async function cleanupAsMigrator() {
|
|
if (!migrationConnectionString) {
|
|
return;
|
|
}
|
|
const migrator = new pg.Client({
|
|
connectionString: migrationConnectionString,
|
|
application_name: 'krona-staff-test-cleanup',
|
|
});
|
|
await migrator.connect();
|
|
try {
|
|
await migrator.query('BEGIN');
|
|
try {
|
|
await migrator.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
|
|
for (const table of cleanupTables) {
|
|
await migrator.query(`DELETE FROM ${table} WHERE tenant_id = $1`, [tenantId]);
|
|
}
|
|
await migrator.query('COMMIT');
|
|
} catch (error) {
|
|
await migrator.query('ROLLBACK');
|
|
throw error;
|
|
}
|
|
} finally {
|
|
await migrator.end();
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await cleanupAsMigrator();
|
|
await database.tenantTransaction(tenantId, actorId, async (client) => {
|
|
// Create employee
|
|
await client.query(
|
|
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
|
|
VALUES ($1, $2, 'EMP-REJ-01', 'Тестовый Сотрудник', 'active')`,
|
|
[employeeId, tenantId],
|
|
);
|
|
|
|
// Create sync device
|
|
await client.query(
|
|
`INSERT INTO staff_sync_devices (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
[
|
|
deviceId,
|
|
tenantId,
|
|
testDevice.device_code,
|
|
testDevice.name,
|
|
tokenHash,
|
|
testDevice.token_hint,
|
|
'active',
|
|
['attendance:sync'],
|
|
actorId,
|
|
],
|
|
);
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await cleanupAsMigrator();
|
|
} finally {
|
|
await database.close();
|
|
}
|
|
});
|
|
|
|
it('rejects upsert_work_session and creates NO records in staff_work_sessions or change log', async () => {
|
|
// S1 scenario: device attempts to create a confirmed work session with 2880 minutes
|
|
const command: SyncCommand = {
|
|
command_type: 'upsert_work_session',
|
|
schema_version: 1,
|
|
idempotency_key: 'cmd-live-s1-work-session',
|
|
correlation_id: newUlid(),
|
|
created_at: new Date().toISOString(),
|
|
payload: {
|
|
id: sessionId,
|
|
employee_id: employeeId,
|
|
business_date: '2026-09-26',
|
|
status: 'confirmed',
|
|
worked_minutes: 2880,
|
|
revision: 1,
|
|
},
|
|
};
|
|
|
|
const results = await syncService.push(tenantId, testDevice, [command]);
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]?.status).toBe('rejected');
|
|
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
|
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
|
|
|
// Verify 0 rows in staff_work_sessions
|
|
const sessionRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
client.query<{ cnt: number }>(
|
|
'SELECT count(*)::int as cnt FROM staff_work_sessions WHERE tenant_id = $1',
|
|
[tenantId],
|
|
),
|
|
);
|
|
expect(sessionRes.rows[0]?.cnt).toBe(0);
|
|
|
|
// Verify 0 rows in staff_sync_change_log for work_session
|
|
const changeRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
client.query<{ cnt: number }>(
|
|
"SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'work_session'",
|
|
[tenantId],
|
|
),
|
|
);
|
|
expect(changeRes.rows[0]?.cnt).toBe(0);
|
|
|
|
// Verify recorded in staff_sync_command_inbox as rejected
|
|
const inboxRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
client.query<{ status: string; error_code: string }>(
|
|
'SELECT status, error_code FROM staff_sync_command_inbox WHERE tenant_id = $1 AND idempotency_key = $2',
|
|
[tenantId, command.idempotency_key],
|
|
),
|
|
);
|
|
expect(inboxRes.rows[0]?.status).toBe('rejected');
|
|
expect(inboxRes.rows[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
|
});
|
|
|
|
it('rejects upsert_timesheet_entry and creates NO records in staff_timesheet_entries or change log', async () => {
|
|
const command: SyncCommand = {
|
|
command_type: 'upsert_timesheet_entry',
|
|
schema_version: 1,
|
|
idempotency_key: 'cmd-live-timesheet-entry',
|
|
correlation_id: newUlid(),
|
|
created_at: new Date().toISOString(),
|
|
payload: {
|
|
id: timesheetId,
|
|
employee_id: employeeId,
|
|
work_date: '2026-09-26',
|
|
minutes_worked: 480,
|
|
status: 'approved',
|
|
revision: 1,
|
|
},
|
|
};
|
|
|
|
const results = await syncService.push(tenantId, testDevice, [command]);
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]?.status).toBe('rejected');
|
|
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
|
|
expect(results[0]?.error_message).toContain('Команда не поддерживается');
|
|
|
|
// Verify 0 rows in staff_timesheet_entries
|
|
const tsRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
client.query<{ cnt: number }>(
|
|
'SELECT count(*)::int as cnt FROM staff_timesheet_entries WHERE tenant_id = $1',
|
|
[tenantId],
|
|
),
|
|
);
|
|
expect(tsRes.rows[0]?.cnt).toBe(0);
|
|
|
|
// Verify 0 rows in staff_sync_change_log for timesheet_entry
|
|
const changeRes = await database.tenantTransaction(tenantId, actorId, (client) =>
|
|
client.query<{ cnt: number }>(
|
|
"SELECT count(*)::int as cnt FROM staff_sync_change_log WHERE tenant_id = $1 AND entity_type = 'timesheet_entry'",
|
|
[tenantId],
|
|
),
|
|
);
|
|
expect(changeRes.rows[0]?.cnt).toBe(0);
|
|
});
|
|
});
|