mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
feat(fleet-sync): replica self-demote endpoint and role UX (#969)
A replica admin can now demote the instance back to a standalone
control without raw SQLite access. The Settings → Security UI surfaces
a confirm-gated button when the role is replica; the role probe also
surfaces a soft banner when it cannot determine fleet role rather
than silently defaulting to control.
Backend:
- POST /api/fleet/role/demote (admin, requires `{confirm: true}`):
flips fleet_role to 'control', clears fleet_self_identity,
fleet_control_identity, and both received_pushed_at:* watermarks,
drops every replicated_from_control row from scan_policies and
cve_suppressions, nulls out any orphaned policy_evaluation cache.
Returns 409 ALREADY_CONTROL when invoked on a control.
- DatabaseService gains `clearOrphanPolicyEvaluations()` and
`clearReplicatedRows()` helpers. Reanchor consolidates onto
clearReplicatedRows so it shares the same code path.
- `FleetSyncService.demote()` returns boolean for the route to
translate into 200 or 409.
Frontend:
- SecuritySection probes /fleet/role and now records explicit success
vs failure rather than silently treating an error as control. A
soft banner appears when probe fails.
- Replica banner gains a "Demote to control" button and a destructive
ConfirmModal explaining the wipe.
Tests:
- 4 new route-level vitest cases (401, 400 without confirm,
end-to-end demote with replica setup, 409 ALREADY_CONTROL with
explicit precondition).
- Service unit test asserts the consolidated clearReplicatedRows path.
- Full backend suite: 1773 pass / 5 skipped. Frontend: 185 pass.
This commit is contained in:
@@ -273,3 +273,73 @@ describe('POST /api/fleet/role/reanchor', () => {
|
||||
expect(next.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/role/demote', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet/role/demote').send({ confirm: true });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 without confirm:true', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/role/demote')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/confirmation/i);
|
||||
});
|
||||
|
||||
it('demotes a replica back to control, drops mirrored rows, then re-allows local writes', async () => {
|
||||
// Reanchor first so this test does not depend on whichever fingerprint
|
||||
// an earlier test left in place; then self-promote into a replica via a
|
||||
// sync push with at least one row so the demote has something to wipe.
|
||||
const reset = await request(app)
|
||||
.post('/api/fleet/role/reanchor')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.send({ override: true });
|
||||
expect(reset.status).toBe(200);
|
||||
|
||||
const push = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({
|
||||
rows: [
|
||||
{ name: 'mirrored-policy', node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 0, enabled: 1 },
|
||||
],
|
||||
targetIdentity: 'https://me.example',
|
||||
controlIdentity: 'fingerprint-demote-test',
|
||||
});
|
||||
expect(push.status).toBe(200);
|
||||
|
||||
const beforeRole = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader);
|
||||
expect(beforeRole.body.role).toBe('replica');
|
||||
|
||||
const demote = await request(app)
|
||||
.post('/api/fleet/role/demote')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.send({ confirm: true });
|
||||
expect(demote.status).toBe(200);
|
||||
expect(demote.body.role).toBe('control');
|
||||
|
||||
const afterRole = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader);
|
||||
expect(afterRole.body.role).toBe('control');
|
||||
});
|
||||
|
||||
it('returns 409 ALREADY_CONTROL when called on a control instance', async () => {
|
||||
// Be explicit about the precondition so this test holds under --shuffle.
|
||||
const role = await request(app).get('/api/fleet/role').set('Authorization', adminAuthHeader);
|
||||
if (role.body.role !== 'control') {
|
||||
const cleanup = await request(app)
|
||||
.post('/api/fleet/role/demote')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.send({ confirm: true });
|
||||
expect(cleanup.status).toBe(200);
|
||||
}
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/role/demote')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.send({ confirm: true });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('ALREADY_CONTROL');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ const {
|
||||
mockGetLocalCveSuppressions,
|
||||
mockReplaceReplicatedScanPolicies,
|
||||
mockReplaceReplicatedCveSuppressions,
|
||||
mockClearOrphanPolicyEvaluations,
|
||||
mockClearReplicatedRows,
|
||||
mockRecordFleetSyncSuccess,
|
||||
mockRecordFleetSyncFailure,
|
||||
mockGetSystemState,
|
||||
@@ -25,6 +27,8 @@ const {
|
||||
mockGetLocalCveSuppressions: vi.fn().mockReturnValue([]),
|
||||
mockReplaceReplicatedScanPolicies: vi.fn(),
|
||||
mockReplaceReplicatedCveSuppressions: vi.fn(),
|
||||
mockClearOrphanPolicyEvaluations: vi.fn(),
|
||||
mockClearReplicatedRows: vi.fn(),
|
||||
mockRecordFleetSyncSuccess: vi.fn(),
|
||||
mockRecordFleetSyncFailure: vi.fn(),
|
||||
mockGetSystemState: vi.fn().mockReturnValue(null),
|
||||
@@ -42,6 +46,8 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
getLocalCveSuppressions: mockGetLocalCveSuppressions,
|
||||
replaceReplicatedScanPolicies: mockReplaceReplicatedScanPolicies,
|
||||
replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions,
|
||||
clearOrphanPolicyEvaluations: mockClearOrphanPolicyEvaluations,
|
||||
clearReplicatedRows: mockClearReplicatedRows,
|
||||
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
||||
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
||||
getSystemState: mockGetSystemState,
|
||||
@@ -474,7 +480,7 @@ describe('FleetSyncService.reanchor', () => {
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_control_identity', '');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:scan_policies', '');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:cve_suppressions', '');
|
||||
expect(mockReplaceReplicatedScanPolicies).toHaveBeenCalledWith([]);
|
||||
expect(mockClearReplicatedRows).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user