feat(fleet): open Fleet Actions tab to Community (admin-only) (#1153)

* feat(fleet): open Fleet Actions tab to Community (admin-only)

Removes the requirePaid guard from the five Fleet Actions endpoints
(fleet-stop, fleet-prune, match-preview, prune/estimate, bulk-assign)
and drops the matching isPaid parent gate on FleetActionsTab so Community
admins can run fleet-wide bulk operations. requireAdmin stays on every
endpoint; operator and viewer roles still 403 on apply.

Tests flipped from "403 PAID_REQUIRED on community" to positive
"reachable on community + admin" assertions. Docs (fleet-actions,
fleet-view, licensing, overview, stack-labels) rewritten to state the
admin-role requirement once and drop the prior Skipper framing.

* fix(fleet): apply audit findings from PR #1153 review

- stack-labels.mdx: fix the page intro that still framed fleet label
  actions as "Operators on a Skipper or Admiral license". The cards are
  now Community + admin, so the intro reads "Admins also get a pair of
  fleet-wide actions".
- Collapse redundant role-rule statements on the two affected pages.
  fleet-actions.mdx now states the admin gate once in the lead-in Note
  and again only in the troubleshooting accordion (the Prerequisites
  row was duplicative). stack-labels.mdx trims the "Limits and rules"
  bullet to the value-add half (label authoring is open to every role)
  and drops the Fleet Actions repetition.
- Strip now-no-op mockTier('paid') calls from non-tier tests across the
  three fleet test files, plus the test-wide default in the
  fleet-action-card-endpoints beforeEach. Those mocks were misleading
  after the routes stopped consulting tier; if a future change re-adds
  requirePaid the tests will fail loudly instead of silently passing.
This commit is contained in:
Anso
2026-05-22 01:27:27 -04:00
committed by GitHub
parent 2f2401df68
commit 519a59ed2e
11 changed files with 46 additions and 86 deletions
@@ -75,7 +75,6 @@ beforeEach(() => {
// not polluted by earlier tests.
vi.restoreAllMocks();
vi.clearAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
mockFsStacks = ['alpha', 'beta'];
pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 0 });
pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 });
@@ -115,14 +114,15 @@ describe('POST /api/fleet/labels/match-preview', () => {
expect(res.status).toBe(401);
});
it('returns 403 PAID_REQUIRED on community tier', async () => {
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app)
.post('/api/fleet/labels/match-preview')
.set('Authorization', authHeader)
.send({ labelName: 'x' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
.send({ labelName: 'does-not-exist' });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.matchedNodes).toBe(0);
});
it('returns 400 when labelName is missing or empty', async () => {
@@ -171,13 +171,15 @@ describe('POST /api/fleet/prune/estimate', () => {
expect(res.status).toBe(401);
});
it('returns 403 PAID_REQUIRED on community tier', async () => {
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app)
.post('/api/fleet/prune/estimate')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(403);
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body).toHaveProperty('totalBytes');
});
it('returns 400 when targets is empty', async () => {
+10 -15
View File
@@ -38,27 +38,29 @@ describe('Fleet Actions endpoints require authentication', () => {
});
});
describe('Fleet Actions tier gating', () => {
describe('Fleet Actions tier gating (Community + admin)', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet/labels/fleet-stop returns 403 on community tier (Skipper+)', async () => {
it('POST /api/fleet/labels/fleet-stop is reachable on community tier for admins', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: 'prod' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
.send({ labelName: 'this-label-does-not-exist' });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(Array.isArray(res.body.results)).toBe(true);
});
it('POST /api/fleet-actions/labels/bulk-assign returns 403 on community tier (Skipper+)', async () => {
it('POST /api/fleet-actions/labels/bulk-assign is reachable on community tier for admins', async () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
.send({ assignments: [] });
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');
expect(res.body.results).toEqual([]);
});
});
@@ -66,7 +68,6 @@ describe('Fleet Actions input validation', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet/labels/fleet-stop rejects missing labelName', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
@@ -76,7 +77,6 @@ describe('Fleet Actions input validation', () => {
});
it('POST /api/fleet/labels/fleet-stop rejects whitespace-only labelName', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
@@ -85,7 +85,6 @@ describe('Fleet Actions input validation', () => {
});
it('POST /api/fleet-actions/labels/bulk-assign rejects non-array assignments', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
@@ -95,7 +94,6 @@ describe('Fleet Actions input validation', () => {
});
it('POST /api/fleet-actions/labels/bulk-assign rejects oversized payload', async () => {
mockTier('paid');
const big = Array.from({ length: 1001 }, (_, i) => ({ stackName: `s${i}`, labelIds: [] }));
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
@@ -110,7 +108,6 @@ describe('Fleet Actions orchestration shape', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet/labels/fleet-stop with unknown label returns matched:false per node', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
@@ -124,7 +121,6 @@ describe('Fleet Actions orchestration shape', () => {
});
it('POST /api/fleet-actions/labels/bulk-assign accepts empty assignments and returns empty results', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
@@ -134,7 +130,6 @@ describe('Fleet Actions orchestration shape', () => {
});
it('POST /api/fleet-actions/labels/bulk-assign rejects an entry with bad stack name in-line', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet-actions/labels/bulk-assign')
.set('Authorization', authHeader)
+5 -12
View File
@@ -66,18 +66,19 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
expect(res.status).toBe(401);
});
it('returns 403 PAID_REQUIRED on community tier', async () => {
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
mockTier('community');
mockLocalPrune({ managedBytes: { images: 128 } });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
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');
expect(Array.isArray(res.body.results)).toBe(true);
});
it('returns 400 when body is missing', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
@@ -86,7 +87,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('returns 400 when targets is empty', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
@@ -96,7 +96,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('returns 400 when a target is unrecognized', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
@@ -106,7 +105,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('runs pruneManagedOnly per target on the local node and returns aggregated bytes', async () => {
mockTier('paid');
const fake = mockLocalPrune({ managedBytes: { images: 1500, volumes: 320 } });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
@@ -125,7 +123,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('runs pruneSystem when scope is "all" and dedupes targets', async () => {
mockTier('paid');
const fake = mockLocalPrune({ allBytes: { networks: 0, images: 2048 } });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
@@ -139,7 +136,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('records per-target failure when DockerController throws but continues remaining targets', async () => {
mockTier('paid');
mockLocalPrune({ managedBytes: { images: 100 }, throwOn: 'volumes' });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
@@ -155,7 +151,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('reports lock contention when bulk-prune lock is already held', async () => {
mockTier('paid');
mockLocalPrune();
const db = DatabaseService.getInstance();
const localId = db.getNodes().find(n => n.type === 'local')!.id;
@@ -171,7 +166,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('marks a remote node unreachable when fetch throws and short-circuits later targets', async () => {
mockTier('paid');
mockLocalPrune();
const db = DatabaseService.getInstance();
const remoteId = db.addNode({
@@ -202,7 +196,6 @@ describe('POST /api/fleet/labels/fleet-prune', () => {
});
it('parses remote node responses into per-target reclaimed bytes', async () => {
mockTier('paid');
mockLocalPrune();
const db = DatabaseService.getInstance();
const remoteId = db.addNode({
+2 -6
View File
@@ -1028,9 +1028,8 @@ fleetRouter.delete('/update-status', authMiddleware, async (req: Request, res: R
// Fleet-wide stop by label name. Matches each node's labels by name and runs
// container stops on each matching stack.
// Tier: requirePaid + requireAdmin.
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const body = req.body as { labelName?: unknown; dryRun?: unknown } | undefined;
if (!body || typeof body !== 'object') {
@@ -1142,12 +1141,11 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
// systemMaintenance.ts is safe because Docker's prune API is internally
// serialized and idempotent (the worst case is a duplicate call returning 0
// reclaimed bytes).
// Tier: requirePaid + requireAdmin.
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const;
type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number];
fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined;
@@ -1295,7 +1293,6 @@ fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res
// assignments live in the central DB even for remote nodes, populated by the
// nodes' own UIs and synced via Distributed API.
fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const body = req.body as { labelName?: unknown } | undefined;
if (!body || typeof body !== 'object') {
@@ -1336,7 +1333,6 @@ fleetRouter.post('/labels/match-preview', authMiddleware, async (req: Request, r
// fan-out shape as `/labels/fleet-prune` minus the locks (estimation is read
// only).
fleetRouter.post('/prune/estimate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const body = req.body as { targets?: unknown; scope?: unknown } | undefined;
+5 -5
View File
@@ -1,7 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { DatabaseService } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
import { requireAdmin, requireBody } from '../middleware/tierGates';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
@@ -20,14 +20,14 @@ const MAX_ASSIGNMENTS = 1000;
// Bulk label assignment for many stacks on a single node. The single-stack
// endpoint at `PUT /api/stacks/:stackName/labels` covers one stack at a time;
// this wrapper applies the same operation to many stacks atomically per HTTP
// request. Tier: requirePaid + requireAdmin. The per-stack endpoint is
// Community-tier organization metadata; this multi-stack wrapper is an
// automation surface exposed only inside the Skipper+ Fleet Actions tab.
// request. Tier: requireAdmin (admin-only fleet plumbing). The per-stack
// endpoint is Community-tier organization metadata; this multi-stack wrapper
// matches the surrounding Fleet Actions surface, which is admin-only but
// available on every license.
fleetActionsRouter.post(
'/labels/bulk-assign',
authMiddleware,
async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const { assignments } = req.body as { assignments?: unknown };