fix(fleet): harden remote node updates with admin enforcement, expiry fix, and diagnostics (#542)

- Fix completed-entry auto-expiry using resolvedAt instead of startedAt
- Add admin role enforcement to update trigger and update-all endpoints
- Add missing error field in rejected-promise fallback for update-status
- Fix rejected promises in update-all losing node names
- Harden frontend recheck button with try/catch/finally error handling
- Align frontend isValidVersion with stricter regex validation
- Add diagnostic logging gated behind developer_mode
- Extract resolveTracker helper to centralize terminal state transitions
- Add 15 new fleet test cases covering auth, tier gating, input validation, and admin roles
- Document admin requirement and troubleshooting in fleet-view docs
This commit is contained in:
Anso
2026-04-12 22:07:41 -04:00
committed by GitHub
parent a0fe84e84b
commit d23c6779af
6 changed files with 217 additions and 30 deletions
+134
View File
@@ -60,6 +60,21 @@ describe('Fleet endpoints require authentication', () => {
const res = await request(app).post('/api/fleet/nodes/1/update');
expect(res.status).toBe(401);
});
it('POST /api/fleet/update-all returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet/update-all');
expect(res.status).toBe(401);
});
it('DELETE /api/fleet/nodes/1/update-status returns 401 without auth', async () => {
const res = await request(app).delete('/api/fleet/nodes/1/update-status');
expect(res.status).toBe(401);
});
it('DELETE /api/fleet/update-status returns 401 without auth', async () => {
const res = await request(app).delete('/api/fleet/update-status');
expect(res.status).toBe(401);
});
});
// ─── Input Validation ───
@@ -159,6 +174,125 @@ describe('Fleet tier gating', () => {
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 () => {
mockTier('community');
const res = await request(app)
.post('/api/fleet/update-all')
.set('Authorization', authHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('DELETE /api/fleet/nodes/1/update-status returns 403 on free 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');
});
it('DELETE /api/fleet/update-status returns 403 on free 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');
});
});
// ─── Update Endpoint Input Validation ───
describe('Fleet update input validation', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/fleet/nodes/abc/update returns 400 for NaN nodeId', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/nodes/abc/update')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid node id/i);
});
it('POST /api/fleet/nodes/99999/update returns 404 for missing node', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/nodes/99999/update')
.set('Authorization', authHeader);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('DELETE /api/fleet/nodes/abc/update-status returns 400 for NaN nodeId', async () => {
mockTier('paid');
const res = await request(app)
.delete('/api/fleet/nodes/abc/update-status')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid node id/i);
});
it('DELETE /api/fleet/nodes/99999/update-status returns 404 for missing node', async () => {
mockTier('paid');
const res = await request(app)
.delete('/api/fleet/nodes/99999/update-status')
.set('Authorization', authHeader);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
});
// ─── Admin Role Enforcement ───
describe('Fleet update admin enforcement', () => {
afterEach(() => vi.restoreAllMocks());
let viewerHeader: string;
beforeAll(async () => {
// Create a viewer user and generate a token for them
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const bcrypt = await import('bcrypt');
const viewerHash = await bcrypt.hash('viewerpass', 1);
try {
db.addUser({ username: 'testviewer', password_hash: viewerHash, role: 'viewer' });
} catch {
// User may already exist from a prior run
}
const viewerToken = jwt.sign({ username: 'testviewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
viewerHeader = `Bearer ${viewerToken}`;
});
it('POST /api/fleet/nodes/1/update returns 403 for viewer', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/nodes/1/update')
.set('Authorization', viewerHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('POST /api/fleet/update-all returns 403 for viewer', async () => {
mockTier('paid');
const res = await request(app)
.post('/api/fleet/update-all')
.set('Authorization', viewerHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
});
// ─── Snapshot CRUD ───