import crypto from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; import { newUlid, parseUlid } from '../../src/core/ids.js'; import type { Database } from '../../src/infrastructure/database.js'; import { decodeCursor, encodeCursor, InvalidCursorError } from '../../src/domain/sync/cursor.js'; import { SyncService } from '../../src/domain/sync/service.js'; import type { SyncCommand } from '../../src/domain/sync/types.js'; import { buildApp } from '../../src/app.js'; import { loadConfig } from '../../src/config.js'; function createMockDb(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) { const queries: Array<{ sql: string; params: unknown[] }> = []; const query = vi.fn(async (sql: string, params: unknown[] = []) => { queries.push({ sql, params }); if (queryHandler) { const res = queryHandler(sql, params); return { rows: res.rows, rowCount: res.rowCount ?? res.rows.length }; } return { rows: [], rowCount: 0 }; }); const database = { ping: vi.fn(async () => undefined), close: vi.fn(async () => undefined), tenantTransaction: vi.fn( async ( _tenantId: string, _actorId: string, run: (client: { query: typeof query }) => Promise, ) => run({ query }), ), } as unknown as Database; return { database, queries }; } describe('Sync Cursor', () => { const tenantA = parseUlid('01J0000000000000000000000A'); const tenantB = parseUlid('01J0000000000000000000000B'); it('encodes and decodes monotonic cursor correctly', () => { const cursor = encodeCursor(tenantA, 42n); expect(cursor).toMatch(/^cur_42_[0-9a-f]{12}$/); const decoded = decodeCursor(tenantA, cursor); expect(decoded).toBe(42n); }); it('rejects cursor with invalid format or tampered signature', () => { expect(() => decodeCursor(tenantA, 'invalid')).toThrowError(InvalidCursorError); expect(() => decodeCursor(tenantA, 'cur_42')).toThrowError(InvalidCursorError); expect(() => decodeCursor(tenantA, 'cur_abc_1234567890ab')).toThrowError(InvalidCursorError); // Tampered seq with same signature const valid = encodeCursor(tenantA, 42n); const tampered = valid.replace('cur_42_', 'cur_43_'); expect(() => decodeCursor(tenantA, tampered)).toThrowError(InvalidCursorError); }); it('enforces tenant isolation in cursor verification', () => { const cursorA = encodeCursor(tenantA, 100n); // Passing cursor of tenantA to tenantB must fail expect(() => decodeCursor(tenantB, cursorA)).toThrowError(InvalidCursorError); }); }); describe('Sync Device Authentication & Tenant Isolation', () => { const tenantA = parseUlid('01J0000000000000000000000A'); const tenantB = parseUlid('01J0000000000000000000000B'); const systemActorId = parseUlid('01J00000000000000000000001'); it('authenticates active device and updates last_seen_at', async () => { const deviceId = newUlid(); const rawSecret = crypto.randomBytes(32).toString('hex'); const rawToken = `kstaff_dev_${tenantA}_${rawSecret}`; const tokenHash = SyncService.hashToken(rawToken); const { database, queries } = createMockDb((sql) => { if (sql.includes('SELECT id, tenant_id')) { return { rows: [ { id: deviceId, tenant_id: tenantA, device_code: 'TERM-01', name: 'Terminal 1', token_hash: tokenHash, token_hint: 'kstaff_dev_01J...', status: 'active', allowed_scopes: ['attendance:sync'], org_unit_id: null, registered_at: new Date('2026-09-26T10:00:00Z'), last_seen_at: null, revoked_at: null, created_by: systemActorId, }, ], }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); const device = await service.authenticateDevice(rawToken, tenantA); expect(device.id).toBe(deviceId); expect(device.tenant_id).toBe(tenantA); expect(device.status).toBe('active'); expect( queries.some((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')), ).toBe(true); }); it('rejects revoked device with 403 DEVICE_REVOKED', async () => { const rawToken = `kstaff_dev_${tenantA}_${crypto.randomBytes(32).toString('hex')}`; const tokenHash = SyncService.hashToken(rawToken); const { database } = createMockDb((sql) => { if (sql.includes('SELECT id, tenant_id')) { return { rows: [ { id: newUlid(), tenant_id: tenantA, device_code: 'TERM-01', name: 'Terminal 1', token_hash: tokenHash, token_hint: 'hint', status: 'revoked', allowed_scopes: ['attendance:sync'], org_unit_id: null, registered_at: new Date(), last_seen_at: null, revoked_at: new Date(), created_by: systemActorId, }, ], }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); await expect(service.authenticateDevice(rawToken, tenantA)).rejects.toMatchObject({ name: 'SyncAuthError', code: 'DEVICE_REVOKED', statusCode: 403, message: 'Устройство отозвано', }); }); it('prevents reading or authenticating across different tenants (cross-tenant rejection)', async () => { const rawToken = `kstaff_dev_${tenantA}_${crypto.randomBytes(32).toString('hex')}`; const { database } = createMockDb(); const service = new SyncService(database, systemActorId); // Explicitly providing tenantB for a token configured for tenantA await expect(service.authenticateDevice(rawToken, tenantB)).rejects.toMatchObject({ code: 'TENANT_MISMATCH', statusCode: 403, }); }); it('rejects device without required sync scope', async () => { const rawToken = `kstaff_dev_${tenantA}_${crypto.randomBytes(32).toString('hex')}`; const { database } = createMockDb((sql) => { if (sql.includes('SELECT id, tenant_id')) { return { rows: [ { id: newUlid(), tenant_id: tenantA, device_code: 'TERM-VIEWER', name: 'Viewer', token_hash: SyncService.hashToken(rawToken), token_hint: 'hint', status: 'active', allowed_scopes: ['display:read_only'], org_unit_id: null, registered_at: new Date(), last_seen_at: null, revoked_at: null, created_by: systemActorId, }, ], }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({ code: 'SCOPE_INSUFFICIENT', statusCode: 403, }); }); }); describe('Sync Push & Idempotency', () => { const tenantId = parseUlid('01J0000000000000000000000A'); const systemActorId = parseUlid('01J00000000000000000000001'); const deviceId = parseUlid('01J0000000000000000000000D'); const employeeId = parseUlid('01J0000000000000000000000E'); const testDevice = { id: deviceId, tenant_id: tenantId, device_code: 'TERM-01', name: 'Terminal 1', token_hash: 'hash', token_hint: 'hint', 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: systemActorId, }; it('processes attendance_event and inserts into staff_sync_command_inbox', async () => { const { database, queries } = createMockDb((sql) => { if (sql.includes('SELECT status, result, conflict')) { return { rows: [] }; // Not yet in inbox } if (sql.includes('SELECT id FROM staff_employees')) { return { rows: [{ id: employeeId }] }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); const command: SyncCommand = { command_type: 'attendance_event', schema_version: 1, idempotency_key: 'cmd-test-1', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { employee_id: employeeId, event_type: 'check_in', occurred_at: new Date().toISOString(), source_event_id: 'scanner-evt-001', }, }; const results = await service.push(tenantId, testDevice, [command]); expect(results).toHaveLength(1); expect(results[0]?.status).toBe('applied'); expect(results[0]?.result).toMatchObject({ status: 'accepted' }); expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(true); expect(queries.some((q) => q.sql.includes('INSERT INTO staff_sync_command_inbox'))).toBe(true); expect(queries.some((q) => q.sql.includes('INSERT INTO staff_sync_change_log'))).toBe(true); }); it('returns cached result on repeated idempotency_key without re-executing', async () => { const cachedResult = { event_id: newUlid(), status: 'accepted' }; const { database, queries } = createMockDb((sql) => { if (sql.includes('SELECT status, result, conflict')) { // Return already processed command return { rows: [ { status: 'applied', result: cachedResult, conflict: null, error_code: null, error_message: null, }, ], }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); const command: SyncCommand = { command_type: 'attendance_event', schema_version: 1, idempotency_key: 'cmd-duplicate', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { employee_id: employeeId, event_type: 'check_in', occurred_at: new Date().toISOString(), source_event_id: 'scanner-evt-001', }, }; const results = await service.push(tenantId, testDevice, [command]); expect(results).toHaveLength(1); expect(results[0]?.status).toBe('applied'); expect(results[0]?.result).toEqual(cachedResult); // Did NOT execute insert into staff_attendance_events again! expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(false); }); it('returns structured conflict on revision mismatch for work_session', async () => { const sessionId = newUlid(); const { database } = createMockDb((sql) => { if (sql.includes('SELECT status, result, conflict')) { return { rows: [] }; // Not in inbox } if (sql.includes('SELECT id, revision, status FROM staff_work_sessions')) { // Server has revision 3 return { rows: [{ id: sessionId, revision: 3, status: 'confirmed' }] }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); const command: SyncCommand = { command_type: 'upsert_work_session', schema_version: 1, idempotency_key: 'cmd-session-conflict', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { id: sessionId, employee_id: employeeId, org_unit_id: newUlid(), business_date: '2026-09-26', revision: 2, // Client expects revision 2, but server has 3 worked_minutes: 480, }, }; const results = await service.push(tenantId, testDevice, [command]); expect(results).toHaveLength(1); expect(results[0]?.status).toBe('conflict'); expect(results[0]?.conflict).toEqual({ conflict_code: 'REVISION_MISMATCH', entity_type: 'work_session', entity_id: sessionId, expected_revision: 2, actual_revision: 3, safe_description: 'Ревизия рабочей смены на сервере изменилась', client_action: 'FETCH_LATEST_AND_RETRY', }); }); it('returns structured conflict on revision mismatch for timesheet_entry', async () => { const entryId = newUlid(); const { database } = createMockDb((sql) => { if (sql.includes('SELECT status, result, conflict')) { return { rows: [] }; } if (sql.includes('SELECT id, revision, status FROM staff_timesheet_entries')) { return { rows: [{ id: entryId, revision: 4, status: 'approved' }] }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); const command: SyncCommand = { command_type: 'upsert_timesheet_entry', schema_version: 1, idempotency_key: 'cmd-ts-conflict', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: { id: entryId, employee_id: employeeId, work_date: '2026-09-26', minutes_worked: 500, revision: 1, // Mismatch: expects 1, server has 4 }, }; const results = await service.push(tenantId, testDevice, [command]); expect(results).toHaveLength(1); expect(results[0]?.status).toBe('conflict'); expect(results[0]?.conflict).toEqual({ conflict_code: 'REVISION_MISMATCH', entity_type: 'timesheet_entry', entity_id: entryId, expected_revision: 1, actual_revision: 4, safe_description: 'Ревизия записи табеля на сервере изменилась', client_action: 'FETCH_LATEST_AND_RETRY', }); }); it('enforces batch size limits and payload size limits', async () => { const { database } = createMockDb(); const service = new SyncService(database, systemActorId); // Empty batch await expect(service.push(tenantId, testDevice, [])).rejects.toMatchObject({ code: 'BATCH_EMPTY', statusCode: 400, }); // Batch > 100 const largeBatch: SyncCommand[] = Array.from({ length: 101 }, (_, i) => ({ command_type: 'attendance_event', schema_version: 1, idempotency_key: `key-${i}`, correlation_id: newUlid(), created_at: new Date().toISOString(), payload: {}, })); await expect(service.push(tenantId, testDevice, largeBatch)).rejects.toMatchObject({ code: 'BATCH_SIZE_EXCEEDED', statusCode: 400, }); // Single payload > 64KB const giantPayload = { data: 'x'.repeat(70000) }; const commandWithGiantPayload: SyncCommand = { command_type: 'attendance_event', schema_version: 1, idempotency_key: 'giant-payload', correlation_id: newUlid(), created_at: new Date().toISOString(), payload: giantPayload, }; const results = await service.push(tenantId, testDevice, [commandWithGiantPayload]); expect(results[0]?.status).toBe('rejected'); expect(results[0]?.error_code).toBe('PAYLOAD_TOO_LARGE'); }); it('rejects commands with timestamps outside the replay window', async () => { const { database } = createMockDb(); const service = new SyncService(database, systemActorId); // 40 days old (> 30 days) const oldDate = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(); const oldCmd: SyncCommand = { command_type: 'attendance_event', schema_version: 1, idempotency_key: 'cmd-old', correlation_id: newUlid(), created_at: oldDate, payload: {}, }; // 10 minutes in future (> 5 minutes) const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString(); const futureCmd: SyncCommand = { command_type: 'attendance_event', schema_version: 1, idempotency_key: 'cmd-future', correlation_id: newUlid(), created_at: futureDate, payload: {}, }; const results = await service.push(tenantId, testDevice, [oldCmd, futureCmd]); expect(results[0]?.status).toBe('rejected'); expect(results[0]?.error_code).toBe('TIMESTAMP_OUT_OF_WINDOW'); expect(results[1]?.status).toBe('rejected'); expect(results[1]?.error_code).toBe('TIMESTAMP_OUT_OF_WINDOW'); }); }); describe('Sync Pull & Bootstrap Data Safety', () => { const tenantId = parseUlid('01J0000000000000000000000A'); const systemActorId = parseUlid('01J00000000000000000000001'); const deviceId = parseUlid('01J0000000000000000000000D'); it('bootstrap (cursor = null) returns safe entities without salary, PII, or biometrics', async () => { const empId = newUlid(); const orgId = newUlid(); const profId = newUlid(); const { database } = createMockDb((sql) => { if (sql.includes('MAX(cursor_seq)')) { return { rows: [{ max_seq: '50' }] }; } if (sql.includes('FROM staff_org_units')) { return { rows: [{ id: orgId, code: 'KITCHEN', name: 'Кухня', status: 'active' }] }; } if (sql.includes('FROM staff_professions')) { return { rows: [{ id: profId, code: 'CHEF', name: 'Шеф', status: 'active' }] }; } if (sql.includes('FROM staff_employees')) { return { rows: [ { id: empId, personnel_number: 'EMP-01', display_name: 'Петров Петр', status: 'active', profession_id: profId, position: 'Шеф-повар', }, ], }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); const result = await service.pull(tenantId, deviceId, { cursor: null }); expect(result.changes).toHaveLength(3); expect(result.next_cursor).toBe(encodeCursor(tenantId, 50n)); expect(result.has_more).toBe(false); // Verify employee payload has NO sensitive fields const empChange = result.changes.find((c) => c.entity_type === 'employee'); expect(empChange).toBeDefined(); const payload = empChange!.payload; expect(payload).toHaveProperty('personnel_number'); expect(payload).toHaveProperty('display_name'); expect(payload).not.toHaveProperty('salary'); expect(payload).not.toHaveProperty('hourly_rate'); expect(payload).not.toHaveProperty('passport'); expect(payload).not.toHaveProperty('phone'); expect(payload).not.toHaveProperty('biometrics'); expect(payload).not.toHaveProperty('template'); }); it('incremental pull returns changes strictly after cursor', async () => { const cursor = encodeCursor(tenantId, 10n); const { database } = createMockDb((sql) => { if (sql.includes('FROM staff_sync_change_log')) { return { rows: [ { id: newUlid(), cursor_seq: '11', entity_type: 'employee', entity_id: newUlid(), operation: 'upsert', revision: 2, payload: { id: 'test', display_name: 'Тест' }, created_at: new Date('2026-09-26T12:00:00Z'), }, ], }; } return { rows: [] }; }); const service = new SyncService(database, systemActorId); const result = await service.pull(tenantId, deviceId, { cursor }); expect(result.changes).toHaveLength(1); expect(result.changes[0]?.cursor).toBe(encodeCursor(tenantId, 11n)); expect(result.next_cursor).toBe(encodeCursor(tenantId, 11n)); expect(result.has_more).toBe(false); }); }); describe('Sync API HTTP Endpoints', () => { const tenantId = '01J00000000000000000000000'; const managerToken = 'a'.repeat(64); const config = loadConfig({ DATABASE_URL: 'postgres://localhost/staff', STANDALONE_TENANT_ID: tenantId, STANDALONE_ACTOR_ID: '01J00000000000000000000001', STANDALONE_MANAGER_TOKEN: managerToken, STANDALONE_TERMINAL_TOKEN: 'b'.repeat(64), LOG_LEVEL: 'silent', NODE_ENV: 'test', }); it('registers device with manager authorization', async () => { const deviceId = newUlid(); const { database } = createMockDb((sql) => { if (sql.includes('INSERT INTO staff_sync_devices')) { return { rows: [ { id: deviceId, tenant_id: tenantId, device_code: 'TERM-API-01', name: 'Касса 1', token_hint: 'kstaff_dev_...', status: 'active', allowed_scopes: ['attendance:sync'], org_unit_id: null, registered_at: new Date(), created_by: config.STANDALONE_ACTOR_ID, }, ], }; } return { rows: [] }; }); const app = await buildApp({ config, database }); await app.ready(); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/sync/devices', headers: { authorization: `Bearer ${managerToken}`, }, payload: { device_code: 'TERM-API-01', name: 'Касса 1', }, }); expect(response.statusCode).toBe(201); const body = response.json<{ device: { device_code: string }; token: string }>(); expect(body.device.device_code).toBe('TERM-API-01'); expect(body.token).toMatch(/^kstaff_dev_/); await app.close(); }); it('rejects unauthenticated requests to device sync endpoints', async () => { const { database } = createMockDb(); const app = await buildApp({ config, database }); await app.ready(); const response = await app.inject({ method: 'GET', url: '/api/v1/staff/sync/status', }); expect(response.statusCode).toBe(401); await app.close(); }); it('handles checkpoint acknowledgment', async () => { const rawToken = `kstaff_dev_${tenantId}_${crypto.randomBytes(32).toString('hex')}`; const deviceId = newUlid(); const cursor = encodeCursor(tenantId, 55n); const { database } = createMockDb((sql) => { if (sql.includes('FROM staff_sync_devices')) { return { rows: [ { id: deviceId, tenant_id: tenantId, device_code: 'TERM-01', name: 'Term', token_hash: SyncService.hashToken(rawToken), token_hint: 'hint', status: 'active', allowed_scopes: ['attendance:sync'], org_unit_id: null, registered_at: new Date(), last_seen_at: null, revoked_at: null, created_by: config.STANDALONE_ACTOR_ID, }, ], }; } if (sql.includes('INSERT INTO staff_sync_device_checkpoints')) { return { rows: [{ acknowledged_at: new Date('2026-09-26T12:00:00Z') }] }; } return { rows: [] }; }); const app = await buildApp({ config, database }); await app.ready(); const response = await app.inject({ method: 'POST', url: '/api/v1/staff/sync/ack', headers: { authorization: `Bearer ${rawToken}`, }, payload: { cursor, }, }); expect(response.statusCode).toBe(200); const body = response.json<{ acknowledged: boolean; cursor: string }>(); expect(body.acknowledged).toBe(true); expect(body.cursor).toBe(cursor); await app.close(); }); }); describe('Security & Privacy Audit (No secrets, no PII, no biometrics in logs or state)', () => { const tenantId = parseUlid('01J0000000000000000000000A'); const actorId = parseUlid('01J00000000000000000000001'); it('guarantees raw device token is never stored in the database, only SHA-256 hash', async () => { const { database, queries } = createMockDb((sql) => { if (sql.includes('INSERT INTO staff_sync_devices')) { return { rows: [ { id: newUlid(), tenant_id: tenantId, device_code: 'DEV-SEC', name: 'Secure Terminal', token_hint: 'kstaff_dev_...', status: 'active', allowed_scopes: ['attendance:sync'], org_unit_id: null, registered_at: new Date(), created_by: actorId, }, ], }; } return { rows: [] }; }); const service = new SyncService(database, actorId); const regResult = await service.registerDevice(tenantId, actorId, { device_code: 'DEV-SEC', name: 'Secure Terminal', }); const rawToken = regResult.token; expect(rawToken).toMatch(/^kstaff_dev_/); // Verify SQL query parameters const insertQuery = queries.find((q) => q.sql.includes('INSERT INTO staff_sync_devices')); expect(insertQuery).toBeDefined(); // Raw token MUST NOT be present in parameters const paramsString = JSON.stringify(insertQuery!.params); expect(paramsString).not.toContain(rawToken); // Only the hash should be stored const expectedHash = SyncService.hashToken(rawToken); expect(paramsString).toContain(expectedHash); }); it('sanitizes and excludes sensitive employee attributes from sync payloads', async () => { const { database } = createMockDb((sql) => { if (sql.includes('MAX(cursor_seq)')) { return { rows: [{ max_seq: '1' }] }; } if (sql.includes('FROM staff_employees')) { return { rows: [ { id: newUlid(), personnel_number: 'EMP-99', display_name: 'Секретный Сотрудник', status: 'active', profession_id: null, position: 'Аналитик', }, ], }; } return { rows: [] }; }); const service = new SyncService(database, actorId); const result = await service.pull(tenantId, newUlid(), { cursor: null }); const serialized = JSON.stringify(result); // Explicit forbidden terms check const forbiddenTerms = [ 'salary', 'wage', 'rate', 'passport', 'snils', 'inn', 'bank_account', 'card_number', 'phone', 'email', 'biometrics', 'fingerprint', 'face_descriptor', ]; for (const term of forbiddenTerms) { expect(serialized.toLowerCase()).not.toContain(`"${term}"`); } }); });