[pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring

- Top-bar calendar widget (status/js/calendar-widget.js): next-appointment
  display, settings modal with CalDAV/ICS source management and a
  Test-connection probe. Follows the weather-widget pattern.
- Backend routes/calendar.js: sources CRUD (secrets in credential manager,
  never in responses), aggregated events across sources with per-source
  error isolation, Radicale provision endpoint, CalDAV discover probe.
- src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded
  RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL,
  EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap.
- src/calendar/caldav-client.js: CalDAV REPORT time-range queries over
  node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap.
- src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates
  a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash
  verified byte-for-byte against openssl passwd -apr1 vectors), stores
  credentials, registers the source. Deploy hook in routes/apps/deploy.js
  wires any Radicale deployed via the App Selector automatically.
- Radicale app template (tomsquest/docker-radicale) in the catalog.
- Tests: 32 new (parser unit, provisioner unit incl. openssl vectors,
  route tests). Full suite 3552 passed; only pre-existing pdf-parse
  billing/invoice failures remain (proven red at base 448f535).
- OpenAPI: 5 calendar paths documented. CHANGELOG entry added.
This commit is contained in:
Hermes committed 2026-10-07 19:57:22 -07:00
1 parent 448f5358bf
commit 74e21c1edc
18 files changed
+1845

No files matched your search

@@ -0,0 +1,221 @@
/**
* 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('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', async () => {
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' }),
});
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:radicale:calendar']);
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$');
});
});
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);
});
});
});
});