Compare commits

..
Author SHA1 Message Date
PaddyandClaude Opus 5.5 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
17 changed files with 370 additions and 391 deletions

No files matched your search

+2 -3
View File
@@ -47,9 +47,8 @@ and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
A permission is **(app, role)** in `user_app_permissions`, keyed on
`(user_id, app, role)` so one user can hold several roles per app. Two roles exist: `access`
(full access) and `read` (read-only, not for `admin`) - see `docs/admin-roles.md`, including
how `requireAppAccess` enforces `read` by HTTP method; `APP_ROLES` in `admin.schema.ts` is the contract,
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract,
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
narrow to one role, and queries the database on every request (no cookie cache) so disabling
-73
View File
@@ -1,73 +0,0 @@
# Admin roles: full access and read-only
Added 2026-10-29. A user's permission is a list of **(app, role)** grants in
`user_app_permissions` (see `src/models/admin/admin.schema.ts`). Each app can now be granted at
one of two levels:
| Role | UI label (admin app) | Apps | Meaning |
|----------|----------------------|-------------------------------|--------------------------------------------|
| `access` | Vollzugriff | calendar, feedback, tickets, admin | Everything in that app, as before. |
| `read` | Nur lesen | calendar, feedback, tickets | See everything, change nothing. |
Rules:
- **`access` and `read` on the same app are mutually exclusive.** `toPermissions` returns `null`
for a list holding both, so `PUT /admin/users/:id/permissions` and `POST /admin/invitations`
answer 400. There is no "higher one wins".
- **`admin` (user management) has no `read` role.** Viewing it means viewing other people's
email addresses, IPs and sessions; that is a different trust level from viewing concerts.
- Existing grants were not migrated: every pre-existing row is `access` and still means full
access.
## Enforcement
`requireAppAccess(app)` (no role argument - which is how every app router uses it) does, after
the existing session/disabled/has-any-grant checks:
- `GET`, `HEAD`, `OPTIONS` - allowed for any grant on the app.
- anything else - allowed only with `access` (`canWrite`). A `read`-only user gets
`403 {status: 'FORBIDDEN', message: 'Du hast für diesen Bereich nur Lesezugriff.'}`.
This is by method on purpose: a write route added later is covered without being marked. It
relies on one convention that **must stay true**: admin routes read with GET and change with
POST/PUT/PATCH/DELETE. When this was built, every GET handler in tickets, feedback and calendar
was checked to call only read functions (no SQL writes, no mail). A GET with a side effect, or a
POST that only reads, breaks the read-only role in one direction or the other.
Consequences worth knowing:
- "Resend confirmation email" (tickets) is a POST and therefore not available read-only - by
decision: it has an effect outside the system.
- CSV exports and reports are GETs and therefore available read-only - also by decision.
Read-only means all data, including personal data. A restricted variant would be a new role.
- `requireAppAccess(app, role)` with an explicit role does **not** apply the method rule: the
caller has already named exactly the grant it needs.
## Telling the frontend
- `GET /tickets/admin/me` and `GET /feedback/admin/me` return `readOnly: boolean` alongside
`email` and `fullName`. Frontends hide write controls when it is `true`; the API refuses the
writes regardless.
- `GET /admin/me` returns the full `permissions` list; `isReadOnly(permissions, app)` in
`admin.schema.ts` is the same rule.
## Calendar
The API side is done: calendar writes sit behind `requireAppAccess('calendar')` and so refuse a
`read`-only user, and the optional-cookie reads (`/:calendar/json`, `/ical`) check
`access.apps.includes('calendar')`, which a `read` grant satisfies - read-only users see the
restricted calendars.
The current Angular frontend (`Calendar_Frontend`) was deliberately **not** changed, because it
is being rewritten in Next.js. Until then a read-only calendar user sees the edit buttons and
gets the 403 above when using them. Contract for the rewrite: there is no `/calendar/.../me`
with `readOnly`; read `GET /admin/me` and compute `isReadOnly(permissions, 'calendar')` - or add
a calendar `/me` mirroring tickets/feedback.
## Adding a finer role later
`canWrite` checks for `access` specifically, so a new role (e.g. `tickets:redeem`) grants no
writes through the method rule. Watch out: tickets and feedback apply the role-less
`requireAdminAuth` once at the top of their admin router, so that gate would already refuse the
new role's POST before any route-level `requireAppAccess(app, 'redeem')` runs. A finer role
therefore needs `canWrite` (or the top-level gate) taught about it, not just a route-level check.
+65
View File
@@ -0,0 +1,65 @@
# Calendar iCal export and subscriptions
How `GET /calendar/events/{calendar}/ical` builds its file, what changed on 2026-09-28, and the
endpoint the calendar app uses to hand editors ready-made subscription URLs.
## The export
`icalgenerator.service.ts` turns a calendar's `PUBLIC` events into one `VCALENDAR`:
- Timed events: `DTSTART;TZID=Europe/Berlin:` / `DTEND;TZID=Europe/Berlin:` formatted with the
**API process's local** `getHours()` etc. The times are therefore only right if the process runs
in Berlin time (`TZ=Europe/Berlin`, or a server in that zone). Nothing enforces this. Inferred
from the code; not checked against the production server.
- Whole-day events: `VALUE=DATE`, and the stored end date is treated as the *inclusive* last day -
the generator adds one day for `DTEND`. The calendar app stores whole-day events as 00:00 to
23:59 Berlin time for this reason.
- `repeatFrequency` is written verbatim as `RRULE:FREQ=<value>` (no interval, count or end).
- `public` needs no credential; the other calendars take `?password=` because a calendar client
cannot send the session cookie (see `calendar-auth-migration.md`).
## RFC 5545 conformance (2026-09-28)
Before this date the generator wrote text values raw. A line break in a description ended the
`DESCRIPTION` property early and the rest was read as a garbage property; unescaped `,` and `;`
are invalid in text values. The new calendar frontend has a multi-line description field, so this
went from theoretical to certain. Now:
- `SUMMARY`, `DESCRIPTION` and `LOCATION` go through `escapeText` (`\` `;` `,` and line breaks,
backslash first).
- `URL` is a URI value and is not escaped; line breaks are stripped from it.
- Every content line is folded at 75 octets (UTF-8 aware - a multi-byte character is never split)
and the file uses CRLF line endings throughout (`toContentLines`).
- `formatDate` no longer shifts the caller's `Date` when it adds the whole-day end's extra day.
- The response is `Content-Type: text/calendar; charset=utf-8`. Before, Express inferred
`text/html` from the string body; calendar apps coped, but it was wrong.
Pinned by `test/calendar/icalgenerator.service.test.ts`.
Not changed, worth knowing: `ORGANIZER` is written as a bare display name (`ORGANIZER:Anna`),
while RFC 5545 expects a cal-address (`ORGANIZER;CN=Anna:mailto:…`). Calendar apps have tolerated
it so far.
## `GET /calendar/events/subscriptions`
For the calendar app's "Abonnieren" dialog. Behind `requireAppAccess('calendar')`; the shared
password is **not** accepted here (one calendar's password must not reveal the others).
Returns `[{calendar, icalUrl}]` in `calendarNames` order, built from `API_BASE_URL`:
- `public` without a password;
- every other calendar with `?password=<its shared password>`, URL-encoded;
- a calendar whose credential env var is unset is left out rather than listed with a URL that
cannot work.
`choir` and `birthdays` share `CHOIR_CREDENTIAL`, so rotating it changes both URLs - and breaks
every existing subscription to either. The response is sent with `Cache-Control: no-store`
because it carries the passwords.
Signed-in editors can read every event of every calendar anyway, so handing them the passwords
exposes nothing they could not already see.
## Found while doing this
- Only the admin pool reads `DB_PORT`; `Calendar.db.ts` (and the feedback/tickets pools) always
connect to 3306. A local test database for the calendar has to listen there.
+1 -15
View File
@@ -2,14 +2,9 @@ import express from 'express';
import {fromNodeHeaders} from 'better-auth/node';
import {auth} from './admin.auth.js';
import * as UsersService from './users/users.admin.service.js';
import {AppName, AppPermission, AppRole, canWrite} from './admin.schema.js';
import {AppName, AppPermission, AppRole} from './admin.schema.js';
import {sendServerError} from './admin.errors.js';
// What a read-only grant may do. Every admin route reads with GET and changes
// with POST/PUT/PATCH/DELETE - keep it that way, or a read-only user either
// loses a view or gains a write.
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* The one authenticator for every admin area in this API. It replaces
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
@@ -136,15 +131,6 @@ export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestH
return;
}
// The read-only level, enforced by method rather than per route so a
// write route added later is covered without being marked. Only the
// role-less form applies it: a caller asking for a specific role has
// already said exactly what is required.
if (role === undefined && !READ_METHODS.has(req.method) && !canWrite(access.permissions, app)) {
forbidden(res, 'Du hast für diesen Bereich nur Lesezugriff.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
+4 -36
View File
@@ -35,26 +35,13 @@ export const isAppName = (value: unknown): value is AppName => {
*/
export const ACCESS_ROLE = 'access';
/**
* Read-only access to an app: everything `access` may see, nothing it may
* change. requireAppAccess enforces it by HTTP method - a user holding only
* this role gets GET/HEAD/OPTIONS and a 403 for anything else - so a new write
* route is covered without anyone remembering to mark it.
*
* It and `access` are mutually exclusive on one app (toPermissions rejects a
* list holding both), because "full access and also read-only" has no sensible
* reading. The `admin` app deliberately has no read role: viewing user
* management means viewing other people's addresses, IPs and sessions.
*/
export const READ_ROLE = 'read';
export type AppRole = string;
/** Every role that exists, per app, in display order. Extend to add one. */
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
calendar: [ACCESS_ROLE, READ_ROLE],
feedback: [ACCESS_ROLE, READ_ROLE],
tickets: [ACCESS_ROLE, READ_ROLE],
calendar: [ACCESS_ROLE],
feedback: [ACCESS_ROLE],
tickets: [ACCESS_ROLE],
admin: [ACCESS_ROLE]
};
@@ -98,7 +85,7 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
}
const seen = new Set<string>();
const unique = permissions.filter(permission => {
return permissions.filter(permission => {
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
@@ -106,11 +93,6 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
seen.add(key);
return true;
});
// Rejected rather than resolved to either level: whichever one we picked,
// the caller asked for something else.
const contradictory = APP_NAMES.some(app => seen.has(`${app}:${ACCESS_ROLE}`) && seen.has(`${app}:${READ_ROLE}`));
return contradictory ? null : unique;
};
/** The distinct apps a permission list grants any access to. */
@@ -118,20 +100,6 @@ export const appsOf = (permissions: AppPermission[]): AppName[] => {
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
};
/**
* Whether these grants allow changing anything in `app`. Only `access` does:
* `read` is the read-only level, and a finer role added later grants exactly
* what its own check says, not writes in general.
*/
export const canWrite = (permissions: AppPermission[], app: AppName): boolean => {
return permissions.some(permission => permission.app === app && permission.role === ACCESS_ROLE);
};
/** Holds some grant on `app`, but none that allows changes. */
export const isReadOnly = (permissions: AppPermission[], app: AppName): boolean => {
return appsOf(permissions).includes(app) && !canWrite(permissions, app);
};
export interface UserTable {
id: string;
name: string;
@@ -21,7 +21,13 @@ dotenv.config();
* test/calendar/credentials.service.test.ts.
*/
const credentialFor = (calendarName: string): string | undefined => {
/**
* The shared password for a calendar, or undefined for `public` (which needs
* none), an unknown calendar, or one whose credential is not configured.
* Exported for the subscriptions endpoint, which hands signed-in editors the
* ready-made iCal URLs - see GET /calendar/events/subscriptions.
*/
export const credentialFor = (calendarName: string): string | undefined => {
switch (calendarName) {
case 'members':
return process.env.MEMBER_CREDENTIAL;
+60 -1
View File
@@ -8,6 +8,7 @@ import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service.js';
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
import {API_BASE_URL} from '../../admin/admin.config.js';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
@@ -423,7 +424,12 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let file = await iCalService.convertToIcal(events);
// Send the ical file back
res.set({'Content-Disposition': 'attachment; filename=' + fileName + '.ics'});
// text/calendar, not the text/html that send() infers from a string - calendar apps
// coped with the wrong type, but it was wrong (RFC 5545 §8.1).
res.set({
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'attachment; filename=' + fileName + '.ics'
});
res.status(200).send(file);
} catch (e: any) {
let errorGuid = Guid.create().toString();
@@ -436,6 +442,59 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
}
});
/**
* @swagger
* /calendar/events/subscriptions:
* get:
* summary: The iCal subscription URL of every calendar
* description: >
* Ready-to-use iCal URLs for each calendar, with the calendar's shared password
* already in the query string where it needs one - for the calendar app's
* "Abonnieren" dialog. A calendar whose password is not configured is left out
* rather than listed with a URL that cannot work. Requires a signed-in account
* with the calendar permission: those editors can read every event anyway, so the
* passwords tell them nothing new. Never cached.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* calendar:
* type: string
* example: members
* icalUrl:
* type: string
* example: https://api.nachklang.art/calendar/events/members/ical?password=…
* 401:
* description: Unauthorized - not signed in
* 403:
* description: Forbidden - the account lacks the calendar permission
*/
eventsRouter.get('/subscriptions', requireCalendarAccess, (req: Request, res: Response) => {
const base = API_BASE_URL.replace(/\/+$/, '');
const subscriptions = Array.from(calendarNames.keys()).flatMap((calendar) => {
const url = `${base}/calendar/events/${calendar}/ical`;
if (calendar === 'public') {
return [{calendar, icalUrl: url}];
}
const password = CredentialService.credentialFor(calendar);
return password ? [{calendar, icalUrl: `${url}?password=${encodeURIComponent(password)}`}] : [];
});
// These URLs carry the shared passwords; keep them out of every cache on the way.
res.set('Cache-Control', 'no-store');
res.status(200).send(subscriptions);
});
/**
* @swagger
* /calendar/events:
@@ -13,7 +13,7 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
addEventToFile(ical, event);
}
return serializeIcalFile(ical);
return toContentLines(serializeIcalFile(ical));
} catch (err) {
throw err;
}
@@ -116,9 +116,11 @@ const addEventToFile = (ical: iCalFile, event: Event) => {
* @param event
*/
const createIcalEvent = (event: Event): iCalEvent => {
let description = event.description ? event.description + '\n' : '';
let location = event.location ? event.location + '\n' : '';
let url = event.url ? event.url + '\n' : '';
// Free text is escaped (see escapeText); the URL is a URI value, which has
// no escapes - it only must not break the line.
let description = event.description ? escapeText(event.description) + '\n' : '';
let location = event.location ? escapeText(event.location) + '\n' : '';
let url = event.url ? event.url.replace(/[\r\n]+/g, '') + '\n' : '';
return {
header: 'BEGIN:VEVENT\n',
@@ -128,7 +130,7 @@ const createIcalEvent = (event: Event): iCalEvent => {
start: formatDate(event.startDateTime, event.wholeDay) + '\n',
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
summary: event.name + '\n',
summary: escapeText(event.name) + '\n',
description: description,
location: location,
url: url,
@@ -143,8 +145,11 @@ const createIcalEvent = (event: Event): iCalEvent => {
* @param wholeDayFormat
* @param isEndDate
*/
const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
const formatDate = (input: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
let returnString = '';
// A copy: this used to shift the caller's Date in place, so the event object
// came out of the export with its end a day later than it went in.
const date = new Date(input);
// We need to do this for whole day events as otherwise the event ends one day too early
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
@@ -184,6 +189,58 @@ export interface iCalEvent {
footer: string;
}
/**
* RFC 5545 §3.3.11 TEXT escaping. Without it a description with a line break
* ended the DESCRIPTION property early and the rest of the text was read as a
* new (garbage) property, and an unescaped `,` or `;` is invalid in the value.
* The backslash goes first, or the escapes added after it would be doubled.
*/
export const escapeText = (value: string): string =>
value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n');
const MAX_LINE_OCTETS = 75;
/**
* RFC 5545 §3.1 line folding: no content line longer than 75 octets, a longer
* one continues on the next line after a single leading space. Counts UTF-8
* octets, not characters, and never splits inside a character - "ü" is two
* octets and a fold between them corrupts it.
*/
export const foldLine = (line: string): string => {
const parts: string[] = [];
let current = '';
let octets = 0;
for (const char of line) {
const size = Buffer.byteLength(char, 'utf8');
// A continuation line's leading space counts towards its 75.
const limit = parts.length === 0 ? MAX_LINE_OCTETS : MAX_LINE_OCTETS - 1;
if (octets + size > limit) {
parts.push(current);
current = '';
octets = 0;
}
current += char;
octets += size;
}
parts.push(current);
return parts.join('\r\n ');
};
/**
* The generator builds the file with bare `\n` line ends; the format wants
* every content line folded and terminated by CRLF (RFC 5545 §3.1).
*/
const toContentLines = (ical: string): string =>
ical
.split('\n')
.filter((line) => line !== '')
.map(foldLine)
.join('\r\n') + '\r\n';
/**
* Checks if a given string is null, undefined or blank
* @param str The string to check
+1 -10
View File
@@ -3,7 +3,6 @@
*/
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../feedback.auth.js';
import {isReadOnly} from '../../admin/admin.schema.js';
import {sendServerError} from '../feedback.errors.js';
import {eventsAdminRouter} from './events.admin.router.js';
import {songsAdminRouter} from './songs.admin.router.js';
@@ -41,21 +40,13 @@ adminRouter.use(requireAdminAuth);
* type: string
* fullName:
* type: string
* readOnly:
* type: boolean
* description: Holds only the read role for feedback - every write answers 403.
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// Lets the frontend hide what the API would refuse anyway.
readOnly: isReadOnly(res.locals.admin.permissions, 'feedback')
});
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
});
/**
+1 -19
View File
@@ -1,6 +1,5 @@
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../tickets.auth.js';
import {isReadOnly} from '../../admin/admin.schema.js';
import {vouchersAdminRouter} from './vouchers.admin.router.js';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
import {eventsAdminRouter} from './events.admin.router.js';
@@ -22,30 +21,13 @@ adminRouter.use(requireAdminAuth);
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* fullName:
* type: string
* readOnly:
* type: boolean
* description: Holds only the read role for tickets - every write answers 403.
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// Lets the frontend hide what the API would refuse anyway.
readOnly: isReadOnly(res.locals.admin.permissions, 'tickets')
});
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
});
adminRouter.use('/vouchers', vouchersAdminRouter);
@@ -129,13 +129,12 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* type: array
* items:
* type: object
* required: [name, eventIds]
* required: [name, email, eventIds]
* properties:
* name:
* type: string
* email:
* type: string
* description: Optional - a guest can supply their own email when redeeming.
* eventIds:
* type: array
* items:
@@ -123,10 +123,10 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
throw new Error('at least one row is required');
}
for (const row of rows) {
if (!row.name || row.eventIds.length === 0) {
throw new Error('each row requires a name and at least one eligible event');
if (!row.name || !row.email || row.eventIds.length === 0) {
throw new Error('each row requires a name, email, and at least one eligible event');
}
if (row.email && !isValidEmail(row.email)) {
if (!isValidEmail(row.email)) {
throw new Error(`"${row.email}" does not look like a valid email address`);
}
}
@@ -147,7 +147,7 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
codes.push(code);
await conn.query(
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
[code, 'UNUSED', row.maxGuests, row.name, row.email || null, batchId, createdByEmail]
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail]
);
for (const eventId of row.eventIds) {
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
+1 -59
View File
@@ -15,7 +15,7 @@ import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models
const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as Mock;
const makeReq = (method = 'GET'): Request => ({method, headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
@@ -219,62 +219,4 @@ describe('requireAppAccess', () => {
expect(next).toHaveBeenCalled();
});
describe('read-only grant', () => {
const readOnlyUser = {
...activeUser,
permissions: [{app: 'tickets', role: 'read'}],
apps: ['tickets']
};
it.each(['GET', 'HEAD', 'OPTIONS'])('lets %s through', async method => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(readOnlyUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(method), res, next);
expect(next).toHaveBeenCalled();
});
it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('403s %s', async method => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(readOnlyUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(method), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({message: 'Du hast für diesen Bereich nur Lesezugriff.'}));
expect(next).not.toHaveBeenCalled();
});
it('does not let full access to another app write here', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'read'}, {app: 'feedback', role: 'access'}],
apps: ['feedback', 'tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq('POST'), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('lets full access write', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq('DELETE'), res, next);
expect(next).toHaveBeenCalled();
});
});
});
-41
View File
@@ -1,12 +1,9 @@
import {describe, expect, it} from 'vitest';
import {
ACCESS_ROLE,
READ_ROLE,
appsOf,
canWrite,
isAppPermission,
isAppRole,
isReadOnly,
toPermissions
} from '../../src/models/admin/admin.schema.js';
@@ -62,44 +59,6 @@ describe('toPermissions', () => {
it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]);
});
it('reads the read-only role', () => {
expect(toPermissions([{app: 'tickets', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: READ_ROLE}
]);
});
it('rejects full and read-only access to the same app together', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: READ_ROLE}])).toBeNull();
});
it('allows full access to one app next to read-only access to another', () => {
expect(toPermissions(['tickets', {app: 'feedback', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'feedback', role: READ_ROLE}
]);
});
it('rejects read-only access to user management', () => {
expect(toPermissions([{app: 'admin', role: READ_ROLE}])).toBeNull();
});
});
describe('canWrite / isReadOnly', () => {
it('lets only the access role write', () => {
const permissions = [
{app: 'tickets' as const, role: ACCESS_ROLE},
{app: 'feedback' as const, role: READ_ROLE}
];
expect(canWrite(permissions, 'tickets')).toBe(true);
expect(canWrite(permissions, 'feedback')).toBe(false);
expect(isReadOnly(permissions, 'tickets')).toBe(false);
expect(isReadOnly(permissions, 'feedback')).toBe(true);
});
it('is neither for an app without any grant', () => {
expect(canWrite([], 'calendar')).toBe(false);
expect(isReadOnly([], 'calendar')).toBe(false);
});
});
describe('isAppRole', () => {
+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);
});
});
+111
View File
@@ -0,0 +1,111 @@
import {describe, expect, it} from 'vitest';
import {convertToIcal, escapeText, foldLine} from '../../src/models/calendar/events/icalgenerator.service.js';
import {Event} from '../../src/models/calendar/events/event.interface.js';
/**
* The iCal export is read by every subscribed calendar app, so a malformed
* file is an outage nobody on our side sees. These pin RFC 5545's text
* escaping and line folding, which the generator did not do before: a line
* break in a description ended the DESCRIPTION property early, and the new
* calendar frontend's multi-line description field would have produced
* exactly that.
*/
const event = (overrides: Partial<Event> = {}): Event => ({
eventId: 1,
calendarId: 1,
uuid: 'uuid-1',
name: 'Konzert',
description: '',
startDateTime: new Date('2026-10-03T17:00:00Z'),
endDateTime: new Date('2026-10-03T19:00:00Z'),
createdDate: new Date('2026-01-01T12:00:00Z'),
location: '',
createdBy: 'Anna',
url: '',
wholeDay: false,
repeatFrequency: '',
...overrides
});
/** RFC 5545 unfolding: a CRLF followed by one space or tab joins the lines. */
const unfold = (ical: string): string[] => ical.replace(/\r\n[ \t]/g, '').split('\r\n').filter(Boolean);
describe('escapeText', () => {
it('escapes backslash, semicolon, comma and line breaks', () => {
expect(escapeText('a\\b;c,d')).toBe('a\\\\b\\;c\\,d');
expect(escapeText('Zeile 1\nZeile 2\r\nZeile 3\rZeile 4')).toBe('Zeile 1\\nZeile 2\\nZeile 3\\nZeile 4');
});
it('escapes the backslash first, so the others are not doubled', () => {
expect(escapeText(';')).toBe('\\;');
});
});
describe('foldLine', () => {
it('leaves a short line alone', () => {
expect(foldLine('SUMMARY:Konzert')).toBe('SUMMARY:Konzert');
});
it('keeps every physical line within 75 octets and unfolds back to the original', () => {
const line = 'DESCRIPTION:' + 'Probe im Gemeindehaus Süd – bitte Noten mitbringen. '.repeat(6);
const folded = foldLine(line);
for (const physical of folded.split('\r\n')) {
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
}
expect(folded.replace(/\r\n /g, '')).toBe(line);
});
it('never splits a multi-byte character', () => {
const line = 'SUMMARY:' + 'ü'.repeat(80);
for (const physical of foldLine(line).split('\r\n')) {
expect(physical.replace(/^ /, '')).toMatch(/^(SUMMARY:)?ü*$/);
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
}
});
});
describe('convertToIcal', () => {
it('terminates every line with CRLF', async () => {
const ical = await convertToIcal([event()]);
expect(ical.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ical.replace(/\r\n/g, '')).not.toContain('\n');
});
it('keeps a multi-line description inside one DESCRIPTION property', async () => {
const ical = await convertToIcal([event({description: 'Einlass 18:30\nBeginn 19:00; Eintritt frei, Spenden willkommen'})]);
const lines = unfold(ical);
expect(lines).toContain('DESCRIPTION:Einlass 18:30\\nBeginn 19:00\\; Eintritt frei\\, Spenden willkommen');
// Nothing leaked out as a line of its own.
expect(lines.some((l) => l.startsWith('Beginn'))).toBe(false);
});
it('escapes the title and the location too', async () => {
const lines = unfold(await convertToIcal([event({name: 'Konzert, Teil 2', location: 'Kirche; Karlsruhe'})]));
expect(lines).toContain('SUMMARY:Konzert\\, Teil 2');
expect(lines).toContain('LOCATION:Kirche\\; Karlsruhe');
});
it('does not escape the URL, but keeps it on one line', async () => {
const lines = unfold(await convertToIcal([event({url: 'https://nachklang.art/konzert?a=1,2'})]));
expect(lines).toContain('URL:https://nachklang.art/konzert?a=1,2');
});
it('still writes the yearly rule for birthdays', async () => {
const lines = unfold(await convertToIcal([event({repeatFrequency: 'YEARLY', wholeDay: true})]));
expect(lines).toContain('RRULE:FREQ=YEARLY');
});
it('leaves the event\'s own dates untouched', async () => {
// formatDate used to add the whole-day end's extra day to the caller's Date in place.
const whole = event({
wholeDay: true,
startDateTime: new Date('2026-10-03T00:00:00'),
endDateTime: new Date('2026-10-03T23:59:00')
});
const endBefore = whole.endDateTime.getTime();
await convertToIcal([whole]);
expect(whole.endDateTime.getTime()).toBe(endBefore);
});
});
-121
View File
@@ -1,121 +0,0 @@
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
import type {Application} from 'express';
import {createApp} from '../../src/app.factory.js';
import {READ_ROLE} from '../../src/models/admin/admin.schema.js';
import {closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
/**
* The read-only role end to end: a real session cookie through the real
* middleware into each app's routers. The writes are rejected before any
* handler runs, which is why this suite needs no tickets/feedback/calendar
* database - a 403 here proves the gate held, not that a query failed.
*/
let app: Application;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
const readOnlyEverywhere = async () => {
return createAndAcceptInvitation(app, 'reader@nachklang.art', 'Reader', [
{app: 'tickets', role: READ_ROLE},
{app: 'feedback', role: READ_ROLE},
{app: 'calendar', role: READ_ROLE}
]);
};
describe('read-only access', () => {
it('reports readOnly on each app\'s /me', async () => {
const {agent} = await readOnlyEverywhere();
const tickets = await agent.get('/tickets/admin/me');
const feedback = await agent.get('/feedback/admin/me');
expect(tickets.status).toBe(200);
expect(tickets.body.readOnly).toBe(true);
expect(feedback.status).toBe(200);
expect(feedback.body.readOnly).toBe(true);
});
it('reports readOnly false for full access', async () => {
const {agent} = await createAndAcceptInvitation(app, 'full@nachklang.art', 'Full', ['tickets']);
const res = await agent.get('/tickets/admin/me');
expect(res.status).toBe(200);
expect(res.body.readOnly).toBe(false);
});
it.each([
['post', '/tickets/admin/vouchers/wildcard'],
['post', '/tickets/admin/redemptions/1/resend-confirmation'],
['patch', '/tickets/admin/redemptions/1'],
['put', '/tickets/admin/events/1/settings'],
['delete', '/feedback/admin/submissions/1'],
['post', '/feedback/admin/events'],
['post', '/calendar/events'],
['delete', '/calendar/events/1']
] as const)('refuses %s %s', async (method, path) => {
const {agent} = await readOnlyEverywhere();
const res = await agent[method](path).send({});
expect(res.status).toBe(403);
expect(res.body.message).toBe('Du hast für diesen Bereich nur Lesezugriff.');
});
it('does not open user management', async () => {
const {agent} = await readOnlyEverywhere();
expect((await agent.get('/admin/users')).status).toBe(403);
});
});
describe('granting read-only access', () => {
const signedInAdmin = () => createAndAcceptInvitation(app, 'admin@nachklang.art', 'Admin', ['admin']);
it('stores the read role per app', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'feedback', role: 'access'}]});
expect(res.status).toBe(200);
expect(res.body.permissions).toEqual(expect.arrayContaining([
{app: 'tickets', role: READ_ROLE},
{app: 'feedback', role: 'access'}
]));
});
it('rejects full and read-only access to the same app', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'tickets', role: 'access'}]});
expect(res.status).toBe(400);
});
it('rejects inviting someone with read-only user management', async () => {
const {agent} = await signedInAdmin();
const res = await agent
.post('/admin/invitations')
.send({email: 'new@nachklang.art', name: 'New', permissions: [{app: 'admin', role: READ_ROLE}]});
expect(res.status).toBe(400);
});
});