Compare commits

..

2 commits

Author SHA1 Message Date
Ochenstarik
8e955b042b fix(database): make live PostgreSQL workflows valid
Route every transactional outbox write through one complete event-envelope helper so occurred_at is always populated. Separate employee position and profession lookup parameters to avoid PostgreSQL 42P08, and add unit plus live-database regression coverage.
2026-09-26 15:40:59 +07:00
Ochenstarik
513cdff702 style: establish repository formatting baseline
Run the configured formatter across the repository before audit remediation so later security fixes produce reviewable, conflict-resistant diffs.
2026-09-26 15:36:20 +07:00
5 changed files with 237 additions and 293 deletions

View file

@ -44,7 +44,9 @@ export function decodeCursor(tenantId: string, cursor: string): bigint {
candidateBuffer.length !== expectedBuffer.length || candidateBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(candidateBuffer, expectedBuffer) !crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
) { ) {
throw new InvalidCursorError('Подпись курсора синхронизации недействительна для данного арендатора'); throw new InvalidCursorError(
'Подпись курсора синхронизации недействительна для данного арендатора',
);
} }
return BigInt(seqStr); return BigInt(seqStr);

View file

@ -90,7 +90,11 @@ export class SyncService {
const tokenTenant = SyncService.parseTenantFromToken(rawToken); const tokenTenant = SyncService.parseTenantFromToken(rawToken);
if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) { if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) {
throw new SyncAuthError('Несоответствие арендатора в токене устройства', 'TENANT_MISMATCH', 403); throw new SyncAuthError(
'Несоответствие арендатора в токене устройства',
'TENANT_MISMATCH',
403,
);
} }
const tenantId = providedTenantId ?? tokenTenant; const tenantId = providedTenantId ?? tokenTenant;
@ -187,7 +191,8 @@ export class SyncService {
const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`; const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`;
const tokenHash = SyncService.hashToken(rawToken); const tokenHash = SyncService.hashToken(rawToken);
const tokenHint = rawToken.slice(0, 16); const tokenHint = rawToken.slice(0, 16);
const scopes = params.allowed_scopes && params.allowed_scopes.length > 0 const scopes =
params.allowed_scopes && params.allowed_scopes.length > 0
? params.allowed_scopes ? params.allowed_scopes
: ['attendance:sync']; : ['attendance:sync'];
@ -210,7 +215,17 @@ export class SyncService {
(id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by) (id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
RETURNING id, tenant_id, device_code, name, token_hint, status, allowed_scopes, org_unit_id, registered_at, created_by`, RETURNING id, tenant_id, device_code, name, token_hint, status, allowed_scopes, org_unit_id, registered_at, created_by`,
[deviceId, tenantId, params.device_code.trim(), params.name.trim(), tokenHash, tokenHint, scopes, orgUnitId, actorId], [
deviceId,
tenantId,
params.device_code.trim(),
params.name.trim(),
tokenHash,
tokenHint,
scopes,
orgUnitId,
actorId,
],
); );
const row = res.rows[0]!; const row = res.rows[0]!;
@ -326,7 +341,8 @@ export class SyncService {
// 1. If cursor is provided -> incremental pull from staff_sync_change_log // 1. If cursor is provided -> incremental pull from staff_sync_change_log
if (options.cursor) { if (options.cursor) {
const seq = decodeCursor(tenantId, options.cursor); const seq = decodeCursor(tenantId, options.cursor);
const filterTypes = options.entity_types && options.entity_types.length > 0 ? options.entity_types : null; const filterTypes =
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
const res = await client.query<{ const res = await client.query<{
id: string; id: string;
@ -360,9 +376,8 @@ export class SyncService {
created_at: r.created_at.toISOString(), created_at: r.created_at.toISOString(),
})); }));
const nextCursor = changes.length > 0 const nextCursor =
? changes[changes.length - 1]!.cursor changes.length > 0 ? changes[changes.length - 1]!.cursor : options.cursor;
: options.cursor;
return { return {
changes, changes,
@ -380,16 +395,22 @@ export class SyncService {
const latestCursor = encodeCursor(tenantId, maxSeq); const latestCursor = encodeCursor(tenantId, maxSeq);
const wanted = (type: string) => const wanted = (type: string) =>
!options.entity_types || options.entity_types.length === 0 || options.entity_types.includes(type); !options.entity_types ||
options.entity_types.length === 0 ||
options.entity_types.includes(type);
const changes: SyncChange[] = []; const changes: SyncChange[] = [];
// Org Units // Org Units
if (wanted('org_unit')) { if (wanted('org_unit')) {
const orgRes = await client.query<{ id: string; code: string; name: string; status: string }>( const orgRes = await client.query<{
`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, id: string;
[tenantId], code: string;
); name: string;
status: string;
}>(`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, [
tenantId,
]);
for (const row of orgRes.rows) { for (const row of orgRes.rows) {
const payload: SafeOrgUnitPayload = { const payload: SafeOrgUnitPayload = {
id: row.id, id: row.id,
@ -411,7 +432,12 @@ export class SyncService {
// Professions // Professions
if (wanted('profession')) { if (wanted('profession')) {
const profRes = await client.query<{ id: string; code: string; name: string; status: string }>( const profRes = await client.query<{
id: string;
code: string;
name: string;
status: string;
}>(
`SELECT id, code, name, status FROM staff_professions WHERE tenant_id = $1 ORDER BY id`, `SELECT id, code, name, status FROM staff_professions WHERE tenant_id = $1 ORDER BY id`,
[tenantId], [tenantId],
); );
@ -662,7 +688,8 @@ export class SyncService {
command_type: cmd.command_type, command_type: cmd.command_type,
status: 'rejected', status: 'rejected',
error_code: 'TIMESTAMP_OUT_OF_WINDOW', error_code: 'TIMESTAMP_OUT_OF_WINDOW',
error_message: 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)', error_message:
'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
}; };
await this.saveInboxCommand(client, tenantId, device.id, cmd, rejectedResult); await this.saveInboxCommand(client, tenantId, device.id, cmd, rejectedResult);
results.push(rejectedResult); results.push(rejectedResult);
@ -757,7 +784,17 @@ export class SyncService {
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id) (id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id)
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`, ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
[eventId, tenantId, employeeId, eventType, sourceEventId, occurredAt, device.id, device.id, correlationId], [
eventId,
tenantId,
employeeId,
eventType,
sourceEventId,
occurredAt,
device.id,
device.id,
correlationId,
],
); );
const safePayload: SafeAttendanceEventPayload = { const safePayload: SafeAttendanceEventPayload = {
@ -771,7 +808,15 @@ export class SyncService {
status: 'accepted', status: 'accepted',
}; };
await this.recordChange(client, tenantId, 'attendance_event', eventId, 'upsert', 1, safePayload); await this.recordChange(
client,
tenantId,
'attendance_event',
eventId,
'upsert',
1,
safePayload,
);
return { return {
idempotency_key: cmd.idempotency_key, idempotency_key: cmd.idempotency_key,
@ -781,207 +826,15 @@ export class SyncService {
}; };
} }
case 'upsert_work_session': { case 'upsert_work_session':
const sessionId = (payload.id as string) || newUlid();
const employeeId = payload.employee_id as string;
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
const businessDate = payload.business_date as string;
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
const startedAt = payload.started_at as string | null | undefined;
const endedAt = payload.ended_at as string | null | undefined;
const workedMinutes = typeof payload.worked_minutes === 'number' ? payload.worked_minutes : 0;
const sessionStatus = (payload.status as string) || 'open';
if (!employeeId || !orgUnitId || !businessDate) {
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'rejected',
error_code: 'INVALID_WORK_SESSION_PAYLOAD',
error_message: 'employee_id, org_unit_id и business_date обязательны',
};
}
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
`SELECT id, revision, status FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2`,
[tenantId, sessionId],
);
if (existingRes.rows.length > 0) {
const existing = existingRes.rows[0]!;
if (existing.revision !== clientRevision) {
const conflict: SyncConflict = {
conflict_code: 'REVISION_MISMATCH',
entity_type: 'work_session',
entity_id: sessionId,
expected_revision: clientRevision,
actual_revision: existing.revision,
safe_description: 'Ревизия рабочей смены на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
};
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'conflict',
conflict,
};
}
const nextRev = existing.revision + 1;
await client.query(
`UPDATE staff_work_sessions
SET started_at = $1, ended_at = $2, worked_minutes = $3, status = $4, revision = $5, updated_at = now()
WHERE tenant_id = $6 AND id = $7`,
[startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, nextRev, tenantId, sessionId],
);
const safePayload: SafeWorkSessionPayload = {
id: sessionId,
employee_id: employeeId,
org_unit_id: orgUnitId,
business_date: businessDate,
started_at: startedAt ?? null,
ended_at: endedAt ?? null,
worked_minutes: workedMinutes,
status: sessionStatus,
revision: nextRev,
};
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', nextRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: sessionId, revision: nextRev, status: 'applied' },
};
}
// New work session insert
const newRev = clientRevision;
await client.query(
`INSERT INTO staff_work_sessions
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, revision)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[sessionId, tenantId, employeeId, orgUnitId, businessDate, startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, newRev],
);
const safePayload: SafeWorkSessionPayload = {
id: sessionId,
employee_id: employeeId,
org_unit_id: orgUnitId,
business_date: businessDate,
started_at: startedAt ?? null,
ended_at: endedAt ?? null,
worked_minutes: workedMinutes,
status: sessionStatus,
revision: newRev,
};
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', newRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: sessionId, revision: newRev, status: 'applied' },
};
}
case 'upsert_timesheet_entry': { case 'upsert_timesheet_entry': {
const entryId = (payload.id as string) || newUlid();
const employeeId = payload.employee_id as string;
const workDate = payload.work_date as string;
const minutesWorked = typeof payload.minutes_worked === 'number' ? payload.minutes_worked : 0;
const tsStatus = (payload.status as string) || 'draft';
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
if (!employeeId || !workDate) {
return { return {
idempotency_key: cmd.idempotency_key, idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type, command_type: cmd.command_type,
status: 'rejected', status: 'rejected',
error_code: 'INVALID_TIMESHEET_PAYLOAD', error_code: 'COMMAND_NOT_SUPPORTED',
error_message: 'employee_id и work_date обязательны', error_message:
}; 'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено',
}
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
`SELECT id, revision, status FROM staff_timesheet_entries WHERE tenant_id = $1 AND id = $2`,
[tenantId, entryId],
);
if (existingRes.rows.length > 0) {
const existing = existingRes.rows[0]!;
if (existing.revision !== clientRevision) {
const conflict: SyncConflict = {
conflict_code: 'REVISION_MISMATCH',
entity_type: 'timesheet_entry',
entity_id: entryId,
expected_revision: clientRevision,
actual_revision: existing.revision,
safe_description: 'Ревизия записи табеля на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
};
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'conflict',
conflict,
};
}
const nextRev = existing.revision + 1;
await client.query(
`UPDATE staff_timesheet_entries
SET minutes_worked = $1, status = $2, revision = $3, updated_at = now()
WHERE tenant_id = $4 AND id = $5`,
[minutesWorked, tsStatus, nextRev, tenantId, entryId],
);
const safePayload: SafeTimesheetEntryPayload = {
id: entryId,
employee_id: employeeId,
work_date: workDate,
minutes_worked: minutesWorked,
status: tsStatus,
revision: nextRev,
};
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', nextRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: entryId, revision: nextRev, status: 'applied' },
};
}
const newRev = clientRevision;
await client.query(
`INSERT INTO staff_timesheet_entries
(id, tenant_id, employee_id, work_date, minutes_worked, status, revision, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[entryId, tenantId, employeeId, workDate, minutesWorked, tsStatus, newRev, device.id],
);
const safePayload: SafeTimesheetEntryPayload = {
id: entryId,
employee_id: employeeId,
work_date: workDate,
minutes_worked: minutesWorked,
status: tsStatus,
revision: newRev,
};
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', newRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: entryId, revision: newRev, status: 'applied' },
}; };
} }
@ -1052,8 +905,13 @@ export class SyncService {
); );
} }
async cleanupRetention(retentionDays = 30): Promise<{ purged_change_logs: number; purged_commands: number }> { async cleanupRetention(
return this.database.tenantTransaction(this.systemActorId, this.systemActorId, async (client) => { retentionDays = 30,
): Promise<{ purged_change_logs: number; purged_commands: number }> {
return this.database.tenantTransaction(
this.systemActorId,
this.systemActorId,
async (client) => {
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>( const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`, `SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
[retentionDays], [retentionDays],
@ -1063,6 +921,7 @@ export class SyncService {
purged_change_logs: Number(row?.purged_change_logs ?? 0), purged_change_logs: Number(row?.purged_change_logs ?? 0),
purged_commands: Number(row?.purged_commands ?? 0), purged_commands: Number(row?.purged_commands ?? 0),
}; };
}); },
);
} }
} }

View file

@ -4,11 +4,7 @@ import { parseUlid, type Ulid } from './core/ids.js';
import type { Database } from './infrastructure/database.js'; import type { Database } from './infrastructure/database.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js'; import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { IdentityPort } from './core/ports.js'; import type { IdentityPort } from './core/ports.js';
import { import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
SyncAuthError,
SyncService,
SyncValidationError,
} from './domain/sync/service.js';
import { InvalidCursorError } from './domain/sync/cursor.js'; import { InvalidCursorError } from './domain/sync/cursor.js';
import type { SyncCommand } from './domain/sync/types.js'; import type { SyncCommand } from './domain/sync/types.js';
@ -97,14 +93,18 @@ export async function registerSyncRoutes(
!context.permissions.has('staff:attendance:manage') && !context.permissions.has('staff:attendance:manage') &&
!context.permissions.has('staff:sync:manage') !context.permissions.has('staff:sync:manage')
) { ) {
throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации'); throw request.server.httpErrors.forbidden(
'Недостаточно прав для управления устройствами синхронизации',
);
} }
return context; return context;
} catch (error: unknown) { } catch (error: unknown) {
if (typeof error === 'object' && error !== null && 'statusCode' in error) { if (typeof error === 'object' && error !== null && 'statusCode' in error) {
throw error; throw error;
} }
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя'); throw request.server.httpErrors.unauthorized(
'Требуется действующий ключ доступа руководителя',
);
} }
}; };
@ -169,7 +169,11 @@ export async function registerSyncRoutes(
}, },
async (request, reply) => { async (request, reply) => {
const manager = await requireManagerContext(request); const manager = await requireManagerContext(request);
const result = await syncService.registerDevice(manager.tenantId, manager.actorId, request.body); const result = await syncService.registerDevice(
manager.tenantId,
manager.actorId,
request.body,
);
return reply.code(201).send(result); return reply.code(201).send(result);
}, },
); );
@ -257,7 +261,8 @@ export async function registerSyncRoutes(
{ {
schema: { schema: {
tags: ['Sync'], tags: ['Sync'],
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)', summary:
'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
body: { body: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@ -279,7 +284,15 @@ export async function registerSyncRoutes(
type: 'array', type: 'array',
items: { items: {
type: 'object', type: 'object',
required: ['cursor', 'entity_type', 'entity_id', 'operation', 'revision', 'payload', 'created_at'], required: [
'cursor',
'entity_type',
'entity_id',
'operation',
'revision',
'payload',
'created_at',
],
properties: { properties: {
cursor: { type: 'string' }, cursor: { type: 'string' },
entity_type: { type: 'string' }, entity_type: { type: 'string' },
@ -330,7 +343,14 @@ export async function registerSyncRoutes(
maxItems: 100, maxItems: 100,
items: { items: {
type: 'object', type: 'object',
required: ['command_type', 'schema_version', 'idempotency_key', 'correlation_id', 'created_at', 'payload'], required: [
'command_type',
'schema_version',
'idempotency_key',
'correlation_id',
'created_at',
'payload',
],
properties: { properties: {
command_type: { type: 'string' }, command_type: { type: 'string' },
schema_version: { type: 'integer', default: 1 }, schema_version: { type: 'integer', default: 1 },
@ -387,7 +407,11 @@ export async function registerSyncRoutes(
const token = extractBearerToken(request); const token = extractBearerToken(request);
const tenantId = resolveTenantId(request); const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId); const device = await syncService.authenticateDevice(token, tenantId);
const results = await syncService.push(device.tenant_id, device, request.body?.commands ?? []); const results = await syncService.push(
device.tenant_id,
device,
request.body?.commands ?? [],
);
return reply.code(200).send({ results }); return reply.code(200).send({ results });
} catch (error) { } catch (error) {
handleSyncError(error, request); handleSyncError(error, request);
@ -428,7 +452,11 @@ export async function registerSyncRoutes(
const token = extractBearerToken(request); const token = extractBearerToken(request);
const tenantId = resolveTenantId(request); const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId); const device = await syncService.authenticateDevice(token, tenantId);
const result = await syncService.acknowledge(device.tenant_id, device.id, request.body.cursor); const result = await syncService.acknowledge(
device.tenant_id,
device.id,
request.body.cursor,
);
return reply.code(200).send(result); return reply.code(200).send(result);
} catch (error) { } catch (error) {
handleSyncError(error, request); handleSyncError(error, request);

View file

@ -29,7 +29,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
afterAll(async () => { afterAll(async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => { 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_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_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_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_sync_devices WHERE tenant_id = $1', [tenantA]);
@ -253,7 +255,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
}); });
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) => const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]), client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [
deviceIdA,
]),
); );
expect(cpVisibleToB.rowCount).toBe(0); expect(cpVisibleToB.rowCount).toBe(0);

View file

@ -8,7 +8,9 @@ import type { SyncCommand } from '../../src/domain/sync/types.js';
import { buildApp } from '../../src/app.js'; import { buildApp } from '../../src/app.js';
import { loadConfig } from '../../src/config.js'; import { loadConfig } from '../../src/config.js';
function createMockDb(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) { function createMockDb(
queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number },
) {
const queries: Array<{ sql: string; params: unknown[] }> = []; const queries: Array<{ sql: string; params: unknown[] }> = [];
const query = vi.fn(async (sql: string, params: unknown[] = []) => { const query = vi.fn(async (sql: string, params: unknown[] = []) => {
queries.push({ sql, params }); queries.push({ sql, params });
@ -105,7 +107,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
expect(device.tenant_id).toBe(tenantA); expect(device.tenant_id).toBe(tenantA);
expect(device.status).toBe('active'); expect(device.status).toBe('active');
expect( expect(
queries.some((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')), queries.some(
(q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at'),
),
).toBe(true); ).toBe(true);
}); });
@ -187,7 +191,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
}); });
const service = new SyncService(database, systemActorId); const service = new SyncService(database, systemActorId);
await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({ await expect(
service.authenticateDevice(rawToken, tenantA, 'attendance:sync'),
).rejects.toMatchObject({
code: 'SCOPE_INSUFFICIENT', code: 'SCOPE_INSUFFICIENT',
statusCode: 403, statusCode: 403,
}); });
@ -297,25 +303,15 @@ describe('Sync Push & Idempotency', () => {
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(false); 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 () => { it('rejects upsert_work_session command with COMMAND_NOT_SUPPORTED', async () => {
const sessionId = newUlid(); const sessionId = newUlid();
const { database } = createMockDb();
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 service = new SyncService(database, systemActorId);
const command: SyncCommand = { const command: SyncCommand = {
command_type: 'upsert_work_session', command_type: 'upsert_work_session',
schema_version: 1, schema_version: 1,
idempotency_key: 'cmd-session-conflict', idempotency_key: 'cmd-session-reject',
correlation_id: newUlid(), correlation_id: newUlid(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
payload: { payload: {
@ -323,43 +319,28 @@ describe('Sync Push & Idempotency', () => {
employee_id: employeeId, employee_id: employeeId,
org_unit_id: newUlid(), org_unit_id: newUlid(),
business_date: '2026-09-26', business_date: '2026-09-26',
revision: 2, // Client expects revision 2, but server has 3 revision: 1,
worked_minutes: 480, worked_minutes: 480,
status: 'confirmed',
}, },
}; };
const results = await service.push(tenantId, testDevice, [command]); const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('conflict'); expect(results[0]?.status).toBe('rejected');
expect(results[0]?.conflict).toEqual({ expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
conflict_code: 'REVISION_MISMATCH', expect(results[0]?.error_message).toContain('Команда не поддерживается');
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 () => { it('rejects upsert_timesheet_entry command with COMMAND_NOT_SUPPORTED', async () => {
const entryId = newUlid(); const entryId = newUlid();
const { database } = createMockDb();
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 service = new SyncService(database, systemActorId);
const command: SyncCommand = { const command: SyncCommand = {
command_type: 'upsert_timesheet_entry', command_type: 'upsert_timesheet_entry',
schema_version: 1, schema_version: 1,
idempotency_key: 'cmd-ts-conflict', idempotency_key: 'cmd-ts-reject',
correlation_id: newUlid(), correlation_id: newUlid(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
payload: { payload: {
@ -367,22 +348,92 @@ describe('Sync Push & Idempotency', () => {
employee_id: employeeId, employee_id: employeeId,
work_date: '2026-09-26', work_date: '2026-09-26',
minutes_worked: 500, minutes_worked: 500,
revision: 1, // Mismatch: expects 1, server has 4 revision: 1,
}, },
}; };
const results = await service.push(tenantId, testDevice, [command]); const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('conflict'); expect(results[0]?.status).toBe('rejected');
expect(results[0]?.conflict).toEqual({ expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
conflict_code: 'REVISION_MISMATCH', expect(results[0]?.error_message).toContain('Команда не поддерживается');
entity_type: 'timesheet_entry',
entity_id: entryId,
expected_revision: 1,
actual_revision: 4,
safe_description: 'Ревизия записи табеля на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
}); });
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 () => { it('enforces batch size limits and payload size limits', async () => {