Escape and fold the iCal export, serve it as text/calendar, add subscription links

- iCal export: RFC 5545 text escaping for SUMMARY, DESCRIPTION and LOCATION,
  line folding at 75 octets (UTF-8 aware), CRLF line endings, and
  Content-Type text/calendar instead of the inferred text/html. A line break
  in a description used to end the property early; the new calendar
  frontend has a multi-line description field.
- formatDate no longer shifts the event's end date in place.
- GET /calendar/events/subscriptions: the iCal URL of every calendar, with
  its shared password where needed, for the calendar app's "Abonnieren"
  dialog. Editors only (requireAppAccess('calendar')), the shared password
  is not accepted there, never cached; calendars without a configured
  credential are left out.
- Tests for both; documented in docs/calendar-ical.md.

Backwards compatible with the current Angular calendar app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:15:59 +02:00
parent d522f35663
commit a574e93359
6 changed files with 355 additions and 8 deletions
+49
View File
@@ -68,6 +68,8 @@ const validEvent = {
beforeEach(() => {
vi.clearAllMocks();
process.env.MEMBER_CREDENTIAL = 'member-secret';
process.env.CHOIR_CREDENTIAL = 'choir & more';
process.env.MANAGEMENT_CREDENTIAL = '';
(EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
@@ -164,6 +166,13 @@ describe('reading', () => {
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('serves the iCal export as text/calendar', async () => {
const res = await request(app).get('/calendar/events/public/ical').expect(200);
expect(res.headers['content-type']).toBe('text/calendar; charset=utf-8');
expect(res.headers['content-disposition']).toBe('attachment; filename=Nachklang_calendar.ics');
});
it('keeps the shared password working on the iCal export', async () => {
(EventService.getAllEvents as any).mockResolvedValue([]);
@@ -222,3 +231,43 @@ describe('writing', () => {
.expect(401);
});
});
describe('subscriptions', () => {
it('is not for anyone signed out, or signed in without the calendar permission', async () => {
await request(app).get('/calendar/events/subscriptions').expect(401);
signedInAs(['tickets']);
await request(app).get('/calendar/events/subscriptions').expect(403);
});
it('refuses the shared password as a way in', async () => {
// Otherwise one calendar's password would unlock all the others.
await request(app).get('/calendar/events/subscriptions').query({password: 'member-secret'}).expect(401);
});
it('lists a ready-made iCal URL per configured calendar, never cached', async () => {
signedInAs(['calendar']);
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toEqual([
{calendar: 'public', icalUrl: 'http://localhost:3000/calendar/events/public/ical'},
{calendar: 'members', icalUrl: 'http://localhost:3000/calendar/events/members/ical?password=member-secret'},
// URL-encoded, so a password with & or spaces survives.
{calendar: 'choir', icalUrl: 'http://localhost:3000/calendar/events/choir/ical?password=choir%20%26%20more'},
// management is missing: its credential is unset, and a URL without one could never work.
{calendar: 'birthdays', icalUrl: 'http://localhost:3000/calendar/events/birthdays/ical?password=choir%20%26%20more'}
]);
});
it('hands out URLs that actually open the calendar', async () => {
signedInAs(['calendar']);
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
const members = res.body.find((s: {calendar: string}) => s.calendar === 'members');
signedOut();
const path = new URL(members.icalUrl);
await request(app).get(path.pathname + path.search).expect(200);
});
});