[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* DC-161: ICS parser unit tests.
|
||||
* Covers: basic VEVENT extraction, all-day events, RRULE expansion
|
||||
* (DAILY/WEEKLY-BYDAY/MONTHLY + COUNT/UNTIL/INTERVAL), EXDATE,
|
||||
* RECURRENCE-ID overrides, event cap, malformed DTSTART.
|
||||
*/
|
||||
|
||||
const { parseIcs, parseIcsDate, unfoldLines } = require('../../src/calendar/ics-parser');
|
||||
|
||||
const RANGE = { start: new Date('2026-10-01T00:00:00Z'), end: new Date('2026-11-01T00:00:00Z') };
|
||||
|
||||
function ics(body) {
|
||||
return `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n${body}\r\nEND:VCALENDAR`;
|
||||
}
|
||||
|
||||
describe('DC-161 ics-parser', () => {
|
||||
test('parses a simple timed event', () => {
|
||||
const { events, errors } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:1\r\nDTSTART:20261007T140000Z\r\nDTEND:20261007T150000Z\r\nSUMMARY:Dentist\\, cleaning\r\nLOCATION:Main St\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
expect(errors).toEqual([]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].title).toBe('Dentist, cleaning');
|
||||
expect(events[0].start.toISOString()).toBe('2026-10-07T14:00:00.000Z');
|
||||
expect(events[0].allDay).toBe(false);
|
||||
expect(events[0].location).toBe('Main St');
|
||||
});
|
||||
|
||||
test('parses an all-day event (DATE value)', () => {
|
||||
const { events } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:2\r\nDTSTART;VALUE=DATE:20261010\r\nSUMMARY:Conference\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
expect(events[0].allDay).toBe(true);
|
||||
expect(events[0].start.getFullYear()).toBe(2026);
|
||||
expect(events[0].start.getMonth()).toBe(9);
|
||||
expect(events[0].start.getDate()).toBe(10);
|
||||
});
|
||||
|
||||
test('daily recurrence with COUNT expands within window', () => {
|
||||
const { events } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:3\r\nDTSTART:20261001T090000Z\r\nDTEND:20261001T093000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Standup\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
expect(events).toHaveLength(5);
|
||||
expect(events[0].start.getUTCDate()).toBe(1);
|
||||
expect(events[4].start.getUTCDate()).toBe(5);
|
||||
expect(events.every(e => e.recurring)).toBe(true);
|
||||
});
|
||||
|
||||
test('weekly BYDAY expansion lands on correct weekdays', () => {
|
||||
const { events } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:4\r\nDTSTART:20261005T100000Z\r\nDTEND:20261005T110000Z\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\nSUMMARY:Gym\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
// Oct 5 2026 is a Monday. Expect 3/wk for the 4 weeks in October window.
|
||||
expect(events.length).toBeGreaterThanOrEqual(11);
|
||||
for (const ev of events) {
|
||||
expect([1, 3, 5]).toContain(ev.start.getUTCDay());
|
||||
}
|
||||
});
|
||||
|
||||
test('EXDATE excludes occurrences', () => {
|
||||
const { events } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:5\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;COUNT=4\r\nEXDATE:20261003T090000Z\r\nSUMMARY:Except Wednesday\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events.some(e => e.start.getUTCDate() === 3)).toBe(false);
|
||||
});
|
||||
|
||||
test('UNTIL bounds recurrence', () => {
|
||||
const { events } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:6\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;UNTIL=20261004T090000Z\r\nSUMMARY:Bounded\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
expect(events).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('events outside window are excluded; window straddles inclusion', () => {
|
||||
const { events } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:7\r\nDTSTART:20260901T090000Z\r\nDTEND:20260901T100000Z\r\nSUMMARY:September only\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('RECURRENCE-ID override replaces instance title', () => {
|
||||
const { events } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:8\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Original\r\nEND:VEVENT\r\n' +
|
||||
'BEGIN:VEVENT\r\nUID:8\r\nRECURRENCE-ID:20261002T090000Z\r\nDTSTART:20261002T130000Z\r\nSUMMARY:Moved to 1pm\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
const moved = events.find(e => e.start.getUTCDate() === 2);
|
||||
expect(moved.title).toBe('Moved to 1pm');
|
||||
expect(moved.start.getUTCHours()).toBe(13);
|
||||
});
|
||||
|
||||
test('malformed DTSTART is reported as an error, not thrown', () => {
|
||||
const { events, errors } = parseIcs(ics(
|
||||
'BEGIN:VEVENT\r\nUID:9\r\nDTSTART:notadate\r\nSUMMARY:Broken\r\nEND:VEVENT'
|
||||
), RANGE);
|
||||
expect(events).toHaveLength(0);
|
||||
expect(errors.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('event count is capped at 500', () => {
|
||||
let body = '';
|
||||
for (let i = 0; i < 600; i++) {
|
||||
body += `BEGIN:VEVENT\r\nUID:x${i}\r\nDTSTART:20261001T${String(i % 24).padStart(2, '0')}0000Z\r\nSUMMARY:E${i}\r\nEND:VEVENT\r\n`;
|
||||
}
|
||||
const { events } = parseIcs(ics(body), RANGE);
|
||||
expect(events).toHaveLength(500);
|
||||
});
|
||||
|
||||
test('unfoldLines joins folded lines (RFC: CRLF + one WSP removed)', () => {
|
||||
// Two leading spaces: fold char removed, second space is content
|
||||
expect(unfoldLines('SUMMARY:hello\r\n world')).toEqual(['SUMMARY:hello world']);
|
||||
// Exactly one space: fully joined
|
||||
expect(unfoldLines('SUMMARY:hello\r\n world')).toEqual(['SUMMARY:helloworld']);
|
||||
});
|
||||
|
||||
test('parseIcsDate handles UTC, floating, and date forms', () => {
|
||||
expect(parseIcsDate('20261007T140000Z').toISOString()).toBe('2026-10-07T14:00:00.000Z');
|
||||
expect(parseIcsDate('20261007')).toEqual(new Date(2026, 9, 7));
|
||||
expect(parseIcsDate('20261007T140000')).toEqual(new Date(2026, 9, 7, 14, 0, 0));
|
||||
expect(parseIcsDate('garbage')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* DC-161: Radicale provisioner unit tests.
|
||||
* The APR1 vectors below are ground truth generated by `openssl passwd -apr1
|
||||
* -salt <salt> <pw>` on this host — the Node port must reproduce them
|
||||
* byte-for-byte or Radicale's htpasswd auth will reject the user.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { apr1Hash, generatePassword, writeRadicaleConfig, registerAutoWiredSource } = require('../../src/calendar/radicale-provisioner');
|
||||
|
||||
describe('DC-161 radicale-provisioner', () => {
|
||||
describe('apr1Hash — openssl ground-truth vectors', () => {
|
||||
test.each([
|
||||
['MyTestPass123', 'abcd1234', '$apr1$abcd1234$DYT32mQm7IRMuLT7wjldt.'],
|
||||
['calender-wire-2026', 'zzzz9999', '$apr1$zzzz9999$WIDHV/rdr9g9GeH7PCFNk.'],
|
||||
])('hashes %s with salt %s to openssl output', (pw, salt, expected) => {
|
||||
expect(apr1Hash(pw, salt)).toBe(expected);
|
||||
});
|
||||
|
||||
test('generates an 8-char salt and 22-char hash when salt omitted', () => {
|
||||
const h = apr1Hash('somepass');
|
||||
const parts = h.split('$'); // ['', 'apr1', salt, hash]
|
||||
expect(parts[1]).toBe('apr1');
|
||||
expect(parts[2]).toHaveLength(8);
|
||||
expect(parts[3]).toHaveLength(22);
|
||||
});
|
||||
|
||||
test('same password + salt is deterministic; different salts differ', () => {
|
||||
expect(apr1Hash('x', 'saltsalt')).toBe(apr1Hash('x', 'saltsalt'));
|
||||
expect(apr1Hash('x', 'saltsalt')).not.toBe(apr1Hash('x', 'othersal'));
|
||||
});
|
||||
|
||||
test('truncates long salts to 8 chars (Apache behavior)', () => {
|
||||
expect(apr1Hash('x', '1234567890123')).toBe(apr1Hash('x', '12345678'));
|
||||
});
|
||||
});
|
||||
|
||||
test('generatePassword is 24 chars and unique per call', () => {
|
||||
const a = generatePassword();
|
||||
const b = generatePassword();
|
||||
expect(a).toHaveLength(24);
|
||||
expect(b).toHaveLength(24);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toMatch(/^[A-Za-z0-9]{24}$/);
|
||||
});
|
||||
|
||||
describe('writeRadicaleConfig', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'radicale-test-')); });
|
||||
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
||||
|
||||
test('writes config + users file with htpasswd line', async () => {
|
||||
await writeRadicaleConfig(tmpDir, { user: 'dashcaddy', pass: 'secret123' });
|
||||
const config = fs.readFileSync(path.join(tmpDir, 'config'), 'utf8');
|
||||
const users = fs.readFileSync(path.join(tmpDir, 'users'), 'utf8');
|
||||
expect(config).toContain('[auth]');
|
||||
expect(config).toContain('type = htpasswd');
|
||||
expect(config).toContain('htpasswd_filename = /config/users');
|
||||
expect(users).toMatch(/^dashcaddy:\$apr1\$[./0-9A-Za-z]{8}\$[./0-9A-Za-z]{22}\n$/);
|
||||
// The plaintext password must never appear in any written file
|
||||
expect(users).not.toContain('secret123');
|
||||
expect(config).not.toContain('secret123');
|
||||
});
|
||||
|
||||
test('users file is written 0600', async () => {
|
||||
await writeRadicaleConfig(tmpDir, { user: 'u', pass: 'p' });
|
||||
const mode = fs.statSync(path.join(tmpDir, 'users')).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerAutoWiredSource', () => {
|
||||
function makeMocks() {
|
||||
const stored = {};
|
||||
const items = [];
|
||||
return {
|
||||
stored, items,
|
||||
calendarStateManager: {
|
||||
addItem: jest.fn(async (item) => { items.push(item); return item; }),
|
||||
read: jest.fn(async () => items),
|
||||
},
|
||||
credentialManager: {
|
||||
store: jest.fn(async (key, value) => { stored[key] = value; }),
|
||||
retrieve: jest.fn(async (key) => stored[key] ?? null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('stores credentials under calendar key and registers an enabled source', async () => {
|
||||
const mocks = makeMocks();
|
||||
const source = await registerAutoWiredSource({
|
||||
calendarStateManager: mocks.calendarStateManager,
|
||||
credentialManager: mocks.credentialManager,
|
||||
subdomain: 'calendar',
|
||||
serviceUrl: 'https://calendar.sami',
|
||||
creds: { user: 'dashcaddy', pass: 'pw123' },
|
||||
});
|
||||
expect(source.url).toBe('https://calendar.sami/dashcaddy/');
|
||||
expect(source.autoProvisioned).toBe(true);
|
||||
expect(source.enabled).toBe(true);
|
||||
expect(mocks.calendarStateManager.addItem).toHaveBeenCalledTimes(1);
|
||||
const saved = JSON.parse(mocks.stored['calendar:radicale:calendar']);
|
||||
expect(saved).toEqual({ user: 'dashcaddy', pass: 'pw123' });
|
||||
});
|
||||
|
||||
test('trailing slash in serviceUrl does not double-slash the principal URL', async () => {
|
||||
const mocks = makeMocks();
|
||||
const source = await registerAutoWiredSource({
|
||||
calendarStateManager: mocks.calendarStateManager,
|
||||
credentialManager: mocks.credentialManager,
|
||||
subdomain: 'cal',
|
||||
serviceUrl: 'https://cal.sami/',
|
||||
creds: { user: 'u', pass: 'p' },
|
||||
});
|
||||
expect(source.url).toBe('https://cal.sami/u/');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user