mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
refactor(backend): add tests then extract metrics and image-updates routers (phase 4b follow-up) (#738)
Wraps up Phase 4 Round B by tackling the two deferred groups. 25 new integration tests land first and run green against the inline monolith, then each group is extracted byte-for-byte. index.ts drops from ~3,678 to ~3,231 lines; test count rises 1,320 → 1,345. New coverage: - metrics-routes.test.ts (11) — auth + shape checks for /api/stats, /api/metrics/historical, /api/system/stats, /api/system/cache-stats (admin-only), and SSE headers for /api/logs/global/stream - image-updates-routes.test.ts (14) — auth, admin gating, rate-limit tolerance on /refresh, fleet aggregation, /auto-update/execute input validation and no-stacks short-circuit New route files: - routes/metrics.ts — /stats, /metrics/historical, /logs/global (+ SSE /stream), /system/stats, /system/cache-stats. Mounted at /api so the mixed sub-paths line up. - routes/imageUpdates.ts — /api/image-updates CRUD + fleet aggregation, plus a separate autoUpdateRouter mounted at /api/auto-update that owns the /execute handler. Same split pattern as license.ts + systemUpdateRouter. index.ts trims unused imports left behind by the extraction: globalDockerNetwork, si, STATS_CACHE_TTL_MS, SYSTEM_STATS_CACHE_TTL_MS, GlobalLogEntry + log-parsing helpers.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Integration tests for /api/image-updates and /api/auto-update/execute.
|
||||
* Locks down auth, admin gating, rate limiting, and input validation
|
||||
* before extraction.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'iu-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'iu-viewer', password: 'viewerpass' });
|
||||
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('GET /api/image-updates', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/image-updates');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns the current stack update status map for authenticated users', async () => {
|
||||
const res = await request(app).get('/api/image-updates').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/image-updates/refresh', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).post('/api/image-updates/refresh');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects non-admin users with 403', async () => {
|
||||
const res = await request(app).post('/api/image-updates/refresh').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 200 or 429 when admin hits it (cooldown-aware)', async () => {
|
||||
// Running first: expect 200 unless the service is already mid-refresh
|
||||
// or a previous manual trigger set the cooldown. Either way, only 200
|
||||
// or 429 are acceptable; 4xx/5xx would indicate a regression.
|
||||
const res = await request(app).post('/api/image-updates/refresh').set('Cookie', adminCookie);
|
||||
expect([200, 429]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/image-updates/status', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/image-updates/status');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns a checking flag', async () => {
|
||||
const res = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.checking).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/image-updates/fleet', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/image-updates/fleet');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns the fleet-wide aggregation map', async () => {
|
||||
const res = await request(app).get('/api/image-updates/fleet').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auto-update/execute', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).post('/api/auto-update/execute').send({ target: '*' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects non-admin users with 403', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ target: '*' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects missing target with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Missing "target"/);
|
||||
});
|
||||
|
||||
it('rejects invalid stack name with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: '../etc/passwd' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid stack name/);
|
||||
});
|
||||
|
||||
it('returns a summary string when no stacks exist (target="*")', async () => {
|
||||
// On a fresh test instance there are no stacks on disk, so the handler
|
||||
// short-circuits with the "no stacks found" branch.
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: '*' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.result).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Integration tests for metrics and log endpoints.
|
||||
*
|
||||
* Covers the happy paths that don't require a live Docker daemon:
|
||||
* - GET /api/stats is DOCKER-dependent; we assert it returns 200 or 500
|
||||
* (depending on whether the test host has Docker) but never 4xx.
|
||||
* - GET /api/metrics/historical returns a JSON array.
|
||||
* - GET /api/system/stats returns CPU/memory/disk/network blocks.
|
||||
* - GET /api/system/cache-stats requires admin.
|
||||
* - GET /api/logs/global/stream sets SSE headers.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'metrics-viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'metrics-viewer', password: 'viewerpass' });
|
||||
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('GET /api/stats', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/stats');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('authenticated users get a non-4xx response', async () => {
|
||||
// The handler reaches the local Docker daemon; in CI without Docker
|
||||
// that surfaces as 500, which is acceptable — we only want to prove
|
||||
// auth + routing work.
|
||||
const res = await request(app).get('/api/stats').set('Cookie', adminCookie);
|
||||
expect([200, 500]).toContain(res.status);
|
||||
if (res.status === 200) {
|
||||
expect(res.body).toHaveProperty('active');
|
||||
expect(res.body).toHaveProperty('total');
|
||||
expect(res.body).toHaveProperty('managed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/metrics/historical', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/metrics/historical');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns an array for authenticated users', async () => {
|
||||
const res = await request(app).get('/api/metrics/historical').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/system/stats', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/system/stats');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns system metrics for authenticated users', async () => {
|
||||
const res = await request(app).get('/api/system/stats').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('cpu');
|
||||
expect(res.body).toHaveProperty('memory');
|
||||
expect(res.body).toHaveProperty('network');
|
||||
expect(res.body.cpu).toHaveProperty('cores');
|
||||
expect(res.body.memory).toHaveProperty('total');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/system/cache-stats', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/system/cache-stats');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects non-admin users with 403', async () => {
|
||||
const res = await request(app).get('/api/system/cache-stats').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns cache statistics for admin', async () => {
|
||||
const res = await request(app).get('/api/system/cache-stats').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/logs/global/stream', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).get('/api/logs/global/stream');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('sets SSE response headers for authenticated users', async () => {
|
||||
// The SSE handler writes headers immediately then keeps the connection
|
||||
// open. supertest .end() after we see the headers lets Express flush
|
||||
// the close listener cleanly.
|
||||
const req = request(app).get('/api/logs/global/stream').set('Cookie', adminCookie).buffer(false);
|
||||
const res = await new Promise<{ status: number; headers: Record<string, string> }>((resolve, reject) => {
|
||||
req.on('response', r => {
|
||||
resolve({ status: r.status, headers: r.headers as Record<string, string> });
|
||||
r.destroy();
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||
expect(res.headers['cache-control']).toContain('no-cache');
|
||||
expect(res.headers['x-accel-buffering']).toBe('no');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user