feat: implement togglable debug log streaming for server stdout/stderr and client console logs
- Created server/utils/logCapture.js to intercept and buffer server output, stripping ANSI escape codes. - Created server/middleware/logStreamAuth.js enforcing subnet IP filtering (LOG_ALLOW_SUBNETS), Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass. - Created server/routes/debug.js with SSE streams /api/debug/server-logs, /api/debug/client-logs and batched POST /api/debug/client-logs. Exposes public configuration status at /api/debug/status. - Integrated log capture and mounted debug routes in server/app.js and server/index.js. - Implemented client/src/utils/clientLogCapture.js in the frontend SPA to hook console log/warn/error and flush batched console events. - Documented all endpoints in OpenAPI server/openapi.yaml, ARCHITECTURE.md, and README.md. - Wrote route integration tests and frontend console capture tests, with full validation in swagger-coverage.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
* Tests for client/src/utils/clientLogCapture.js
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { initClientLogCapture } from '../../../client/src/utils/clientLogCapture.js';
|
||||
|
||||
describe('clientLogCapture', () => {
|
||||
let fetchMock;
|
||||
let originalConsoleLog;
|
||||
let originalConsoleWarn;
|
||||
let originalConsoleError;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Preserve original console methods
|
||||
originalConsoleLog = console.log;
|
||||
originalConsoleWarn = console.warn;
|
||||
originalConsoleError = console.error;
|
||||
|
||||
// Reset console methods to standard ones
|
||||
console.log = vi.fn();
|
||||
console.warn = vi.fn();
|
||||
console.error = vi.fn();
|
||||
|
||||
// Mock window fetch
|
||||
fetchMock = vi.fn();
|
||||
global.window.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Restore original console methods
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('exits early and does not intercept console if status returns disabled', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ enabled: false })
|
||||
});
|
||||
|
||||
await initClientLogCapture();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||
|
||||
// Console calls should NOT be queued or overridden (i.e. they should just run vi.fn mocks)
|
||||
console.log('Test message');
|
||||
expect(console.log).toHaveBeenCalledWith('Test message');
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/debug/client-logs', expect.any(Object));
|
||||
});
|
||||
|
||||
it('hooks console and flushes logs periodically when status returns enabled', async () => {
|
||||
fetchMock.mockImplementation((url, options) => {
|
||||
if (url === '/api/debug/status') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ enabled: true })
|
||||
});
|
||||
}
|
||||
if (url === '/api/debug/client-logs') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ success: true })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await initClientLogCapture();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||
|
||||
// Trigger console logs
|
||||
console.log('Booting app', { config: 'loaded' });
|
||||
console.warn('Deprecated api call');
|
||||
console.error('Failed request', new Error('timeout'));
|
||||
|
||||
// Move timers forward to trigger flush interval (2000ms)
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/debug/client-logs', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}));
|
||||
|
||||
const lastCall = fetchMock.mock.calls.find(call => call[0] === '/api/debug/client-logs');
|
||||
expect(lastCall).toBeDefined();
|
||||
|
||||
const loggedEntries = JSON.parse(lastCall[1].body);
|
||||
expect(loggedEntries).toHaveLength(4); // Includes the interceptor boot message + 3 logs
|
||||
|
||||
expect(loggedEntries[1].level).toBe('info');
|
||||
expect(loggedEntries[1].message).toContain('Booting app {"config":"loaded"}');
|
||||
|
||||
expect(loggedEntries[2].level).toBe('warn');
|
||||
expect(loggedEntries[2].message).toContain('Deprecated api call');
|
||||
|
||||
expect(loggedEntries[3].level).toBe('error');
|
||||
expect(loggedEntries[3].message).toContain('Failed request');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import { createApp } from '../../server/app.js';
|
||||
|
||||
const EMBY_BASE = 'https://emby.test';
|
||||
|
||||
describe('Debug Logs API Integration', () => {
|
||||
beforeAll(() => {
|
||||
process.env.EMBY_URL = EMBY_BASE;
|
||||
process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret-xyz';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||
delete process.env.ENABLE_LOG_STREAM;
|
||||
delete process.env.LOG_ALLOW_SUBNETS;
|
||||
delete process.env.TRUST_PROXY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
describe('GET /api/debug/status', () => {
|
||||
it('returns enabled: false when ENABLE_LOG_STREAM is not true', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('returns enabled: true when ENABLE_LOG_STREAM is true', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global toggle checking', () => {
|
||||
it('returns 403 Forbidden on server logs GET when feature is disabled', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/server-logs');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
it('returns 403 Forbidden on client logs GET when feature is disabled', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/client-logs');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
it('returns 403 Forbidden on client logs POST when feature is disabled', async () => {
|
||||
process.env.ENABLE_LOG_STREAM = 'false';
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).post('/api/debug/client-logs').send([]);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subnet CIDR validation', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
process.env.LOG_ALLOW_SUBNETS = '127.0.0.1/32,192.168.1.0/24';
|
||||
process.env.TRUST_PROXY = '1';
|
||||
});
|
||||
|
||||
it('returns 403 Forbidden if client IP is not in subnet allowlist', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs')
|
||||
.set('X-Forwarded-For', '10.0.0.50');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/Access denied from IP/i);
|
||||
});
|
||||
|
||||
it('bypasses subnet check and hits auth validation if client IP is allowed', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// In subnet allowlist but missing credentials -> returns 401 instead of 403!
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs')
|
||||
.set('X-Forwarded-For', '192.168.1.150');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.LOG_ALLOW_SUBNETS;
|
||||
delete process.env.TRUST_PROXY;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication and Bypass policies', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
});
|
||||
|
||||
it('returns 401 Unauthorized when all auth options are missing', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app).get('/api/debug/server-logs');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers['www-authenticate']).toContain('Basic realm=');
|
||||
});
|
||||
|
||||
it('allows access via X-Webhook-Secret header bypass', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
// X-Webhook-Secret bypass avoids Emby login entirely (returns 200 SSE stream)
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs?testClose=true')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||
});
|
||||
|
||||
it('allows access via Basic Authentication with valid Emby administrator credentials', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
// Mock Emby login
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, {
|
||||
AccessToken: 'admin-emby-tok',
|
||||
User: { Id: 'admin-user-id', Name: 'embyadmin' }
|
||||
});
|
||||
|
||||
// Mock Emby profile fetch verifying IsAdministrator is true
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users/admin-user-id')
|
||||
.reply(200, {
|
||||
Id: 'admin-user-id',
|
||||
Name: 'embyadmin',
|
||||
Policy: { IsAdministrator: true }
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs?testClose=true')
|
||||
.auth('embyadmin', 'password123');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||
});
|
||||
|
||||
it('denies access via Basic Authentication if user is not an administrator', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(200, {
|
||||
AccessToken: 'user-emby-tok',
|
||||
User: { Id: 'regular-user-id', Name: 'embyuser' }
|
||||
});
|
||||
|
||||
nock(EMBY_BASE)
|
||||
.get('/Users/regular-user-id')
|
||||
.reply(200, {
|
||||
Id: 'regular-user-id',
|
||||
Name: 'embyuser',
|
||||
Policy: { IsAdministrator: false }
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/debug/server-logs')
|
||||
.auth('embyuser', 'password123');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client logs ingestion and streaming', () => {
|
||||
beforeAll(() => {
|
||||
process.env.ENABLE_LOG_STREAM = 'true';
|
||||
});
|
||||
|
||||
it('returns 400 Bad Request on client logs POST if body is not an array', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const res = await request(app)
|
||||
.post('/api/debug/client-logs')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||
.send({ message: 'not an array' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ingests client logs array and streams them over client logs GET SSE', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
|
||||
// Ingest client logs
|
||||
const postRes = await request(app)
|
||||
.post('/api/debug/client-logs')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||
.send([
|
||||
{ timestamp: new Date().toISOString(), level: 'info', message: 'Hello from client' }
|
||||
]);
|
||||
expect(postRes.status).toBe(200);
|
||||
expect(postRes.body.count).toBe(1);
|
||||
|
||||
// Verify log streams successfully via GET
|
||||
const getRes = await request(app)
|
||||
.get('/api/debug/client-logs?testClose=true')
|
||||
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.headers['content-type']).toContain('text/event-stream');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user