Files
fredy/test/services/debug/debugRouter.test.js
2026-06-09 15:42:25 +02:00

251 lines
8.1 KiB
JavaScript

/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'node:path';
import Fastify from 'fastify';
describe('api/routes/debugRouter.js', () => {
let app;
let state;
beforeEach(async () => {
state = {
enabled: false,
hasLogs: false,
everEnabled: false,
size: 0,
max: 5 * 1024 * 1024,
};
const ROOT = path.resolve('.');
const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js');
const bundlePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js');
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
const storageMock = {
isEnabled: () => state.enabled,
enableDebugLogging: async ({ clearPrevious = false } = {}) => {
state.enabled = true;
state.everEnabled = true;
if (clearPrevious) {
state.hasLogs = false;
state.size = 0;
}
},
disableDebugLogging: async () => {
state.enabled = false;
},
getCurrentSize: async () => state.size,
getMaxSize: () => state.max,
hasAnyLogs: () => state.hasLogs,
wasEverEnabled: async () => state.everEnabled,
clearAllDebugLogs: () => {
state.hasLogs = false;
state.size = 0;
},
};
const bundleMock = {
buildDebugBundleFileName: async () => '2026-06-08-FredyDebug-22.5.0.zip',
buildDebugBundleZip: async () => Buffer.from('FAKEZIP'),
};
const settingsMock = {
getSettings: async () => ({ port: 9998 }),
};
vi.resetModules();
vi.doMock(storagePath, () => storageMock);
vi.doMock(bundlePath, () => bundleMock);
vi.doMock(settingsStoragePath, () => settingsMock);
const mod = await import(path.join(ROOT, 'lib', 'api', 'routes', 'debugRouter.js'));
const plugin = mod.default;
app = Fastify({ logger: false });
await app.register(plugin, { prefix: '/api/admin/debug' });
await app.register(
async (sub) => {
mod.registerDebugPublicProbe(sub);
},
{ prefix: '/api/debug' },
);
await app.ready();
});
afterEach(async () => {
if (app) await app.close();
});
it('GET /status returns the current snapshot', async () => {
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/status' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
enabled: false,
size: 0,
max: state.max,
hasLogs: false,
everEnabled: false,
});
});
it('POST /enable flips the feature on and returns updated status', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/admin/debug/enable',
payload: {},
});
expect(res.statusCode).toBe(200);
const json = res.json();
expect(json.enabled).toBe(true);
expect(json.everEnabled).toBe(true);
});
it('POST /enable with clearPrevious=true wipes existing logs first', async () => {
state.hasLogs = true;
state.size = 1234;
state.everEnabled = true;
const res = await app.inject({
method: 'POST',
url: '/api/admin/debug/enable',
payload: { clearPrevious: true },
});
expect(res.statusCode).toBe(200);
const json = res.json();
expect(json.enabled).toBe(true);
expect(json.hasLogs).toBe(false);
expect(json.size).toBe(0);
});
it('POST /disable turns the feature off without losing existing logs', async () => {
state.enabled = true;
state.hasLogs = true;
state.everEnabled = true;
state.size = 99;
const res = await app.inject({ method: 'POST', url: '/api/admin/debug/disable' });
expect(res.statusCode).toBe(200);
const json = res.json();
expect(json.enabled).toBe(false);
expect(json.hasLogs).toBe(true);
expect(json.size).toBe(99);
});
it('GET /download returns 409 when the feature was never enabled', async () => {
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/never produced any data/i);
});
it('GET /download returns 409 when ever-enabled but no logs are stored', async () => {
state.everEnabled = true;
state.hasLogs = false;
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' });
expect(res.statusCode).toBe(409);
});
it('GET /download streams a zip with the expected headers when logs exist', async () => {
state.everEnabled = true;
state.hasLogs = true;
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('application/zip');
expect(res.headers['content-disposition']).toContain('FredyDebug');
expect(res.rawPayload.toString('utf-8')).toBe('FAKEZIP');
});
it('DELETE /logs wipes stored logs without touching the enabled flag', async () => {
state.enabled = true;
state.hasLogs = true;
state.everEnabled = true;
state.size = 1234;
const res = await app.inject({ method: 'DELETE', url: '/api/admin/debug/logs' });
expect(res.statusCode).toBe(200);
const json = res.json();
expect(json.enabled).toBe(true);
expect(json.hasLogs).toBe(false);
expect(json.size).toBe(0);
// everEnabled must stay true so the download button does not change semantics.
expect(json.everEnabled).toBe(true);
});
it('GET /api/debug/active returns only the enabled boolean (no other settings)', async () => {
state.enabled = false;
let res = await app.inject({ method: 'GET', url: '/api/debug/active' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ enabled: false });
state.enabled = true;
res = await app.inject({ method: 'GET', url: '/api/debug/active' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ enabled: true });
});
});
describe('api/routes/debugRouter.js - admin-only enforcement', () => {
let app;
beforeEach(async () => {
const ROOT = path.resolve('.');
const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js');
const bundlePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js');
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
vi.resetModules();
vi.doMock(storagePath, () => ({
isEnabled: () => false,
enableDebugLogging: async () => {},
disableDebugLogging: async () => {},
getCurrentSize: async () => 0,
getMaxSize: () => 5 * 1024 * 1024,
hasAnyLogs: () => false,
wasEverEnabled: async () => false,
clearAllDebugLogs: () => {},
}));
vi.doMock(bundlePath, () => ({
buildDebugBundleFileName: async () => 'x.zip',
buildDebugBundleZip: async () => Buffer.from(''),
}));
vi.doMock(settingsStoragePath, () => ({
getSettings: async () => ({}),
}));
const plugin = (await import(path.join(ROOT, 'lib', 'api', 'routes', 'debugRouter.js'))).default;
app = Fastify({ logger: false });
await app.register(
async (sub) => {
// Same wiring shape as lib/api/api.js: apply adminHook before the plugin.
sub.addHook('preHandler', async (request, reply) => {
reply.code(401).send();
});
sub.register(plugin, { prefix: '/api/admin/debug' });
},
{ prefix: '/' },
);
await app.ready();
});
afterEach(async () => {
if (app) await app.close();
});
it('rejects non-admin callers with 401 on every endpoint', async () => {
for (const route of [
['GET', '/api/admin/debug/status'],
['POST', '/api/admin/debug/enable'],
['POST', '/api/admin/debug/disable'],
['GET', '/api/admin/debug/download'],
['DELETE', '/api/admin/debug/logs'],
]) {
const [method, url] = route;
const res = await app.inject({ method, url, payload: method === 'POST' ? {} : undefined });
expect(res.statusCode, `${method} ${url}`).toBe(401);
}
});
});