- 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.
121 lines
4.8 KiB
JavaScript
121 lines
4.8 KiB
JavaScript
/**
|
|
* 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/');
|
|
});
|
|
});
|
|
});
|