mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
251 lines
8.1 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|