mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
fix(fleet-sync): version the wire protocol and serialize per-node pushes (#967)
Hardens the scan-policy and CVE-suppression replication channel as the foundation of a multi-PR fleet sync hardening track. No new endpoints, no new tables, no schema changes; receivers still tolerate legacy payloads (absent pushedAt and controlIdentity) for rollout safety. Wire protocol: - Sender stamps every push with a strictly-increasing pushedAt and a placeholder controlIdentity. Receiver rejects strictly-older pushedAt with 409 STALE_SYNC_PUSH so the next write retries. - pushedAt comparison plus row replacement plus watermark write run in a single SQLite transaction; a partial-write window cannot leave the watermark behind the row state. Concurrency and limits: - Per-node mutex on the sender so concurrent control writes serialize per remote and never apply older state on top of newer. - Sender-side row cap at MAX_SYNC_ROWS=5000 with a 6-hour throttled truncation alert so flapping configs cannot flood the operator. - Route-level body limit raised to 5MB on POST /api/fleet/sync/:resource only; the global 100KB cap is unchanged. Oversize bodies return a structured 413 SYNC_PAYLOAD_TOO_LARGE. Determinism and hygiene: - getMatchingPolicy gains an id-ASC tiebreaker so two replicas resolve the same winner when policies tie on scope class. - Inline comment documents why getMatchingPolicy filters node_id at SQL yet still relies on JS identity matching for replicated rows. - Comment on the receive endpoint documents why no requirePaid is enforced (control's tier authorizes; replica trusts the bearer). - STALE_SYNC_PUSH 409s no longer record a node failure; they are expected protocol outcomes, not health issues. Public surface additions: - DatabaseService.transaction(fn): generic SAVEPOINT-friendly wrapper. - DatabaseService.getLocalScanPolicies / getLocalCveSuppressions: SQL filter on replicated_from_control = 0. - StaleSyncPushError: typed sentinel the route translates to 409. - fleetSyncConstants: shared MAX_SYNC_ROWS, body limit, state-key and error-code maps so the wire protocol has one source of truth. Tests: - 24 new vitest cases across fleet-sync-service, fleet-sync-routes, and database-matching-policy covering monotonic pushedAt, per-node serialization, row truncation and throttle, stale-push suppression, receiver back-compat, oversize-body 413, deterministic matching. - Full backend suite: 1757 pass / 5 skipped.
This commit is contained in:
@@ -131,3 +131,59 @@ describe('GET /api/fleet/sync-status', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/sync/:resource pushedAt protocol', () => {
|
||||
const validRow = {
|
||||
name: 'from-control',
|
||||
node_identity: '',
|
||||
stack_pattern: null,
|
||||
max_severity: 'CRITICAL' as const,
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
};
|
||||
|
||||
it('accepts payloads without pushedAt for back-compat with legacy controls', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a fresh pushedAt and persists it for stale-rejection compare', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pushedAt: 1_700_000_000_000 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects a stale pushedAt with 409 STALE_SYNC_PUSH', async () => {
|
||||
// Send fresh, then send strictly-older.
|
||||
await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pushedAt: 1_800_000_000_000 });
|
||||
const stale = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pushedAt: 1_700_000_000_000 });
|
||||
expect(stale.status).toBe(409);
|
||||
expect(stale.body.code).toBe('STALE_SYNC_PUSH');
|
||||
});
|
||||
|
||||
it('returns a friendly 413 SYNC_PAYLOAD_TOO_LARGE when the body exceeds the parser limit', async () => {
|
||||
// ~6 MB of padding pushes past the 5mb route-level limit. Keeps a single
|
||||
// valid row so any path that did parse would succeed; we want the parser
|
||||
// to reject before the handler runs.
|
||||
const padding = 'x'.repeat(6 * 1024 * 1024);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pad: padding });
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body.code).toBe('SYNC_PAYLOAD_TOO_LARGE');
|
||||
expect(res.body.error).toMatch(/Sync payload too large/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user