mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat(fleet): replicate scan policies across managed nodes (#649)
Scan policies now propagate from the control Sencho instance to every registered remote. The control is the source of truth; replicas render rules read-only with a managed-by-control banner. Pushes fire on every policy write, record per-node success and failure on a new fleet_sync_status table, and use node_proxy Bearer tokens exclusively so only sibling Senchos can apply incoming sync payloads. Policy scope now travels as a string identity (api_url or a local sentinel) so node-scoped rules evaluate correctly on each target.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Route-level tests for fleet sync endpoints introduced by PR 3.
|
||||
* Focus: auth gating on /api/fleet/sync/:resource (node_proxy only) and
|
||||
* payload validation. Service-level behavior is covered separately in
|
||||
* fleet-sync-service.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let adminAuthHeader: string;
|
||||
let nodeProxyAuthHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
const adminToken = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
adminAuthHeader = `Bearer ${adminToken}`;
|
||||
const proxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
nodeProxyAuthHeader = `Bearer ${proxyToken}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/role', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).get('/api/fleet/role');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns the current role for an authenticated admin', async () => {
|
||||
const res = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ role: 'control' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/sync/:resource auth gate', () => {
|
||||
const validRow = {
|
||||
name: 'from-control',
|
||||
node_identity: '',
|
||||
stack_pattern: null,
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
};
|
||||
|
||||
it('rejects unauthenticated callers with 401', async () => {
|
||||
const res = await request(app).post('/api/fleet/sync/scan_policies').send({ rows: [validRow] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects admin user session tokens with 403 NODE_PROXY_REQUIRED', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.send({ rows: [validRow] });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('NODE_PROXY_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects unknown resources with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/foo')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects payloads without a rows array', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects rows with invalid max_severity', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [{ ...validRow, max_severity: 'GIGA' }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/max_severity/);
|
||||
});
|
||||
|
||||
it('rejects rows with non-flag enabled value', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [{ ...validRow, enabled: 2 }] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects payloads exceeding the row cap', async () => {
|
||||
const bigRows = Array.from({ length: 5001 }, () => validRow);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: bigRows });
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/sync-status', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).get('/api/fleet/sync-status');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 PAID_REQUIRED on community tier', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns an empty list for an admin on paid tier', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const res = await request(app).get('/api/fleet/sync-status').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unit tests for FleetSyncService: the service that replicates security
|
||||
* configuration from a control Sencho instance to every registered remote.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetNodes,
|
||||
mockGetNode,
|
||||
mockGetScanPolicies,
|
||||
mockReplaceReplicatedScanPolicies,
|
||||
mockRecordFleetSyncSuccess,
|
||||
mockRecordFleetSyncFailure,
|
||||
mockGetSystemState,
|
||||
mockSetSystemState,
|
||||
mockAxiosPost,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetNodes: vi.fn().mockReturnValue([]),
|
||||
mockGetNode: vi.fn(),
|
||||
mockGetScanPolicies: vi.fn().mockReturnValue([]),
|
||||
mockReplaceReplicatedScanPolicies: vi.fn(),
|
||||
mockRecordFleetSyncSuccess: vi.fn(),
|
||||
mockRecordFleetSyncFailure: vi.fn(),
|
||||
mockGetSystemState: vi.fn().mockReturnValue(null),
|
||||
mockSetSystemState: vi.fn(),
|
||||
mockAxiosPost: vi.fn().mockResolvedValue({ data: { success: true } }),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
getScanPolicies: mockGetScanPolicies,
|
||||
replaceReplicatedScanPolicies: mockReplaceReplicatedScanPolicies,
|
||||
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
||||
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
||||
getSystemState: mockGetSystemState,
|
||||
setSystemState: mockSetSystemState,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getNode: mockGetNode,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: { post: mockAxiosPost },
|
||||
AxiosError: class AxiosError extends Error {
|
||||
response?: { status: number; statusText: string; data: unknown };
|
||||
},
|
||||
}));
|
||||
|
||||
import { FleetSyncService, LOCAL_IDENTITY_SENTINEL } from '../services/FleetSyncService';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetSystemState.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe('FleetSyncService.getRole', () => {
|
||||
it('returns control when no fleet_role is set in system_state', () => {
|
||||
mockGetSystemState.mockReturnValue(null);
|
||||
expect(FleetSyncService.getRole()).toBe('control');
|
||||
});
|
||||
|
||||
it('returns replica when fleet_role system_state is "replica"', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => (key === 'fleet_role' ? 'replica' : null));
|
||||
expect(FleetSyncService.getRole()).toBe('replica');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.getSelfIdentity', () => {
|
||||
it('returns LOCAL_IDENTITY_SENTINEL on control nodes', () => {
|
||||
mockGetSystemState.mockReturnValue(null);
|
||||
expect(FleetSyncService.getSelfIdentity()).toBe(LOCAL_IDENTITY_SENTINEL);
|
||||
});
|
||||
|
||||
it('returns the cached target identity on replicas', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_role') return 'replica';
|
||||
if (key === 'fleet_self_identity') return 'https://sencho.example.com';
|
||||
return null;
|
||||
});
|
||||
expect(FleetSyncService.getSelfIdentity()).toBe('https://sencho.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.resolveIdentityForNodeId', () => {
|
||||
it('returns empty string when nodeId is null (fleet-wide policy)', () => {
|
||||
expect(FleetSyncService.resolveIdentityForNodeId(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns LOCAL_IDENTITY_SENTINEL when the node is local', () => {
|
||||
mockGetNode.mockReturnValue({ id: 1, type: 'local', api_url: '', api_token: '' });
|
||||
expect(FleetSyncService.resolveIdentityForNodeId(1)).toBe(LOCAL_IDENTITY_SENTINEL);
|
||||
});
|
||||
|
||||
it('returns the remote api_url for remote nodes', () => {
|
||||
mockGetNode.mockReturnValue({ id: 2, type: 'remote', api_url: 'https://remote.example.com', api_token: 'tok' });
|
||||
expect(FleetSyncService.resolveIdentityForNodeId(2)).toBe('https://remote.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.pushResource', () => {
|
||||
it('does not push when this instance is a replica', async () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => (key === 'fleet_role' ? 'replica' : null));
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
expect(mockAxiosPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips nodes without api_url or api_token', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 1, type: 'local', api_url: '', api_token: '' },
|
||||
{ id: 2, type: 'remote', api_url: '', api_token: 'tok' },
|
||||
{ id: 3, type: 'remote', api_url: 'https://good.example', api_token: '' },
|
||||
]);
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
expect(mockAxiosPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pushes to every configured remote with local rows only', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://a.example', api_token: 'tokA', name: 'A' },
|
||||
{ id: 3, type: 'remote', api_url: 'https://b.example', api_token: 'tokB', name: 'B' },
|
||||
]);
|
||||
mockGetScanPolicies.mockReturnValue([
|
||||
{ id: 1, name: 'local-1', node_identity: '', replicated_from_control: 0, created_at: 1, updated_at: 1 },
|
||||
{ id: 2, name: 'mirrored', node_identity: 'https://somewhere', replicated_from_control: 1, created_at: 1, updated_at: 1 },
|
||||
]);
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
|
||||
expect(mockAxiosPost).toHaveBeenCalledTimes(2);
|
||||
const firstCall = mockAxiosPost.mock.calls[0];
|
||||
expect(firstCall[0]).toBe('https://a.example/api/fleet/sync/scan_policies');
|
||||
expect(firstCall[1].rows).toHaveLength(1);
|
||||
expect(firstCall[1].rows[0].name).toBe('local-1');
|
||||
expect(firstCall[1].targetIdentity).toBe('https://a.example');
|
||||
expect(firstCall[2].headers.Authorization).toBe('Bearer tokA');
|
||||
expect(mockRecordFleetSyncSuccess).toHaveBeenCalledWith(2, 'scan_policies');
|
||||
expect(mockRecordFleetSyncSuccess).toHaveBeenCalledWith(3, 'scan_policies');
|
||||
});
|
||||
|
||||
it('records per-node failure without throwing when one remote errors', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://fail.example', api_token: 'tok', name: 'fail' },
|
||||
{ id: 3, type: 'remote', api_url: 'https://ok.example', api_token: 'tok2', name: 'ok' },
|
||||
]);
|
||||
mockAxiosPost.mockImplementation((url: string) => {
|
||||
if (url.includes('fail.example')) return Promise.reject(new Error('network error'));
|
||||
return Promise.resolve({ data: { success: true } });
|
||||
});
|
||||
await expect(FleetSyncService.getInstance().pushResource('scan_policies')).resolves.not.toThrow();
|
||||
expect(mockRecordFleetSyncFailure).toHaveBeenCalledWith(2, 'scan_policies', expect.stringContaining('network error'));
|
||||
expect(mockRecordFleetSyncSuccess).toHaveBeenCalledWith(3, 'scan_policies');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.applyIncomingSync', () => {
|
||||
it('promotes this instance to replica and caches target identity', () => {
|
||||
const rows = [{
|
||||
id: 0, name: 'from-control', node_id: null, node_identity: '',
|
||||
stack_pattern: null, max_severity: 'CRITICAL' as const,
|
||||
block_on_deploy: 0, enabled: 1, replicated_from_control: 1,
|
||||
created_at: 1, updated_at: 1,
|
||||
}];
|
||||
FleetSyncService.getInstance().applyIncomingSync('scan_policies', rows, 'https://me.example');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_role', 'replica');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_self_identity', 'https://me.example');
|
||||
expect(mockReplaceReplicatedScanPolicies).toHaveBeenCalledWith(rows);
|
||||
});
|
||||
|
||||
it('skips identity caching when targetIdentity is empty', () => {
|
||||
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [], '');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_role', 'replica');
|
||||
expect(mockSetSystemState).not.toHaveBeenCalledWith('fleet_self_identity', expect.anything());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user