mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
This commit is contained in:
@@ -157,34 +157,7 @@ describe('GET /api/fleet/overview', () => {
|
||||
describe('Fleet tier gating', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('GET /api/fleet/update-status returns 403 on free tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/update-status')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots returns 403 on free tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/snapshots')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/fleet/nodes/1/update returns 403 on free tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/nodes/1/update')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/fleet/update-all returns 403 on free tier', async () => {
|
||||
it('POST /api/fleet/update-all returns 403 on community tier (bulk update is Skipper+)', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/update-all')
|
||||
@@ -193,22 +166,44 @@ describe('Fleet tier gating', () => {
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('DELETE /api/fleet/nodes/1/update-status returns 403 on free tier', async () => {
|
||||
it('GET /api/fleet/update-status is accessible on community tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/update-status')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots is accessible on community tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/snapshots')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/fleet/nodes/1/update is accessible on community tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/nodes/1/update')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('DELETE /api/fleet/nodes/1/update-status is accessible on community tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.delete('/api/fleet/nodes/1/update-status')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('DELETE /api/fleet/update-status returns 403 on free tier', async () => {
|
||||
it('DELETE /api/fleet/update-status is accessible on community tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.delete('/api/fleet/update-status')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,14 +379,13 @@ describe('Fleet snapshot restore', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/snapshots/:id/restore returns 403 on free tier', async () => {
|
||||
it('POST /api/fleet/snapshots/:id/restore is accessible on community tier', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/snapshots/${snapshotId}/restore`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ nodeId: 1, stackName: 'test' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns 400 with missing nodeId/stackName', async () => {
|
||||
|
||||
@@ -116,7 +116,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('GET /api/security/compare', () => {
|
||||
it('returns 403 for community tier', async () => {
|
||||
it('is accessible on community tier', async () => {
|
||||
tierSpy.mockReturnValue('community');
|
||||
const a = seedScan();
|
||||
const b = seedScan({ scannedAt: Date.now() + 1000 });
|
||||
@@ -125,8 +125,7 @@ describe('GET /api/security/compare', () => {
|
||||
.get(`/api/security/compare?scanId1=${a}&scanId2=${b}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns 400 for non-finite scanId params', async () => {
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('SchedulerService - license gating', () => {
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-update tasks for non-admiral pro', async () => {
|
||||
it('skips non-update/scan/snapshot tasks for non-admiral pro', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
@@ -292,6 +292,18 @@ describe('SchedulerService - license gating', () => {
|
||||
expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows snapshot tasks for non-admiral pro (Skipper)', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'snapshot', target_type: 'fleet' })]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await (svc as any).tick();
|
||||
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows all actions for admiral (pro + team)', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('admiral');
|
||||
|
||||
@@ -56,11 +56,11 @@ describe('GET /api/security/suppressions', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('requires paid tier', async () => {
|
||||
it('is accessible on community tier', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns an empty list when no suppressions exist', async () => {
|
||||
@@ -123,14 +123,14 @@ describe('POST /api/security/suppressions', () => {
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects community tier with 403', async () => {
|
||||
it('is accessible on community tier (admin still required)', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
const res = await request(app)
|
||||
.post('/api/security/suppressions')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.send(validBody);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects writes on replicas with 403', async () => {
|
||||
|
||||
Reference in New Issue
Block a user