diff --git a/backend/src/__tests__/fleet-sync-routes.test.ts b/backend/src/__tests__/fleet-sync-routes.test.ts index c86b2a21..99a2fc32 100644 --- a/backend/src/__tests__/fleet-sync-routes.test.ts +++ b/backend/src/__tests__/fleet-sync-routes.test.ts @@ -115,21 +115,23 @@ describe('GET /api/fleet/sync-status', () => { expect(res.status).toBe(401); }); - it('returns 403 PAID_REQUIRED on community tier', async () => { + it('returns status rows for a Community admin (no paid gate)', 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(); }); + + it('returns 403 ADMIN_REQUIRED for a non-admin viewer', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const db = DatabaseService.getInstance(); + db.addUser({ username: 'sync-status-viewer', password_hash: 'x', role: 'viewer' }); + const viewerAuth = `Bearer ${jwt.sign({ username: 'sync-status-viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; + const res = await request(app).get('/api/fleet/sync-status').set('Authorization', viewerAuth); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + }); }); describe('POST /api/fleet/sync/:resource pushedAt protocol', () => { diff --git a/backend/src/__tests__/nodes-fleet-sync-reset-anchor.test.ts b/backend/src/__tests__/nodes-fleet-sync-reset-anchor.test.ts index b0934853..d948c071 100644 --- a/backend/src/__tests__/nodes-fleet-sync-reset-anchor.test.ts +++ b/backend/src/__tests__/nodes-fleet-sync-reset-anchor.test.ts @@ -1,5 +1,5 @@ /** - * Tests for POST /api/nodes/:id/fleet-sync/reset-anchor (F-16 fix). + * Tests for POST /api/nodes/:id/fleet-sync/reset-anchor. * * The endpoint proxies the peer's reanchor endpoint and clears every * sticky-error row for the node on success. Covers: @@ -7,7 +7,8 @@ * - peer 401/403 → 502 with helpful message. * - peer unreachable → 504. * - missing/non-proxy node → 400. - * - non-paid tier → 403. + * - Community admin success (no paid gate). + * - non-admin viewer → 403. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -54,14 +55,9 @@ afterAll(() => { cleanupTestDb(tmpDir); }); -beforeEach(async () => { +beforeEach(() => { vi.restoreAllMocks(); globalThis.fetch = originalFetch; - // Re-establish the paid-tier spy after restoreAllMocks. Individual tests - // can override with `mockReturnValue('community')` to exercise the tier - // gate's deny path. - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); }); describe('POST /api/nodes/:id/fleet-sync/reset-anchor', () => { @@ -144,15 +140,66 @@ describe('POST /api/nodes/:id/fleet-sync/reset-anchor', () => { expect(res.status).toBe(404); }); - it('returns 403 (PAID_REQUIRED) when the license is community-tier', async () => { + it('succeeds for a Community admin', async () => { const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + const { DatabaseService } = await import('../services/DatabaseService'); + DatabaseService.getInstance().setFleetSyncSticky( + peerNodeId, 'scan_policies', 'CONTROL_IDENTITY_MISMATCH', 'aaa', 'bbb', + ); + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + globalThis.fetch = fetchSpy as unknown as typeof fetch; const res = await request(app) .post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`) .set('Authorization', authHeader) .send({}); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns 403 PERMISSION_DENIED for a viewer', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + DatabaseService.getInstance().addUser({ + username: 'reset-anchor-viewer', + password_hash: 'x', + role: 'viewer', + }); + const viewerAuth = `Bearer ${jwt.sign( + { username: 'reset-anchor-viewer', role: 'viewer' }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + )}`; + + const res = await request(app) + .post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`) + .set('Authorization', viewerAuth) + .send({}); expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('returns 403 ADMIN_REQUIRED for a node-admin (status is admin-only)', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + DatabaseService.getInstance().addUser({ + username: 'reset-anchor-node-admin', + password_hash: 'x', + role: 'node-admin', + }); + const nodeAdminAuth = `Bearer ${jwt.sign( + { username: 'reset-anchor-node-admin', role: 'node-admin' }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + )}`; + + const res = await request(app) + .post(`/api/nodes/${peerNodeId}/fleet-sync/reset-anchor`) + .set('Authorization', nodeAdminAuth) + .send({}); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); }); }); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index d9c5634b..459b2a61 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -17,7 +17,7 @@ import { StackOpLockService } from '../services/StackOpLockService'; import SelfUpdateService, { type PinInfo } from '../services/SelfUpdateService'; import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry'; import { authMiddleware } from '../middleware/auth'; -import { requirePaid, requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates'; +import { requireAdmin, requireNodeProxy, requireUserSession } from '../middleware/tierGates'; import { checkPermission, requirePermission } from '../middleware/permissions'; import { respondSelfUpdatePreflight } from './license'; import { ImageOperationService } from '../services/ImageOperationService'; @@ -588,7 +588,6 @@ fleetRouter.post('/role/reanchor', authMiddleware, (req: Request, res: Response) fleetRouter.get('/sync-status', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; res.json(DatabaseService.getInstance().getFleetSyncStatuses()); }); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 30b0eda5..61b9a2cb 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -5,7 +5,7 @@ import path from 'path'; import { authMiddleware } from '../middleware/auth'; import { requirePermission } from '../middleware/permissions'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; -import { requireAdmin, requirePaid } from '../middleware/tierGates'; +import { requireAdmin } from '../middleware/tierGates'; import { enrollmentLimiter } from '../middleware/rateLimiters'; import { DeployedStackDeletionService } from '../services/DeployedStackDeletionService'; import { DatabaseService } from '../services/DatabaseService'; @@ -494,24 +494,20 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => { * Reset the FleetSync control anchor on a remote peer. * * Proxies POST /api/fleet/role/reanchor to the peer using its stored - * Bearer token. A successful reanchor clears every sticky-error row for - * this node so the next push (event-driven or via the 5-minute retry - * service) re-attempts cleanly and the peer accepts the central's + * Bearer token. On success, clears every sticky-error row for this node + * so the next push re-attempts cleanly and the peer accepts the central's * fingerprint as the new anchor. * - * Surfaces UI affordance for the F-16 audit (mesh-e2e-2026-05-17.md): - * when a peer was previously enrolled by a different central, FleetSync - * keeps 409'ing every reconcile tick; the sticky flag halts retries and - * this endpoint is the single one-click recovery for the operator. + * Used when a peer was previously enrolled by a different central: + * FleetSync keeps 409'ing every reconcile tick, the sticky flag halts + * retries, and this endpoint is the one-click operator recovery. */ nodesRouter.post('/:id/fleet-sync/reset-anchor', async (req: Request, res: Response) => { if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return; const nodeIdParam = req.params.id as string; if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return; - if (!requirePaid(req, res)) return; - // Reset-anchor is symmetric with `/api/fleet/sync-status` (admin-only). - // Keeping read and write gated at the same role avoids a banner-invisible-to-the-actor - // gap where a node-admin could call reset without ever seeing why. + // Also requireAdmin so reset stays symmetric with GET /api/fleet/sync-status. + // A node-admin must not reset anchors they cannot observe (status is admin-only). if (!requireAdmin(req, res)) return; try { const id = parseInt(nodeIdParam, 10); diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index c269db4f..23e7f827 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -14,7 +14,6 @@ import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime'; import { SettingsPrimaryButton } from './settings/SettingsActions'; import { useMastheadStats } from './settings/MastheadStatsContext'; import { NodeLabelPicker } from './blueprints/NodeLabelPicker'; -import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions'; import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus'; @@ -39,14 +38,10 @@ export interface SenchoNavigateDetail { } export function NodeManager() { - const { isPaid } = useLicense(); const { isAdmin, can } = useAuth(); - // Mirror the backend node:manage guard. This top-level flag checks the global - // role only (admin or global node-admin); the per-row Test/Edit/Delete buttons - // below additionally honor scoped per-node grants via can('node:manage', 'node', id). - // Admins resolve immediately via isAdmin; node-admins once /permissions/me lands. - // Generate-token and reset-anchor below stay admin-only to match their stricter - // backend guards (requireAdmin, and requireAdmin + requirePaid). + // Global node:manage only (admin or global node-admin). Per-row actions also + // check scoped can('node:manage', 'node', id). Generate-token and reset-anchor + // stay isAdmin-only to match their requireAdmin backend guards. const canManageNodes = isAdmin || can('node:manage'); const { nodes, refreshNodeMeta } = useNodes(); useMastheadStats([ @@ -277,7 +272,7 @@ export function NodeManager() { {' '}Reset the anchor on the peer to resume sync, or remove the node from this fleet.
- {isAdmin && isPaid && ( + {isAdmin && (