Предотвращение несанкционированного создания подтверждённых смен и начислений с устройств синхронизации. Команды upsert_work_session и upsert_timesheet_entry отклоняются сервером со статусом rejected и кодом COMMAND_NOT_SUPPORTED без записи в базу данных. Очистка интеграционного теста выполняется соединением мигратора, вставка в staff_employees согласована со схемой миграций.
829 lines
27 KiB
TypeScript
829 lines
27 KiB
TypeScript
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 } = 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}"`);
|
||
}
|
||
});
|
||
});
|