Files
sencho/backend/src/__tests__/database-matching-policy.test.ts
T
Anso 27660f622b 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.
2026-05-07 12:53:13 -04:00

112 lines
4.0 KiB
TypeScript

/**
* Pins the matching and ordering behavior of `DatabaseService.getMatchingPolicy`.
*
* The matcher must be deterministic across replicas: two policies in the same
* scope class (e.g. both fleet-wide stack-wildcard) need to resolve to the same
* winner regardless of SQLite row-iteration order. The chosen tiebreaker is
* lowest id wins, so the oldest-defined policy stays authoritative.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('getMatchingPolicy tiebreaker', () => {
it('returns the lowest-id row when two policies tie on scope class', () => {
const db = DatabaseService.getInstance();
const first = db.createScanPolicy({
name: 'first-fleet-wide',
node_id: null,
node_identity: '',
stack_pattern: null,
max_severity: 'HIGH',
block_on_deploy: 0,
enabled: 1,
replicated_from_control: 0,
});
const second = db.createScanPolicy({
name: 'second-fleet-wide',
node_id: null,
node_identity: '',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 0,
enabled: 1,
replicated_from_control: 0,
});
const winner = db.getMatchingPolicy(1, 'web', 'local');
expect(winner?.id).toBe(first.id);
// Sanity: with first deleted, the next-lowest takes over.
db.deleteScanPolicy(first.id);
const next = db.getMatchingPolicy(1, 'web', 'local');
expect(next?.id).toBe(second.id);
});
it('prefers node-scoped over fleet-wide regardless of id order', () => {
const db = DatabaseService.getInstance();
const fleetWide = db.createScanPolicy({
name: 'tie-fleet',
node_id: null,
node_identity: '',
stack_pattern: null,
max_severity: 'LOW',
block_on_deploy: 0,
enabled: 1,
replicated_from_control: 0,
});
const nodeScoped = db.createScanPolicy({
name: 'tie-node',
node_id: 1,
node_identity: 'local',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 0,
enabled: 1,
replicated_from_control: 0,
});
const winner = db.getMatchingPolicy(1, 'web', 'local');
// Node-scoped wins by class, even though its id is higher than the fleet-wide row.
expect(winner?.id).toBe(nodeScoped.id);
db.deleteScanPolicy(fleetWide.id);
db.deleteScanPolicy(nodeScoped.id);
});
it('respects identity matching for replicated rows', () => {
const db = DatabaseService.getInstance();
const otherIdentity = db.createScanPolicy({
name: 'replicated-other',
node_id: null,
node_identity: 'https://other.example',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 0,
enabled: 1,
replicated_from_control: 1,
});
const ourIdentity = db.createScanPolicy({
name: 'replicated-self',
node_id: null,
node_identity: 'https://me.example',
stack_pattern: null,
max_severity: 'HIGH',
block_on_deploy: 0,
enabled: 1,
replicated_from_control: 1,
});
const winner = db.getMatchingPolicy(1, 'web', 'https://me.example');
expect(winner?.id).toBe(ourIdentity.id);
db.deleteScanPolicy(otherIdentity.id);
db.deleteScanPolicy(ourIdentity.id);
});
});