krona-staff/tests/unit/sync.test.ts
Ochenstarik ef9e00ce80 fix(sync): compute org unit status dynamically in bootstrap pull
Таблица staff_org_units не имеет отдельной колонки status, активность определяется полями is_active и deleted_at. Прямой запрос status при начальной загрузке без курсора приводил к 500 ошибке (дефект SYNC-C0). Статус вычисляется через CASE-выражение. Очистка интеграционного теста выполняется ролью мигратора.
2026-09-26 16:12:20 +07:00

896 lines
30 KiB
TypeScript
Raw Permalink 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 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<unknown>,
) => 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('rejects upsert_work_session command with COMMAND_NOT_SUPPORTED', async () => {
const sessionId = newUlid();
const { database } = createMockDb();
const service = new SyncService(database, systemActorId);
const command: SyncCommand = {
command_type: 'upsert_work_session',
schema_version: 1,
idempotency_key: 'cmd-session-reject',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: sessionId,
employee_id: employeeId,
org_unit_id: newUlid(),
business_date: '2026-09-26',
revision: 1,
worked_minutes: 480,
status: 'confirmed',
},
};
const results = await service.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('Команда не поддерживается');
});
it('rejects upsert_timesheet_entry command with COMMAND_NOT_SUPPORTED', async () => {
const entryId = newUlid();
const { database } = createMockDb();
const service = new SyncService(database, systemActorId);
const command: SyncCommand = {
command_type: 'upsert_timesheet_entry',
schema_version: 1,
idempotency_key: 'cmd-ts-reject',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: entryId,
employee_id: employeeId,
work_date: '2026-09-26',
minutes_worked: 500,
revision: 1,
},
};
const results = await service.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('Команда не поддерживается');
});
it('does not write to staff_work_sessions, staff_timesheet_entries, or staff_sync_change_log on rejected shift commands', async () => {
const { database, queries } = createMockDb();
const service = new SyncService(database, systemActorId);
const commands: SyncCommand[] = [
{
command_type: 'upsert_work_session',
schema_version: 1,
idempotency_key: 'cmd-session-no-write',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: newUlid(),
employee_id: employeeId,
org_unit_id: newUlid(),
business_date: '2026-09-26',
status: 'confirmed',
worked_minutes: 2880,
},
},
{
command_type: 'upsert_timesheet_entry',
schema_version: 1,
idempotency_key: 'cmd-ts-no-write',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: newUlid(),
employee_id: employeeId,
work_date: '2026-09-26',
minutes_worked: 500,
},
},
];
const results = await service.push(tenantId, testDevice, commands);
expect(results).toHaveLength(2);
expect(results.every((r) => r.status === 'rejected')).toBe(true);
// Verify nothing is written to staff_work_sessions
expect(
queries.some(
(q) =>
q.sql.toLowerCase().includes('staff_work_sessions') &&
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
),
).toBe(false);
// Verify nothing is written to staff_timesheet_entries
expect(
queries.some(
(q) =>
q.sql.toLowerCase().includes('staff_timesheet_entries') &&
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
),
).toBe(false);
// Verify nothing is written to staff_sync_change_log
expect(
queries.some(
(q) =>
q.sql.toLowerCase().includes('staff_sync_change_log') &&
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
),
).toBe(false);
// Verify commands ARE saved to inbox with status 'rejected'
const inboxInserts = queries.filter((q) =>
q.sql.includes('INSERT INTO staff_sync_command_inbox'),
);
expect(inboxInserts).toHaveLength(2);
for (const insert of inboxInserts) {
expect(insert.params).toContain('rejected');
expect(insert.params).toContain('COMMAND_NOT_SUPPORTED');
}
});
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, queries } = 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 staff_org_units query uses CASE expression for status
const orgQuery = queries.find((q) => q.sql.includes('FROM staff_org_units'));
expect(orgQuery).toBeDefined();
expect(orgQuery!.sql).toMatch(
/CASE[\s\S]+WHEN deleted_at IS NOT NULL THEN 'archived'[\s\S]+WHEN NOT is_active THEN 'inactive'[\s\S]+ELSE 'active'[\s\S]+END AS status/,
);
// Verify SafeOrgUnitPayload
const orgChange = result.changes.find((c) => c.entity_type === 'org_unit');
expect(orgChange).toBeDefined();
expect(orgChange!.payload).toEqual({
id: orgId,
code: 'KITCHEN',
name: 'Кухня',
status: 'active',
});
// 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('bootstrap sync selects staff_org_units status via CASE expression and forms SafeOrgUnitPayload', async () => {
const orgActiveId = newUlid();
const orgInactiveId = newUlid();
const orgArchivedId = newUlid();
const { database, queries } = createMockDb((sql) => {
if (sql.includes('MAX(cursor_seq)')) {
return { rows: [{ max_seq: '10' }] };
}
if (sql.includes('FROM staff_org_units')) {
return {
rows: [
{ id: orgActiveId, code: 'point_active', name: 'Точка Активная', status: 'active' },
{
id: orgInactiveId,
code: 'point_inactive',
name: 'Точка Неактивная',
status: 'inactive',
},
{
id: orgArchivedId,
code: 'point_archived',
name: 'Точка В Архиве',
status: 'archived',
},
],
};
}
return { rows: [] };
});
const service = new SyncService(database, systemActorId);
const result = await service.pull(tenantId, { cursor: null, entity_types: ['org_unit'] });
const orgQuery = queries.find((q) => q.sql.includes('FROM staff_org_units'));
expect(orgQuery).toBeDefined();
expect(orgQuery!.sql).toContain('CASE');
expect(orgQuery!.sql).toContain("WHEN deleted_at IS NOT NULL THEN 'archived'");
expect(orgQuery!.sql).toContain("WHEN NOT is_active THEN 'inactive'");
expect(orgQuery!.sql).toContain("ELSE 'active'");
expect(orgQuery!.sql).toContain('END AS status');
expect(result.changes).toHaveLength(3);
expect(result.changes.map((c) => c.payload)).toEqual([
{ id: orgActiveId, code: 'point_active', name: 'Точка Активная', status: 'active' },
{ id: orgInactiveId, code: 'point_inactive', name: 'Точка Неактивная', status: 'inactive' },
{ id: orgArchivedId, code: 'point_archived', name: 'Точка В Архиве', status: 'archived' },
]);
});
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}"`);
}
});
});