super-productivity/packages/super-sync-server/tests/validation.service.spec.ts
Johannes Millan f38342eeb9 fix(sync): harden replay and conflict recovery
Make remote replay and authoritative state replacement crash-resumable and atomic. Preserve pending changes until piggyback processing commits, and stop cleanly on incompatible operations. Validate sync identities and schemas while strengthening request deduplication and privacy-safe diagnostics.
2026-07-10 15:24:51 +02:00

565 lines
21 KiB
TypeScript
Raw 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 { describe, it, expect, beforeEach } from 'vitest';
import {
ValidationService,
ALLOWED_ENTITY_TYPES,
} from '../src/sync/services/validation.service';
import { DEFAULT_SYNC_CONFIG, SYNC_ERROR_CODES } from '../src/sync/sync.types';
describe('ValidationService', () => {
let validationService: ValidationService;
const clientId = 'test-client-123';
beforeEach(() => {
validationService = new ValidationService(DEFAULT_SYNC_CONFIG);
});
const createValidOp = (overrides: Record<string, unknown> = {}) => ({
id: 'op-1',
clientId,
opType: 'CRT' as const,
entityType: 'TASK',
entityId: 'entity-1',
payload: { name: 'Test' },
timestamp: Date.now(),
vectorClock: { [clientId]: 1 },
...overrides,
});
describe('validateOp', () => {
it('should accept a valid operation', () => {
const op = createValidOp();
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it('should reject when clientId does not match', () => {
const op = createValidOp();
const result = validationService.validateOp(op, 'different-client');
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_CLIENT_ID);
expect(result.error).toContain('does not match');
});
it('should reject missing operation ID', () => {
const op = createValidOp({ id: '' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_OP_ID);
});
it('should reject non-string operation ID', () => {
const op = createValidOp({ id: 123 });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_OP_ID);
});
it('should reject operation ID longer than 255 characters', () => {
const op = createValidOp({ id: 'x'.repeat(256) });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_OP_ID);
expect(result.error).toContain('too long');
});
it('should reject invalid opType', () => {
const op = createValidOp({ opType: 'INVALID' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_OP_TYPE);
});
it('should reject missing opType', () => {
const op = createValidOp({ opType: '' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_OP_TYPE);
});
it('should reject missing entityType', () => {
const op = createValidOp({ entityType: '' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_TYPE);
});
it('should reject invalid entityType', () => {
const op = createValidOp({ entityType: 'UNKNOWN_TYPE' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_TYPE);
expect(result.error).toContain('Invalid entityType');
});
it('should accept all allowed entity types', () => {
for (const entityType of ALLOWED_ENTITY_TYPES) {
const op = createValidOp({ entityType });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
}
});
it('should reject entityId longer than 255 characters', () => {
const op = createValidOp({ entityId: 'x'.repeat(256) });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
});
it('should reject non-string entityId', () => {
const op = createValidOp({ entityId: 123 });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
});
// === entityIds (multi-entity batch ops) validation (#8334) ===
it('should accept a valid entityIds array', () => {
const op = createValidOp({ entityIds: ['task-1', 'task-2'] });
expect(validationService.validateOp(op, clientId).valid).toBe(true);
});
it('should reject an entityIds element longer than 255 characters', () => {
const op = createValidOp({ entityIds: ['ok', 'x'.repeat(256)] });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
});
it('should reject a non-string / empty entityIds element', () => {
expect(
validationService.validateOp(createValidOp({ entityIds: [123] }), clientId).valid,
).toBe(false);
expect(
validationService.validateOp(createValidOp({ entityIds: [' '] }), clientId)
.valid,
).toBe(false);
});
it('should reject more than SUPER_SYNC_MAX_ENTITY_IDS_PER_OP entries', () => {
const op = createValidOp({ entityIds: new Array(1001).fill('id') });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
});
// === entityId validation for regular entity types ===
it('should reject null entityId for regular entity type (TASK)', () => {
const op = createValidOp({ entityId: null, opType: 'CRT', entityType: 'TASK' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.MISSING_ENTITY_ID);
expect(result.error).toContain('requires entityId');
});
it('should reject undefined entityId for regular entity type (TAG)', () => {
const op = createValidOp({ entityId: undefined, opType: 'UPD', entityType: 'TAG' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.MISSING_ENTITY_ID);
});
it('should reject missing entityId for DEL operation', () => {
const op = createValidOp({ opType: 'DEL', entityId: null });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.MISSING_ENTITY_ID);
});
it('should accept DEL operation with valid entityId', () => {
const op = createValidOp({ opType: 'DEL', entityId: 'entity-1' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
// === entityId validation for bulk entity types (ALL, RECOVERY) ===
it('should accept null entityId for entityType ALL', () => {
const op = createValidOp({ entityId: null, opType: 'UPD', entityType: 'ALL' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
it('should accept null entityId for entityType RECOVERY', () => {
const op = createValidOp({
entityId: null,
opType: 'BATCH',
entityType: 'RECOVERY',
});
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
// === entityId validation for full-state operations ===
it('should accept null entityId for SYNC_IMPORT operation', () => {
const op = createValidOp({
entityId: null,
opType: 'SYNC_IMPORT',
entityType: 'TASK',
payload: { appDataComplete: {} },
});
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
it('should accept null entityId for BACKUP_IMPORT operation', () => {
const op = createValidOp({
entityId: null,
opType: 'BACKUP_IMPORT',
entityType: 'PROJECT',
payload: { appDataComplete: {} },
});
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
it('should accept null entityId for REPAIR operation', () => {
const op = createValidOp({
entityId: null,
opType: 'REPAIR',
entityType: 'TAG',
payload: { appDataComplete: {} },
});
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
// === entityId validation edge cases ===
it('should reject empty string entityId', () => {
const op = createValidOp({ entityId: '', opType: 'CRT', entityType: 'TASK' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
expect(result.error).toContain('empty');
});
it('should reject whitespace-only entityId', () => {
const op = createValidOp({ entityId: ' ', opType: 'UPD', entityType: 'TASK' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
expect(result.error).toContain('empty');
});
it('should reject tab and newline-only entityId', () => {
const op = createValidOp({
entityId: '\t\n \r',
opType: 'CRT',
entityType: 'PROJECT',
});
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID);
});
it('should include opType and entityType in error message for missing entityId', () => {
const op = createValidOp({ entityId: null, opType: 'UPD', entityType: 'PROJECT' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.error).toContain('UPD');
expect(result.error).toContain('PROJECT');
});
it('should reject missing entityId for all regular entity types', () => {
const regularEntityTypes = [
'TASK',
'PROJECT',
'TAG',
'NOTE',
'GLOBAL_CONFIG',
'TIME_TRACKING',
'SIMPLE_COUNTER',
'WORK_CONTEXT',
'TASK_REPEAT_CFG',
'ISSUE_PROVIDER',
'PLANNER',
'MENU_TREE',
'METRIC',
'BOARD',
'REMINDER',
'PLUGIN_USER_DATA',
'PLUGIN_METADATA',
];
for (const entityType of regularEntityTypes) {
const op = createValidOp({ entityId: null, opType: 'UPD', entityType });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.MISSING_ENTITY_ID);
}
});
it('should reject undefined payload', () => {
const op = createValidOp({ payload: undefined });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_PAYLOAD);
});
it('should reject schema version less than 1', () => {
const op = createValidOp({ schemaVersion: 0 });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_SCHEMA_VERSION);
});
it('should reject schema version greater than 100', () => {
const op = createValidOp({ schemaVersion: 101 });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_SCHEMA_VERSION);
});
it('should reject non-integer schema versions', () => {
const result = validationService.validateOp(
createValidOp({ schemaVersion: 1.5 }),
clientId,
);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_SCHEMA_VERSION);
});
it('should accept valid schema versions 1-100', () => {
for (const schemaVersion of [1, 50, 100]) {
const op = createValidOp({ schemaVersion });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
}
});
it('should reject invalid vector clock format', () => {
const op = createValidOp({ vectorClock: 'invalid' });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_VECTOR_CLOCK);
});
it('should strip invalid vector clock entries (non-numeric values)', () => {
const op = createValidOp({ vectorClock: { client1: '5', client2: 10 } });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
// String values are stripped, only valid numeric entries remain
expect(op.vectorClock).toEqual({ client2: 10 });
});
it('should reject deeply nested payloads', () => {
let payload: Record<string, unknown> = { value: 'leaf' };
for (let i = 0; i < 25; i++) {
payload = { nested: payload };
}
const op = createValidOp({ payload });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_PAYLOAD);
expect(result.error).toContain('too complex');
});
it('should skip payload complexity check for SYNC_IMPORT', () => {
let payload: Record<string, unknown> = { value: 'leaf' };
for (let i = 0; i < 25; i++) {
payload = { nested: payload };
}
const op = createValidOp({ opType: 'SYNC_IMPORT', payload, entityId: null });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
it('should skip payload complexity check for BACKUP_IMPORT', () => {
let payload: Record<string, unknown> = { value: 'leaf' };
for (let i = 0; i < 25; i++) {
payload = { nested: payload };
}
const op = createValidOp({ opType: 'BACKUP_IMPORT', payload, entityId: null });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
it('should skip payload complexity check for REPAIR', () => {
let payload: Record<string, unknown> = { value: 'leaf' };
for (let i = 0; i < 25; i++) {
payload = { nested: payload };
}
const op = createValidOp({ opType: 'REPAIR', payload, entityId: null });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
it('should reject payloads larger than max size', () => {
const service = new ValidationService({
...DEFAULT_SYNC_CONFIG,
maxPayloadSizeBytes: 100,
});
const op = createValidOp({ payload: { data: 'x'.repeat(200) } });
const result = service.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.PAYLOAD_TOO_LARGE);
});
it('should measure the size limit in UTF-8 bytes, not UTF-16 code units', () => {
// '✓' (U+2713) is one UTF-16 code unit but three UTF-8 bytes.
// JSON.stringify({ data: '✓'×100 }) is 111 UTF-16 code units but 311 UTF-8
// bytes. With a 200-byte limit the old String#length check (111) wrongly
// passed; the UTF-8 byte measure (311) correctly rejects.
const service = new ValidationService({
...DEFAULT_SYNC_CONFIG,
maxPayloadSizeBytes: 200,
});
const payload = { data: '✓'.repeat(100) };
expect(JSON.stringify(payload).length).toBeLessThanOrEqual(200);
expect(Buffer.byteLength(JSON.stringify(payload), 'utf8')).toBeGreaterThan(200);
const result = service.validateOp(createValidOp({ payload }), clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.PAYLOAD_TOO_LARGE);
});
it('returns the UTF-8 payload byte size on the valid result', () => {
const asciiOp = createValidOp({ payload: { name: 'Test' } });
const asciiResult = validationService.validateOp(asciiOp, clientId);
expect(asciiResult.valid).toBe(true);
expect(asciiResult.payloadBytes).toBe(
Buffer.byteLength(JSON.stringify(asciiOp.payload), 'utf8'),
);
// Non-ASCII: byte size exceeds the UTF-16 code-unit count.
const unicodeOp = createValidOp({ payload: { note: '日本語✓' } });
const unicodeResult = validationService.validateOp(unicodeOp, clientId);
expect(unicodeResult.valid).toBe(true);
expect(unicodeResult.payloadBytes).toBe(
Buffer.byteLength(JSON.stringify(unicodeOp.payload), 'utf8'),
);
expect(unicodeResult.payloadBytes).toBeGreaterThan(
JSON.stringify(unicodeOp.payload).length,
);
});
it('should accept timestamps in the future (clamping handled during upload)', () => {
// Future timestamp validation removed - clamping is handled in OperationUploadService.
const futureTime = Date.now() + 10 * 60 * 1000; // 10 minutes in future
const op = createValidOp({ timestamp: futureTime });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(true);
});
it('should reject timestamps too old', () => {
const oldTime = Date.now() - 50 * 24 * 60 * 60 * 1000; // 50 days ago (beyond 45-day retention)
const op = createValidOp({ timestamp: oldTime });
const result = validationService.validateOp(op, clientId);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_TIMESTAMP);
expect(result.error).toContain('too old');
});
});
describe('validatePayloadComplexity', () => {
it('should accept simple objects', () => {
const result = validationService.validatePayloadComplexity({ a: 1, b: 2 });
expect(result).toBe(true);
});
it('should accept null', () => {
const result = validationService.validatePayloadComplexity(null);
expect(result).toBe(true);
});
it('should accept primitives', () => {
expect(validationService.validatePayloadComplexity('string')).toBe(true);
expect(validationService.validatePayloadComplexity(123)).toBe(true);
expect(validationService.validatePayloadComplexity(true)).toBe(true);
});
it('should accept arrays within limits', () => {
const result = validationService.validatePayloadComplexity([1, 2, 3, 4, 5]);
expect(result).toBe(true);
});
it('should reject objects exceeding max depth', () => {
let obj: Record<string, unknown> = { value: 'leaf' };
for (let i = 0; i < 25; i++) {
obj = { nested: obj };
}
const result = validationService.validatePayloadComplexity(obj);
expect(result).toBe(false);
});
it('should respect custom max depth', () => {
let obj: Record<string, unknown> = { value: 'leaf' };
for (let i = 0; i < 5; i++) {
obj = { nested: obj };
}
// 6 levels deep
expect(validationService.validatePayloadComplexity(obj, 5)).toBe(false);
expect(validationService.validatePayloadComplexity(obj, 10)).toBe(true);
});
it('should reject objects with too many keys', () => {
const obj: Record<string, number> = {};
for (let i = 0; i < 25000; i++) {
obj[`key${i}`] = i;
}
const result = validationService.validatePayloadComplexity(obj);
expect(result).toBe(false);
});
it('should respect custom max keys', () => {
const obj: Record<string, number> = {};
for (let i = 0; i < 100; i++) {
obj[`key${i}`] = i;
}
expect(validationService.validatePayloadComplexity(obj, 20, 50)).toBe(false);
expect(validationService.validatePayloadComplexity(obj, 20, 200)).toBe(true);
});
it('should count array elements towards key limit', () => {
const arr = new Array(25000).fill(1);
const result = validationService.validatePayloadComplexity(arr);
expect(result).toBe(false);
});
it('should handle nested arrays', () => {
const result = validationService.validatePayloadComplexity([[[[[[1]]]]]]);
expect(result).toBe(true);
});
});
describe('ALLOWED_ENTITY_TYPES', () => {
it('should include all expected entity types', () => {
const expectedTypes = [
'TASK',
'PROJECT',
'TAG',
'NOTE',
'GLOBAL_CONFIG',
'TIME_TRACKING',
'SIMPLE_COUNTER',
'WORK_CONTEXT',
'TASK_REPEAT_CFG',
'ISSUE_PROVIDER',
'PLANNER',
'MENU_TREE',
'METRIC',
'BOARD',
'SECTION',
'REMINDER',
'MIGRATION',
'RECOVERY',
'ALL',
'PLUGIN_USER_DATA',
'PLUGIN_METADATA',
];
for (const type of expectedTypes) {
expect(ALLOWED_ENTITY_TYPES.has(type)).toBe(true);
}
});
it('should have exactly the expected number of entity types', () => {
expect(ALLOWED_ENTITY_TYPES.size).toBe(21);
});
});
});