diff --git a/backend/src/__tests__/fleet-sync-apply-flow.test.ts b/backend/src/__tests__/fleet-sync-apply-flow.test.ts new file mode 100644 index 00000000..79a767b2 --- /dev/null +++ b/backend/src/__tests__/fleet-sync-apply-flow.test.ts @@ -0,0 +1,197 @@ +/** + * End-to-end apply-flow integration test for Fleet Sync against a real + * in-process SQLite database. + * + * The unit suite (`fleet-sync-service.test.ts`) mocks every DatabaseService + * method, so it proves the service calls the right methods but not that the + * SQLite transaction actually flips the role, replaces the replicated rows, + * persists the watermark, and leaves local rows alone. This file drives the + * receive path on a real DB: build the wire payload a control would send, + * call `applyIncomingSync`, and assert the replica's observable state plus + * `blockIfReplica` enforcement. It also covers the two rejection branches + * (stale watermark, control-anchor mismatch): each throws before any row + * mutation, so the assertion is that the prior accepted state is preserved. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import type { Response } from 'express'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { SYNC_STATE_KEYS } from '../services/fleetSyncConstants'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let FleetSyncService: typeof import('../services/FleetSyncService').FleetSyncService; +let StaleSyncPushError: typeof import('../services/FleetSyncService').StaleSyncPushError; +let ControlIdentityMismatchError: typeof import('../services/FleetSyncService').ControlIdentityMismatchError; +let blockIfReplica: typeof import('../middleware/fleetSyncGuards').blockIfReplica; + +type ScanPolicy = import('../services/DatabaseService').ScanPolicy; + +function makeRow(name: string, overrides: Partial = {}): ScanPolicy { + return { + id: 0, + name, + node_id: null, + node_identity: '', + stack_pattern: null, + max_severity: 'CRITICAL', + block_on_deploy: 1, + enabled: 1, + replicated_from_control: 1, + created_at: 1, + updated_at: 1, + ...overrides, + }; +} + +/** Minimal Response double capturing the status/body blockIfReplica writes. */ +function fakeRes(): { res: Response; captured: { statusCode: number; body: unknown } } { + const captured = { statusCode: 0, body: undefined as unknown }; + const res = { + status(code: number) { captured.statusCode = code; return res; }, + json(body: unknown) { captured.body = body; return res; }, + }; + return { res: res as unknown as Response, captured }; +} + +/** Force this instance back to a clean control state between cases. */ +function resetToControl(): void { + const db = DatabaseService.getInstance(); + db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'control'); + db.setSystemState(SYNC_STATE_KEYS.fleetSelfIdentity, ''); + db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, ''); + db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), ''); + db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), ''); + db.clearReplicatedRows(); + // Drop any local scan policies seeded by a prior case. + db.getDb().prepare('DELETE FROM scan_policies WHERE replicated_from_control = 0').run(); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ FleetSyncService, StaleSyncPushError, ControlIdentityMismatchError } = await import('../services/FleetSyncService')); + ({ blockIfReplica } = await import('../middleware/fleetSyncGuards')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + resetToControl(); +}); + +describe('Fleet Sync apply flow (real DB round trip)', () => { + it('flips control -> replica, persists identity + watermark, and installs replicated rows', () => { + const db = DatabaseService.getInstance(); + expect(FleetSyncService.getRole()).toBe('control'); + + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + const rows = [makeRow('block-critical'), makeRow('warn-high', { max_severity: 'HIGH', block_on_deploy: 0 })]; + FleetSyncService.getInstance().applyIncomingSync( + 'scan_policies', + rows, + 'https://replica.example', + 1_000, + 'control-fp-1', + ); + + expect(FleetSyncService.getRole()).toBe('replica'); + expect(db.getSystemState(SYNC_STATE_KEYS.fleetSelfIdentity)).toBe('https://replica.example'); + expect(db.getSystemState(SYNC_STATE_KEYS.fleetControlIdentity)).toBe('control-fp-1'); + expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'))).toBe('1000'); + + const installed = db.getScanPolicies(); + expect(installed).toHaveLength(2); + expect(installed.every((p) => p.replicated_from_control === 1)).toBe(true); + expect(installed.map((p) => p.name).sort()).toEqual(['block-critical', 'warn-high']); + + // The transition log fires exactly once on the actual flip. + expect(infoSpy.mock.calls.filter((c) => String(c[0]).includes('now a replica'))).toHaveLength(1); + infoSpy.mockRestore(); + }); + + it('blocks local writes on a replica and allows them on a control', () => { + // Control: guard is a no-op. + const control = fakeRes(); + expect(blockIfReplica(control.res, 'scan_policies')).toBe(false); + expect(control.captured.statusCode).toBe(0); + + // After an apply, the same guard rejects with 403 REPLICA_READ_ONLY. + FleetSyncService.getInstance().applyIncomingSync('scan_policies', [], 'https://replica.example', 1_000, 'fp'); + const replica = fakeRes(); + expect(blockIfReplica(replica.res, 'scan_policies')).toBe(true); + expect(replica.captured.statusCode).toBe(403); + expect(replica.captured.body).toMatchObject({ code: 'REPLICA_READ_ONLY' }); + }); + + it('replaces the prior replicated set wholesale and leaves local rows untouched', () => { + const db = DatabaseService.getInstance(); + // A local policy authored on this instance before it became a replica. + db.createScanPolicy({ + name: 'local-keepme', node_id: null, node_identity: '', stack_pattern: null, + max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0, + }); + + FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('first-gen')], 'https://r.example', 1_000, 'fp'); + expect(db.getScanPolicies().filter((p) => p.replicated_from_control === 1).map((p) => p.name)).toEqual(['first-gen']); + + // Second push with a higher watermark fully replaces the replicated set. + // The transition log must stay silent now that the instance is already a replica. + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('second-gen')], 'https://r.example', 2_000, 'fp'); + expect(infoSpy.mock.calls.filter((c) => String(c[0]).includes('now a replica'))).toHaveLength(0); + infoSpy.mockRestore(); + + const replicated = db.getScanPolicies().filter((p) => p.replicated_from_control === 1); + expect(replicated.map((p) => p.name)).toEqual(['second-gen']); + expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'))).toBe('2000'); + + // The local row survives both pushes. + expect(db.getLocalScanPolicies().map((p) => p.name)).toEqual(['local-keepme']); + }); + + it('installs cve_suppressions through the real DB (multi-resource dispatch)', () => { + const db = DatabaseService.getInstance(); + const rows = [{ + cve_id: 'CVE-2024-0001', pkg_name: 'openssl', image_pattern: null, + reason: 'fixed upstream, not exploitable', created_by: 'control', + created_at: 1, expires_at: null, replicated_from_control: 1, + }]; + FleetSyncService.getInstance().applyIncomingSync('cve_suppressions', rows, 'https://r.example', 1_000, 'fp'); + + expect(FleetSyncService.getRole()).toBe('replica'); + const installed = db.getCveSuppressions(); + expect(installed).toHaveLength(1); + expect(installed[0]).toMatchObject({ cve_id: 'CVE-2024-0001', pkg_name: 'openssl', replicated_from_control: 1 }); + expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'))).toBe('1000'); + }); + + it('rejects a stale push and rolls back so replica state is unchanged', () => { + const db = DatabaseService.getInstance(); + FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('current')], 'https://r.example', 5_000, 'fp'); + + expect(() => { + FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('stale')], 'https://r.example', 4_999, 'fp'); + }).toThrow(StaleSyncPushError); + + // Watermark and rows reflect the accepted push, not the stale one. + expect(db.getSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'))).toBe('5000'); + expect(db.getScanPolicies().map((p) => p.name)).toEqual(['current']); + }); + + it('rejects a push from a different control anchor and rolls back the rows', () => { + const db = DatabaseService.getInstance(); + // First push anchors the replica to control A. + FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('from-A')], 'https://r.example', 1_000, 'control-A'); + expect(db.getSystemState(SYNC_STATE_KEYS.fleetControlIdentity)).toBe('control-A'); + + // A push from control B is rejected; control A's rows remain. + expect(() => { + FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('from-B')], 'https://r.example', 2_000, 'control-B'); + }).toThrow(ControlIdentityMismatchError); + + expect(db.getSystemState(SYNC_STATE_KEYS.fleetControlIdentity)).toBe('control-A'); + expect(db.getScanPolicies().map((p) => p.name)).toEqual(['from-A']); + }); +}); diff --git a/backend/src/services/FleetSyncService.ts b/backend/src/services/FleetSyncService.ts index 32cd8506..9a961e33 100644 --- a/backend/src/services/FleetSyncService.ts +++ b/backend/src/services/FleetSyncService.ts @@ -4,6 +4,7 @@ import { CveSuppression, DatabaseService, MisconfigAcknowledgement, Node, ScanPo import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import { isDebugEnabled } from '../utils/debug'; +import { sanitizeForLog } from '../utils/safeLog'; import { FleetResource, MAX_SYNC_ROWS, @@ -294,6 +295,7 @@ export class FleetSyncService { controlIdentity?: string, ): void { const db = DatabaseService.getInstance(); + let promotedToReplica = false; db.transaction(() => { if (controlIdentity && controlIdentity.length > 0) { // The cached fingerprint has three possible states: @@ -319,6 +321,7 @@ export class FleetSyncService { } db.setSystemState(watermarkKey, String(pushedAt)); } + promotedToReplica = db.getSystemState(SYNC_STATE_KEYS.fleetRole) !== 'replica'; db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'replica'); if (targetIdentity) { const cachedSelf = db.getSystemState(SYNC_STATE_KEYS.fleetSelfIdentity); @@ -364,6 +367,14 @@ export class FleetSyncService { summary: `Replicated ${resource} from ${controlIdentity || 'legacy control'}: replaced ${rows.length} row(s)`, }); }); + // Log the control -> replica transition once, only when it actually + // flips. The transaction sets fleet_role on every apply, so guarding on + // the prior role keeps this off the steady-state per-push path. + if (promotedToReplica) { + console.info( + `[FleetSync] Instance is now a replica, anchored to control ${sanitizeForLog(controlIdentity || 'legacy control')}.`, + ); + } } /** diff --git a/frontend/src/hooks/__tests__/useFleetSyncStatus.test.tsx b/frontend/src/hooks/__tests__/useFleetSyncStatus.test.tsx new file mode 100644 index 00000000..93e52d91 --- /dev/null +++ b/frontend/src/hooks/__tests__/useFleetSyncStatus.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +const fetchStatusesMock = vi.fn(); +let mockIsPaid = false; +let mockIsAdmin = false; + +vi.mock('@/lib/fleetSyncApi', () => ({ + fetchFleetSyncStatuses: (...args: unknown[]) => fetchStatusesMock(...args), +})); +vi.mock('@/context/LicenseContext', () => ({ + useLicense: () => ({ isPaid: mockIsPaid }), +})); +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ isAdmin: mockIsAdmin }), +})); +// Run the interval callback synchronously so we can assert it is (not) armed +// without faking timers. +vi.mock('@/lib/utils', () => ({ + visibilityInterval: (fn: () => void) => { fn(); return () => undefined; }, +})); + +import { useFleetSyncStatus } from '../useFleetSyncStatus'; + +beforeEach(() => { + fetchStatusesMock.mockReset().mockResolvedValue([{ node_id: 1, resource: 'scan_policies' }]); + mockIsPaid = false; + mockIsAdmin = false; +}); +afterEach(() => vi.clearAllMocks()); + +describe('useFleetSyncStatus gate parity', () => { + it('fetches for a paid admin (mirrors requireAdmin + requirePaid)', async () => { + mockIsPaid = true; + mockIsAdmin = true; + const { result } = renderHook(() => useFleetSyncStatus()); + + await waitFor(() => expect(result.current.statuses).toHaveLength(1)); + expect(fetchStatusesMock).toHaveBeenCalled(); + expect(result.current.loading).toBe(false); + }); + + it('does not fetch for a paid non-admin (the 403-loop this fix prevents)', async () => { + mockIsPaid = true; + mockIsAdmin = false; + const { result } = renderHook(() => useFleetSyncStatus()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetchStatusesMock).not.toHaveBeenCalled(); + expect(result.current.statuses).toEqual([]); + }); + + it('does not fetch for an admin on a community license', async () => { + mockIsPaid = false; + mockIsAdmin = true; + const { result } = renderHook(() => useFleetSyncStatus()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetchStatusesMock).not.toHaveBeenCalled(); + expect(result.current.statuses).toEqual([]); + }); + + it('does not fetch when neither paid nor admin', async () => { + const { result } = renderHook(() => useFleetSyncStatus()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetchStatusesMock).not.toHaveBeenCalled(); + }); + + it('ignores rows from a fetch that resolves after the gate flips ineligible', async () => { + let resolveFetch!: (rows: Array<{ node_id: number; resource: string }>) => void; + const pending = new Promise>((res) => { resolveFetch = res; }); + fetchStatusesMock.mockReturnValue(pending); + mockIsPaid = true; + mockIsAdmin = true; + const { result, rerender } = renderHook(() => useFleetSyncStatus()); + expect(fetchStatusesMock).toHaveBeenCalled(); + expect(result.current.statuses).toEqual([]); // in-flight, not resolved yet + + // Lose admin before the in-flight fetch resolves. + mockIsAdmin = false; + act(() => { rerender(); }); + expect(result.current.statuses).toEqual([]); + + // The late resolve must not republish rows to the now-ineligible client. + await act(async () => { + resolveFetch([{ node_id: 9, resource: 'scan_policies' }]); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.statuses).toEqual([]); + }); + + it('starts fetching once the user becomes a paid admin', async () => { + const { result, rerender } = renderHook(() => useFleetSyncStatus()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetchStatusesMock).not.toHaveBeenCalled(); + + mockIsPaid = true; + mockIsAdmin = true; + act(() => { rerender(); }); + + await waitFor(() => expect(fetchStatusesMock).toHaveBeenCalled()); + await waitFor(() => expect(result.current.statuses).toHaveLength(1)); + }); +}); diff --git a/frontend/src/hooks/useFleetSyncStatus.ts b/frontend/src/hooks/useFleetSyncStatus.ts index e6835b93..719bf7ac 100644 --- a/frontend/src/hooks/useFleetSyncStatus.ts +++ b/frontend/src/hooks/useFleetSyncStatus.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useLicense } from '@/context/LicenseContext'; +import { useAuth } from '@/context/AuthContext'; import { visibilityInterval } from '@/lib/utils'; import { fetchFleetSyncStatuses, type FleetSyncStatus } from '@/lib/fleetSyncApi'; @@ -7,9 +8,10 @@ const REFRESH_INTERVAL_MS = 30_000; /** * Polls `/api/fleet/sync-status` and exposes the rows plus a manual - * `refresh()`. Skips fetching for community-tier users since the endpoint is - * paid-tier-gated; the hook returns an empty array in that case so consumers - * can render the `!isPaid` branch without conditionals. + * `refresh()`. The endpoint requires a paid admin, so the hook only fetches + * for paid admins and returns an empty array otherwise. This mirrors the + * route guard exactly: gating on tier alone would leave a paid non-admin + * polling an endpoint that always 403s. */ export function useFleetSyncStatus(): { statuses: FleetSyncStatus[]; @@ -17,29 +19,37 @@ export function useFleetSyncStatus(): { refresh: () => void; } { const { isPaid } = useLicense(); + const { isAdmin } = useAuth(); + const canQuery = isPaid && isAdmin; + // Reflects the latest gate state so a fetch begun while eligible cannot + // publish its rows after the user loses eligibility mid-flight (role or + // tier change). Without this, a late resolve would re-populate statuses + // that the gate-false path already cleared. + const canQueryRef = useRef(canQuery); + canQueryRef.current = canQuery; const [statuses, setStatuses] = useState([]); const [loading, setLoading] = useState(true); const refresh = useCallback(() => { - if (!isPaid) { + if (!canQuery) { setStatuses([]); setLoading(false); return; } fetchFleetSyncStatuses() - .then((rows) => setStatuses(rows)) + .then((rows) => { if (canQueryRef.current) setStatuses(rows); }) .catch((err) => { // Stale data stays visible to avoid flicker; log so the failure isn't completely silent. console.warn('[FleetSync] sync-status fetch failed:', err); }) - .finally(() => setLoading(false)); - }, [isPaid]); + .finally(() => { if (canQueryRef.current) setLoading(false); }); + }, [canQuery]); useEffect(() => { refresh(); - if (!isPaid) return undefined; + if (!canQuery) return undefined; return visibilityInterval(refresh, REFRESH_INTERVAL_MS); - }, [isPaid, refresh]); + }, [canQuery, refresh]); return { statuses, loading, refresh }; }