// 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'); }); }); });