[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:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user