The 'failing source degrades to perSource error' test hardcoded DTSTART:20261007T100000Z. The /events route defaults to a now-7d..now+30d window, so the fixture silently expired on 2026-10-14 and this was the single failing test in every full-suite run since. Fixture now builds DTSTART for tomorrow UTC at runtime. Verified: file suite 15/15; full suite 3704/3705 (this was the only failure). Judge A — urn:ump:3n5xljspojvc57x7isxi4busps2dxlpwuzivhntg44i3jkpiqfaa
313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
/**
|
|
* 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 },
|
|
);
|
|
// Date-relative fixture (was hardcoded 20261007 and silently expired
|
|
// out of the default now-7d..now+30d window): build DTSTART for
|
|
// tomorrow UTC so the event is always inside the aggregation window.
|
|
const ev = new Date(Date.now() + 86400000);
|
|
const p2 = (n) => String(n).padStart(2, '0');
|
|
const dtstart = `${ev.getUTCFullYear()}${p2(ev.getUTCMonth() + 1)}${p2(ev.getUTCDate())}T${p2(ev.getUTCHours())}${p2(ev.getUTCMinutes())}00Z`;
|
|
const goodIcs = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:g1\r\nDTSTART:' + dtstart + '\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 });
|
|
});
|
|
});
|
|
});
|