mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
27660f622b
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.
112 lines
4.0 KiB
TypeScript
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);
|
|
});
|
|
});
|