/** * DC-161: Calendar routes tests. * Covers: sources CRUD (secret never in response), validation rejections, * events aggregation with a failing source (partial success), Radicale * provision happy path + idempotence, discover probe error swallow. */ const express = require('express'); const http = require('http'); const fs = require('fs'); const os = require('os'); const path = require('path'); function buildRouter(overrides = {}) { jest.resetModules(); // Keep provision tests off the real filesystem process.env.DC_RADICALE_CONFIG_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'dc161-provision-')); const mod = require('../../routes/calendar'); const stored = {}; const items = []; const deps = { calendarStateManager: { read: jest.fn(async () => items), addItem: jest.fn(async (item) => { items.push(item); return item; }), removeItem: jest.fn(async (id) => { const i = items.findIndex(x => x.id === id); if (i >= 0) items.splice(i, 1); }), updateItem: jest.fn(async (id, updates) => { const it = items.find(x => x.id === id); if (it) Object.assign(it, updates); return it; }), findItem: jest.fn(async (id) => items.find(x => x.id === id) || null), _items: items, }, credentialManager: { store: jest.fn(async (key, value) => { stored[key] = value; }), retrieve: jest.fn(async (key) => stored[key] ?? null), delete: jest.fn(async (key) => { delete stored[key]; }), _stored: stored, }, docker: null, asyncHandler: (fn) => async (req, res, next) => { try { await fn(req, res, next); } catch (e) { next(e); } }, ok: (res, body) => res.json({ success: true, ...body }), errorResponse: (res, code, msg) => res.status(code).json({ success: false, error: msg }), log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, fetchT: jest.fn(), buildServiceUrl: (sub) => `https://${sub}.sami`, ...overrides, }; const router = express(); router.use(express.json()); router.use('/api/v1/calendar', mod(deps)); // Mirror prod error handling: AppError → JSON with its statusCode // (the real app's error middleware returns { success:false, error }). // eslint-disable-next-line no-unused-vars router.use((err, req, res, next) => { const status = err && err.statusCode ? err.statusCode : 500; res.status(status).json({ success: false, error: err.message }); }); deps._items = items; deps._stored = stored; return { app: router, deps }; } function listen(app) { return new Promise((resolve) => { const server = http.createServer(app); server.listen(0, () => resolve({ server, port: server.address().port })); }); } // Helper that spins a fresh server per test (fresh state each time) async function withServer(fn, overrides) { const { app, deps } = buildRouter(overrides); const { server, port } = await listen(app); try { await fn(port, deps); } finally { await new Promise((r) => server.close(r)); } } const j = (r) => r.json(); describe('DC-161 calendar routes', () => { describe('sources CRUD', () => { test('POST /sources requires name/type/url', async () => { await withServer(async (port) => { const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/name, type and url/); expect(body.success).toBe(false); }); }); test('POST /sources rejects non-http(s) and remote-plaintext URLs', async () => { await withServer(async (port) => { const mk = (url) => fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x', type: 'ics', url }), }); expect((await mk('ftp://example.com/cal.ics')).status).toBe(400); expect((await mk('http://example.com/cal.ics')).status).toBe(400); expect((await mk('http://localhost/cal.ics')).status).toBe(200); }); }); test('CalDAV source without user/token is rejected', async () => { await withServer(async (port) => { const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x', type: 'caldav', url: 'https://cal.example.com/u/' }), }); expect(res.status).toBe(400); }); }); test('stored pass never appears in the sources response', async () => { await withServer(async (port, deps) => { await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Work', type: 'caldav', url: 'https://cal.example.com/u/', user: 'sam', pass: 'hunter2' }), }); const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`); const text = await res.text(); expect(text).not.toContain('hunter2'); expect(deps._stored['calendar:source:' + deps._items[0].id]).toContain('hunter2'); }); }); test('DELETE removes the source and its credential', async () => { await withServer(async (port, deps) => { const add = await (await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'W', type: 'ics', url: 'https://x.example.com/c.ics' }), })).json(); const id = add.source.id; const del = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources/${id}`, { method: 'DELETE' }); expect((await del.json()).removed).toBe(true); expect(deps._items).toHaveLength(0); expect(deps.credentialManager.delete).toHaveBeenCalledWith(`calendar:source:${id}`); }); }); }); describe('events aggregation', () => { test('oversized ICS feed is rejected (5 MiB cap), source degrades to error', async () => { await withServer(async (port, deps) => { deps._items.push({ id: 'huge', type: 'ics', name: 'Huge', url: 'https://huge.example.com/c.ics', enabled: true }); // buildApPayload: a body just over 5 MiB const bigIcs = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n' + 'X'.repeat(5 * 1024 * 1024 + 10) + '\r\nEND:VCALENDAR'; deps.fetchT.mockImplementation(async () => ({ ok: true, status: 200, body: { getReader: () => { const enc = new TextEncoder(); let sent = false; return { read: async () => { if (sent) return { done: true }; sent = true; return { done: false, value: enc.encode(bigIcs) }; }, cancel: async () => {} }; } }, text: async () => bigIcs, })); const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/events`); const body = await res.json(); expect(res.status).toBe(200); // partial-failure contract expect(body.perSource).toEqual([expect.objectContaining({ sourceId: 'huge', ok: false })]); expect(body.errors.join(' ')).toMatch(/size cap/); }); }); test('a failing source degrades to perSource error, others still return', async () => { await withServer(async (port, deps) => { deps._items.push( { id: 'good', type: 'ics', name: 'Good', url: 'https://good.example.com/c.ics', enabled: true }, { id: 'bad', type: 'ics', name: 'Bad', url: 'https://bad.example.com/c.ics', enabled: true }, ); const goodIcs = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:g1\r\nDTSTART:20261007T100000Z\r\nSUMMARY:Good event\r\nEND:VEVENT\r\nEND:VCALENDAR'; deps.fetchT.mockImplementation(async (url) => { if (url.includes('good')) return { ok: true, status: 200, text: async () => goodIcs }; throw new Error('DNS failure'); }); const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/events`); const body = await res.json(); expect(res.status).toBe(200); expect(body.events.map(e => e.title)).toEqual(['Good event']); expect(body.perSource).toEqual(expect.arrayContaining([ expect.objectContaining({ sourceId: 'good', ok: true }), expect.objectContaining({ sourceId: 'bad', ok: false }), ])); expect(body.errors.join(' ')).toMatch(/Bad/); }); }); test('disabled sources are skipped', async () => { await withServer(async (port, deps) => { deps._items.push({ id: 'off', type: 'ics', name: 'Off', url: 'https://off.example.com/c.ics', enabled: false }); const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/events`); const body = await res.json(); expect(body.events).toHaveLength(0); expect(body.perSource).toHaveLength(0); expect(deps.fetchT).not.toHaveBeenCalled(); }); }); test('invalid window is rejected with 400', async () => { await withServer(async (port) => { const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/events?start=nonsense`); expect(res.status).toBe(400); }); }); }); describe('radicale provision', () => { test('registers an auto-wired source and stores credentials (verified Radicale container)', async () => { const dockerStub = { client: { getContainer: () => ({ inspect: jest.fn(async () => ({ Config: { Image: 'tomsquest/docker-radicale:latest' } })), }), }, }; await withServer(async (port, deps) => { const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/radicale/provision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subdomain: 'calendar', serviceUrl: 'https://calendar.sami', containerId: 'abc123' }), }); const body = await res.json(); expect(res.status).toBe(200); expect(body.source.autoProvisioned).toBe(true); expect(body.source.url).toBe('https://calendar.sami/dashcaddy/'); const saved = JSON.parse(deps._stored[`calendar:source:${body.source.id}`]); expect(saved.user).toBe('dashcaddy'); expect(saved.pass).toMatch(/^[A-Za-z0-9]{24}$/); // htpasswd was written for that exact password (inside the test root) const fs = require('fs'); const root = process.env.DC_RADICALE_CONFIG_ROOT; const users = fs.readFileSync(path.join(root, 'config', 'users'), 'utf8'); expect(users).toContain('dashcaddy:$apr1$'); }, { docker: dockerStub }); }); test('invalid subdomain is rejected', async () => { await withServer(async (port) => { const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/radicale/provision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subdomain: '../etc' }), }); expect(res.status).toBe(400); }); }); test.each([ ['missing containerId', { subdomain: 'calendar', serviceUrl: 'https://calendar.sami' }], ['container inspect fails', { subdomain: 'calendar', serviceUrl: 'https://calendar.sami', containerId: 'abc123', _inspect: 'throw' }], ['image is not Radicale', { subdomain: 'calendar', serviceUrl: 'https://calendar.sami', containerId: 'abc123', _image: 'nginx:latest' }], ])('provision fails closed: %s → 409, nothing written or registered', async (_name, body) => { const dockerStub = { client: { getContainer: () => ({ inspect: body._inspect === 'throw' ? jest.fn(async () => { throw new Error('no such container'); }) : jest.fn(async () => ({ Config: { Image: body._image || 'nginx:latest' } })), }), }, }; await withServer(async (port, deps) => { const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/radicale/provision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); expect(res.status).toBe(409); const errBody = await res.json(); expect(errBody.error).toMatch(/Refusing to provision/); // Nothing registered, nothing stored, nothing written to the config root expect(deps._items).toHaveLength(0); expect(Object.keys(deps._stored)).toHaveLength(0); const root = process.env.DC_RADICALE_CONFIG_ROOT; expect(fs.existsSync(path.join(root, 'config'))).toBe(false); }, { docker: dockerStub }); }); test('provision succeeds when the inspected image IS Radicale', async () => { const dockerStub = { client: { getContainer: () => ({ inspect: jest.fn(async () => ({ Config: { Image: 'tomsquest/docker-radicale:latest' } })), }), }, }; await withServer(async (port, deps) => { const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/radicale/provision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subdomain: 'calendar', serviceUrl: 'https://calendar.sami', containerId: 'abc123' }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.source.autoProvisioned).toBe(true); expect(deps._items).toHaveLength(1); const root = process.env.DC_RADICALE_CONFIG_ROOT; expect(fs.existsSync(path.join(root, 'config', 'users'))).toBe(true); }, { docker: dockerStub }); }); }); });