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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user