Files
API/test/calendar/events.router.test.ts
T
Paddy a574e93359 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>
2026-09-28 22:15:59 +02:00

274 lines
10 KiB
TypeScript

import {describe, expect, it, vi, beforeEach} from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
getAllEvents: vi.fn(),
getAllEventsAdmin: vi.fn(),
getEventById: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
moveEvent: vi.fn(),
getNextUpcomingEvent: vi.fn()
}));
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import * as EventService from '../../src/models/calendar/events/events.service.js';
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {eventsRouter} from '../../src/models/calendar/events/events.router.js';
/**
* Step 4 of docs/calendar-auth-migration.md at the route level. The unit test
* on credentials.service covers the password table; this covers the thing that
* table is wired into, which is where the interesting mistakes live:
*
* - the public calendar has to stay readable with no session and no password,
* - the shared password has to keep working for the restricted calendars,
* because iCal clients cannot send a cookie,
* - and every write has to be behind the session cookie *and* an explicit
* calendar permission, not merely behind "is signed in".
*/
const app = express();
app.use(express.json());
app.use('/calendar/events', eventsRouter);
const signedInAs = (apps: string[], disabled = false) => {
(auth.api.getSession as any).mockResolvedValue({user: {id: 'admin-1'}});
(UsersService.loadAccess as any).mockResolvedValue({
id: 'admin-1',
email: 'a@nachklang.art',
displayName: 'A',
disabled,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
};
const signedOut = () => {
(auth.api.getSession as any).mockResolvedValue(null);
};
const validEvent = {
calendarId: 1,
name: 'Konzert',
startDateTime: '2026-04-18T19:00:00Z',
endDateTime: '2026-04-18T21:00:00Z'
};
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'});
(EventService.createEvent as any).mockResolvedValue(1);
(EventService.updateEvent as any).mockResolvedValue(1);
(EventService.moveEvent as any).mockResolvedValue(true);
(EventService.deleteEvent as any).mockResolvedValue(true);
signedOut();
});
describe('reading', () => {
it('serves the public calendar anonymously', async () => {
// The property nachklang.art depends on. No cookie, no password.
await request(app).get('/calendar/events/public/json').expect(200);
// And as the non-admin view: an anonymous caller must not see drafts.
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('refuses a restricted calendar with neither session nor password', async () => {
await request(app).get('/calendar/events/members/json').expect(403);
});
it('serves a restricted calendar to a shared password, without drafts', async () => {
await request(app)
.get('/calendar/events/members/json')
.query({password: 'member-secret'})
.expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('gives a signed-in editor the admin view instead', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json').expect(200);
expect(EventService.getAllEventsAdmin).toHaveBeenCalled();
expect(EventService.getAllEvents).not.toHaveBeenCalled();
});
it('treats a signed-in user without the calendar permission as anonymous', async () => {
// Not a 403: they may still read the public calendar like anyone else.
signedInAs(['tickets']);
await request(app).get('/calendar/events/public/json').expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
await request(app).get('/calendar/events/members/json').expect(403);
});
it('still serves the public calendar when the admin database is down', async () => {
(auth.api.getSession as any).mockRejectedValue(new Error('ECONNREFUSED'));
await request(app).get('/calendar/events/public/json').expect(200);
});
// The endpoint www.nachklang.art actually calls for its next-event teaser.
// Tested separately from /json because it takes a different code path - it
// has no admin view and no editor branch - so covering /json proves nothing
// about it, and its failure is invisible until someone notices the website
// has gone quiet.
it('serves the next upcoming event anonymously on the public calendar', async () => {
await request(app).get('/calendar/events/public/json/next').expect(200);
// And without asking the admin database who the caller is: the public
// feed must not acquire a dependency it has never had.
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('refuses the next upcoming event on a restricted calendar without a credential', async () => {
await request(app).get('/calendar/events/members/json/next').expect(403);
});
it('serves the next upcoming event to a shared password', async () => {
await request(app)
.get('/calendar/events/members/json/next')
.query({password: 'member-secret'})
.expect(200);
});
it('serves the next upcoming event to a signed-in editor', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json/next').expect(200);
});
it('does not consult the admin database for the anonymous public iCal export', async () => {
await request(app).get('/calendar/events/public/ical').expect(200);
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([]);
await request(app).get('/calendar/events/public/ical').expect(200);
await request(app).get('/calendar/events/members/ical').expect(403);
await request(app)
.get('/calendar/events/members/ical')
.query({password: 'member-secret'})
.expect(200);
});
});
describe('writing', () => {
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 401 when signed out', async (method, path) => {
await (request(app) as any)[method](path).send(validEvent).expect(401);
});
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 403 without the calendar permission', async (method, path) => {
signedInAs(['tickets', 'feedback', 'admin']);
await (request(app) as any)[method](path).send(validEvent).expect(403);
});
it('answers 403 for a disabled account that still holds the permission', async () => {
signedInAs(['calendar'], true);
await request(app).post('/calendar/events').send(validEvent).expect(403);
});
it('records the writer as an admin user id, never a legacy one', async () => {
signedInAs(['calendar']);
await request(app).post('/calendar/events').send(validEvent).expect(201);
const written = (EventService.createEvent as any).mock.calls[0][0];
expect(written.createdByUserId).toBe('admin-1');
// Migration 003 made the legacy column nullable precisely so this can be unset.
expect(written.createdById).toBeUndefined();
});
it('refuses the shared password as a way to write', async () => {
// The passwords are a read fallback for clients that cannot hold a
// session. They must never become an editing credential.
await request(app)
.post('/calendar/events')
.query({password: 'member-secret'})
.send(validEvent)
.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);
});
});