Read calendar event creators from the admin module, and archive the old ones #14

Merged
Paddy merged 4 commits from feature/calendar-auth-cutover into master 2026-09-06 21:12:17 +00:00
12 changed files with 540 additions and 309 deletions
Showing only changes of commit b848d6eab9 - Show all commits
+18 -17
View File
@@ -5,27 +5,28 @@ These items were identified during a security review on 2026-05-02 and conscious
--- ---
## 1. Session credentials in URL query parameters (logged-in users) ## 1. Session credentials in URL query parameters (logged-in users) — CLOSED 2026-09-06
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers **Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
`sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers. `sessionId` and `sessionKey` were read from query parameters, which meant they appeared in
server access logs, browser history, proxy logs, and `Referer` headers.
**Fix (updated 2026-09-06):** Move the calendar onto the shared admin identity - **Fixed** by step 4 of `docs/calendar-auth-migration.md`: the calendar's write routes now sit
`requireAppAccess('calendar')` against the better-auth session cookie, per behind `requireAppAccess('calendar')` against the better-auth session cookie, and the read
`docs/calendar-auth-migration.md`. That closes this item outright rather than moving the routes resolve the same cookie optionally. No route reads `sessionId`/`sessionKey` any more,
credential to a safer place, and it is now the cheaper of the two: the feedback and tickets and the Angular frontend sends `withCredentials` instead of appending them to every URL. That
modules made the same move on 2026-09-06 for one line each. closed the item outright rather than moving the credential somewhere safer.
~~Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body.~~ No longer Two things this did *not* change, both deliberate:
the recommendation. Nothing on the server reads those two headers any more - the calendar's
query parameters are the last legacy credential path in the API - so this would build a second
mechanism just as the first is being retired. They survive only in the CORS `allowedHeaders`
list, and only until both frontends are redeployed.
Either fix requires a corresponding frontend update. - The shared calendar `password` parameter stays. An iCal client cannot send a cookie, so
this is the one caller that genuinely needs a credential in the URL. It grants read access
> Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup. to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
never be used to write.
- The legacy `/calendar/users/*` routes still exist. Nothing calls them any more, and a
legacy session they mint no longer opens anything, but they are still live
password-accepting endpoints. Step 5 removes them.
--- ---
@@ -36,9 +37,9 @@ Either fix requires a corresponding frontend update.
- `PUT /move/:eventId` (move) - `PUT /move/:eventId` (move)
- `DELETE /:eventId` (delete) - `DELETE /:eventId` (delete)
Currently any active user can edit, move, or delete any event regardless of who created it. This is acceptable while all users are trusted admins. Currently any account holding the `calendar` permission can edit, move, or delete any event regardless of who created it. This is acceptable while everyone holding it is a trusted admin.
**Fix:** When non-admin users are introduced, fetch the event first and verify `event.createdById === user.userId` before allowing the mutation. Add an `isAdmin` flag to the user model to let admins bypass the check. **Fix (updated 2026-09-06):** fetch the event first and verify `event.createdByUserId === res.locals.admin.id` before allowing the mutation`createdById`, the legacy INT, is no longer written and is gone at step 5. Rather than an `isAdmin` flag, the bypass belongs in the permission model that already exists: `requireAppAccess('calendar', 'manage')` alongside the current `access` role, which needs a row in `APP_ROLES` on both sides and nothing else.
--- ---
+2 -1
View File
@@ -41,7 +41,8 @@ CREATE TABLE `events` (
`calendar_id` int(11) NOT NULL, `calendar_id` int(11) NOT NULL,
`uuid` text NOT NULL, `uuid` text NOT NULL,
`created_date` datetime DEFAULT current_timestamp(), `created_date` datetime DEFAULT current_timestamp(),
`created_by_id` int(11) NOT NULL, -- Nullable since the cutover; see sql/calendar/003_allow_null_legacy_creator.sql.
`created_by_id` int(11) DEFAULT NULL,
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql. -- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Archived creator name; see sql/calendar/002_snapshot_legacy_creator_names.sql. -- Archived creator name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
+42 -16
View File
@@ -1,7 +1,7 @@
# Migrating the Calendar domain onto the admin identity module # Migrating the Calendar domain onto the admin identity module
Status: **steps 1 and 3 done** (2026-09-06), step 2 dropped by decision, part of step 5 Status: **steps 1-4 done** (2026-09-06), step 2 dropped by decision, part of step 5 brought
brought forward, step 4 next. forward. Only step 5, the removal of the legacy path, is left.
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
@@ -77,24 +77,50 @@ Each step is meant to leave production working on its own.
**Writes are not dual-written**, contrary to the original plan: before the cutover the **Writes are not dual-written**, contrary to the original plan: before the cutover the
request only ever carries a legacy session, so there is no admin id available to write. request only ever carries a legacy session, so there is no admin id available to write.
Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4. Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4.
4. **Switch the routes.** Replace the query-parameter session checks in 4. **Switch the routes.** ~~Replace the query-parameter session checks in `events.router.ts`
`events.router.ts` and `users.router.ts` with `requireAppAccess('calendar')`, and change and `users.router.ts` with `requireAppAccess('calendar')`, and change the Angular frontend
the Angular frontend to `withCredentials: true` against the same origin list. Deploy the to `withCredentials: true`.~~ **Done 2026-09-06.** `DEFERRED_SECURITY.md` item 1 is closed:
API first; the calendar frontend is broken between the two deploys, so pick a quiet no route reads `sessionId`/`sessionKey` any more.
time. This closes `DEFERRED_SECURITY.md` item 1.
Two things this step has to carry that the original sequence put in step 5: How it came out, route by route:
- **`sql/calendar/003_*.sql` must make `events.created_by_id` nullable** (`MODIFY - The four write routes sit behind `requireAppAccess('calendar')` as middleware. They
created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the answer 401 when signed out and 403 without the permission, where they used to answer 403
for both.
- The three read routes cannot use middleware - the same URL serves an anonymous visitor,
an iCal subscription holding a shared password, and a signed-in editor who should see
drafts. They call `resolveAccess` optionally instead (`signedInEditor` in the router),
and a signed-in user *without* the calendar permission is treated as anonymous rather
than refused, so they keep their access to the public calendar.
- `credentials.service.ts` lost its session half entirely and is now just the password
table. `hasAccess(calendar, password)`.
- `/calendar/users/*` was left alone. Nothing calls it and a session it mints opens
nothing, but they are live password-accepting endpoints - step 5 removes them.
Also: `calendar.nachklang.art` joined `DEFAULT_APP_ORIGINS` (better-auth `trustedOrigins`,
without which sign-out from the calendar fails while everything else works), and
`localhost:4200` joined the dev origins for the same reason.
Two things this step had to carry that the original sequence put in step 5:
- **`sql/calendar/003_allow_null_legacy_creator.sql` makes `events.created_by_id` nullable**
(`MODIFY created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
cutover would otherwise fail to insert - there is no legacy int id to write any more. cutover would otherwise fail to insert - there is no legacy int id to write any more.
`event_versions.version_created_by_id` is already nullable. The foreign key can stay `event_versions.version_created_by_id` is already nullable. The foreign key can stay
until step 5; it permits NULL. The same migration should re-run 002's idempotent until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch
name backfill, to catch anything created between the two deploys. anything created between the two migrations. Applying it early is safe - widening a
- **The public calendar must stay anonymous.** `hasAccess('public')` returns true before column to accept NULL cannot break the running pre-cutover build.
any credential check, and nachklang.art reads `/calendar/events/public/json` and - **The public calendar stays anonymous.** `hasAccess('public')` returns true before any
`/public/json/next` with no session at all. Pinned by credential check, and nachklang.art reads `/calendar/events/public/json` and
`test/calendar/credentials.service.test.ts` so this cannot regress quietly. `/public/json/next` with no session at all. Pinned at both levels - the password table in
`test/calendar/credentials.service.test.ts`, the routes themselves in
`test/calendar/events.router.test.ts` - so this cannot regress quietly.
**Deploy order:** migration 003, then the API, then the calendar frontend. The frontend is
broken between the last two (its old bundle sends query credentials the new API ignores),
so pick a quiet moment. Production also needs `calendar.nachklang.art` in the admin app's
`NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS`, which is a **build-time** value: a rebuild, not a
restart.
5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions` 5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions`
table, `created_by_id`, and the legacy half of the step 3 read (the `users` join and its table, `created_by_id`, and the legacy half of the step 3 read (the `users` join and its
`legacy_*` aliases - the snapshot fallback stays, it is what makes dropping the table `legacy_*` aliases - the snapshot fallback stays, it is what makes dropping the table
@@ -0,0 +1,32 @@
-- Nachklang e.V. Calendar module — step 4 of docs/calendar-auth-migration.md,
-- the cutover. Apply manually against the CALENDAR_DB database, after 002,
-- and BEFORE deploying the API build that goes with it:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 003_allow_null_legacy_creator.sql
--
-- From the cutover on, an event's creator is an admin-module user id. There is
-- no legacy calendar user id to write any more, and `events.created_by_id` is
-- NOT NULL - so without this the very first event created after the deploy
-- fails to insert. `event_versions.version_created_by_id` is already nullable.
--
-- The foreign key to `users` is kept: it permits NULL, so it costs nothing
-- until step 5 drops the column and the table together.
--
-- Applying this early is harmless. Widening a column to accept NULL cannot
-- break the running pre-cutover build, which always supplies a value, so this
-- can go out ahead of the deploy rather than during it.
ALTER TABLE `events`
MODIFY COLUMN `created_by_id` INT(11) NULL DEFAULT NULL;
-- Re-run of 002's backfill, to catch anything created between the two
-- migrations while the legacy path was still writing events. Idempotent by
-- construction: it only touches rows that have no snapshot yet.
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
+5 -1
View File
@@ -33,7 +33,11 @@ const localhostOrigins = [
'http://localhost:3000', 'http://localhost:3000',
'http://localhost:3001', 'http://localhost:3001',
'http://localhost:3002', 'http://localhost:3002',
'http://localhost:3003' 'http://localhost:3003',
// The Angular calendar frontend; `ng serve` defaults to 4200. Missing from
// this list, sign-out from the calendar answers 403 in dev only, which is a
// confusing thing to debug against a production config that is fine.
'http://localhost:4200'
]; ];
const trustedOrigins = isProd const trustedOrigins = isProd
+4 -2
View File
@@ -79,7 +79,8 @@ const parseList = (value: string | undefined, fallback: string[]): string[] => {
* They feed better-auth's `trustedOrigins`, which is what lets the tickets and * They feed better-auth's `trustedOrigins`, which is what lets the tickets and
* feedback admin areas call /admin/auth/sign-out from their own origin. That * feedback admin areas call /admin/auth/sign-out from their own origin. That
* became load-bearing with the step 4 cutover: before it, the only browser * became load-bearing with the step 4 cutover: before it, the only browser
* origin that ever reached /admin/auth was the admin app itself. * origin that ever reached /admin/auth was the admin app itself. The calendar
* joined them with its own cutover (docs/calendar-auth-migration.md step 4).
* *
* Hence the production default rather than an empty list. An origin missing * Hence the production default rather than an empty list. An origin missing
* here fails in a way that is easy to misread - sign-in works, the app works, * here fails in a way that is easy to misread - sign-in works, the app works,
@@ -89,7 +90,8 @@ const parseList = (value: string | undefined, fallback: string[]): string[] => {
*/ */
const DEFAULT_APP_ORIGINS = [ const DEFAULT_APP_ORIGINS = [
'https://tickets.nachklang.art', 'https://tickets.nachklang.art',
'https://feedback.nachklang.art' 'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
]; ];
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, DEFAULT_APP_ORIGINS) export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, DEFAULT_APP_ORIGINS)
@@ -1,73 +1,55 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import * as UserService from '../users/users.service.js';
dotenv.config(); dotenv.config();
/** /**
* Checks if the password gives admin privileges (view / create / edit / delete) * The shared calendar passwords, and nothing else.
* @param password *
* Before the step 4 cutover each function here also took a sessionId/sessionKey
* pair and checked it against the calendar's own sessions table, so "is this a
* signed-in user?" and "did they send the right shared password?" were tangled
* together in five places. Signed-in access is now decided by
* requireAppAccess('calendar') before the handler runs; what is left is the
* fallback for people who have no account at all.
*
* That fallback survives on purpose, for one reason: an iCal client subscribing
* to a calendar URL cannot send a cookie. Everything the Angular app does goes
* through the session cookie instead. See docs/calendar-auth-migration.md.
*
* `public` is deliberately open to everyone with no credential of any kind -
* nachklang.art reads it anonymously to show the next upcoming event. Pinned by
* test/calendar/credentials.service.test.ts.
*/ */
export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return false;
}
/** const credentialFor = (calendarName: string): string | undefined => {
* Checks if the password gives member view privileges
* @param password
*/
export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.MEMBER_CREDENTIAL;
}
/**
* Checks if the password gives choir view privileges
* @param password
*/
export const checkChoirPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.CHOIR_CREDENTIAL;
}
/**
* Checks if the password gives management view privileges
* @param password
*/
export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.MANAGEMENT_CREDENTIAL;
}
export const hasAccess = async (calendarName: string, sessionId: string, sessionKey: string, password: string, ip: string) => {
switch (calendarName) { switch (calendarName) {
case 'public':
return true;
case 'members': case 'members':
return await checkMemberPrivileges(sessionId, sessionKey, password, ip); return process.env.MEMBER_CREDENTIAL;
case 'choir': case 'choir':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
case 'management':
return await checkManagementPrivileges(sessionId, sessionKey, password, ip);
case 'birthdays': case 'birthdays':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip); return process.env.CHOIR_CREDENTIAL;
case 'management':
return process.env.MANAGEMENT_CREDENTIAL;
default: default:
return undefined;
}
};
/**
* Whether the given shared password opens the given calendar. Answers false
* for an unknown calendar, and - importantly - for a calendar whose credential
* is not configured at all: an unset MEMBER_CREDENTIAL must not turn into
* "everyone with an empty password gets in".
*/
export const hasAccess = async (calendarName: string, password: string): Promise<boolean> => {
if (calendarName === 'public') {
return true;
}
const expected = credentialFor(calendarName);
if (!expected) {
return false; return false;
} }
}
return password === expected;
};
+170 -175
View File
@@ -7,7 +7,7 @@ import {Event} from './event.interface.js';
import * as EventService from './events.service.js'; import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js'; import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service.js'; import * as CredentialService from './credentials.service.js';
import * as UserService from '../users/users.service.js'; import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js'; import logger from '../../../middleware/logger.js';
@@ -29,6 +29,44 @@ export const calendarNames = new Map<string, any>([
['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}] ['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}]
]); ]);
/**
* The gate on everything that writes. Step 4 of
* docs/calendar-auth-migration.md replaced a sessionId/sessionKey pair in the
* query string (DEFERRED_SECURITY.md item 1) with the same session cookie the
* other three apps use, and "any activated @nachklang.art account" with an
* explicit per-user calendar permission.
*/
const requireCalendarAccess = requireAppAccess('calendar');
/** Set by requireCalendarAccess; the writer's admin identity. */
const adminOf = (res: Response): AdminAccess => res.locals.admin as AdminAccess;
/**
* Resolves a signed-in calendar user for the *read* routes, or null.
*
* Reads cannot use the middleware: the same URL serves an anonymous visitor
* (the public calendar the website polls), someone holding a shared password
* (an iCal subscription), and a signed-in editor who should see drafts. So it
* answers "who is this, if anyone?" instead of refusing the request, and each
* handler decides what that means.
*
* A failure to reach the admin database is swallowed for the same reason the
* name lookup in events.service.ts swallows one: it must not be able to take
* the anonymous public calendar down.
*/
const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
try {
const access = await resolveAccess(req);
if (!access || access.disabled || !access.apps.includes('calendar')) {
return null;
}
return access;
} catch (e: any) {
logger.warn('Calendar: could not resolve the session, continuing as anonymous: ' + e.message);
return null;
}
};
/** /**
* Controller Definitions * Controller Definitions
@@ -39,7 +77,10 @@ export const calendarNames = new Map<string, any>([
* /calendar/events/{calendar}/json: * /calendar/events/{calendar}/json:
* get: * get:
* summary: Get all events from a specific calendar in JSON format * summary: Get all events from a specific calendar in JSON format
* description: Returns all events from the specified calendar in JSON format. Authentication required. * description: >
* Returns the calendar's events. The public calendar is open to everyone; the
* others need either a signed-in account with the calendar permission - which
* also unlocks drafts - or the calendar's shared password.
* tags: * tags:
* - calendar * - calendar
* parameters: * parameters:
@@ -51,20 +92,10 @@ export const calendarNames = new Map<string, any>([
* enum: [public, members, choir, management] * enum: [public, members, choir, management]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -109,10 +140,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? ''; let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -126,23 +154,19 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
let user = await UserService.checkSession(sessionId, sessionKey, ip); const editor = await signedInEditor(req);
// If no user was found, check if the password gives access to the calendar // Not signed in: fall back to the shared password for this calendar.
if(user === null || !user.isActive) { if (!editor && ! await CredentialService.hasAccess(calendarName, password)) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
}
let events: Event[]; // Editors get the admin view (drafts included, calendar includes ignored);
// everyone else gets published events only.
if(user?.isActive) { let events: Event[] = editor
events = await EventService.getAllEventsAdmin(calendarId); ? await EventService.getAllEventsAdmin(calendarId)
} else { : await EventService.getAllEvents(calendarId);
events = await EventService.getAllEvents(calendarId);
}
// Send the events back // Send the events back
res.status(200).send(events); res.status(200).send(events);
@@ -170,20 +194,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* enum: [public, members, choir, management] * enum: [public, members, choir, management]
* description: The name of the calendar to get the next event from * description: The name of the calendar to get the next event from
* - in: query * - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -242,10 +256,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? ''; let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -259,7 +270,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) { // Signed in, or holding the calendar's shared password. The password path
// is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie.
if (!await signedInEditor(req) && ! await CredentialService.hasAccess(calendarName, password)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -302,20 +316,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* enum: [public, members, choir, management] * enum: [public, members, choir, management]
* description: The name of the calendar to get events from * description: The name of the calendar to get events from
* - in: query * - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password * name: password
* schema: * schema:
* type: string * type: string
* description: Password for calendar access (if not using session authentication) * description: The calendar's shared password, for callers with no account
* responses: * responses:
* 200: * 200:
* description: Success - returns iCal file * description: Success - returns iCal file
@@ -365,10 +369,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
let calendarName: string = req.params.calendar as string ?? ''; let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? ''; let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) { if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'}); res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -382,7 +383,10 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id; let calendarId: number = calendarNames.get(calendarName)!.id;
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) { // Signed in, or holding the calendar's shared password. The password path
// is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie.
if (!await signedInEditor(req) && ! await CredentialService.hasAccess(calendarName, password)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -413,22 +417,11 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* /calendar/events: * /calendar/events:
* post: * post:
* summary: Create a new event * summary: Create a new event
* description: Creates a new event in the specified calendar. Authentication required. * description: Creates a new event. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* parameters: * security:
* - in: query * - AdminSessionCookie: []
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -495,16 +488,32 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to create events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -522,19 +531,9 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.post('/', async (req: Request, res: Response) => { eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.body.calendarId === undefined || req.body.calendarId === undefined ||
@@ -556,7 +555,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
endDateTime: new Date(req.body.endDateTime), endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdById: user.userId ?? -1, // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -585,9 +586,11 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* /calendar/events/{eventId}: * /calendar/events/{eventId}:
* put: * put:
* summary: Update an existing event * summary: Update an existing event
* description: Updates an existing event with the provided data. Authentication required. * description: Updates an existing event. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -595,18 +598,6 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to update * description: The ID of the event to update
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -673,16 +664,32 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to update events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -700,19 +707,9 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.put('/:eventId', async (req: Request, res: Response) => { eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -736,7 +733,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '', createdBy: req.body.createdBy ?? '',
createdById: user.userId ?? -1, // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -768,9 +767,11 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* /calendar/events/move/{eventId}: * /calendar/events/move/{eventId}:
* put: * put:
* summary: Move an event to a different calendar * summary: Move an event to a different calendar
* description: Moves an existing event to a different calendar. Authentication required. * description: Moves an event to a different calendar. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -778,18 +779,6 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to move * description: The ID of the event to move
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@@ -854,16 +843,32 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to move events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -881,19 +886,9 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => { eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined || req.params.eventId === undefined ||
@@ -914,7 +909,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
createdDate: new Date(), createdDate: new Date(),
location: req.body.location ?? '', location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '', createdBy: req.body.createdBy ?? '',
createdById: user.userId ?? -1, // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '', repeatFrequency: req.body.repeatFrequency ?? '',
@@ -944,9 +941,11 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* /calendar/events/{eventId}: * /calendar/events/{eventId}:
* delete: * delete:
* summary: Delete an event * summary: Delete an event
* description: Deletes an existing event. Authentication required. * description: Deletes an event. Requires a signed-in account with the calendar permission.
* tags: * tags:
* - calendar * - calendar
* security:
* - AdminSessionCookie: []
* parameters: * parameters:
* - in: path * - in: path
* name: eventId * name: eventId
@@ -954,18 +953,6 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* schema: * schema:
* type: integer * type: integer
* description: The ID of the event to delete * description: The ID of the event to delete
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* responses: * responses:
* 200: * 200:
* description: Event deleted successfully * description: Event deleted successfully
@@ -987,16 +974,32 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* message: * message:
* type: string * type: string
* example: Required parameters missing * example: Required parameters missing
* 403: * 401:
* description: Forbidden - no access to delete events * description: Unauthorized - not signed in
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * type: object
* properties: * properties:
* status:
* type: string
* example: UNAUTHORIZED
* message: * message:
* type: string * type: string
* example: You do not have access to the specified calendar. * example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* 500: * 500:
* description: Server error * description: Server error
* content: * content:
@@ -1014,19 +1017,9 @@ eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
* type: string * type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/ */
eventsRouter.delete('/:eventId', async (req: Request, res: Response) => { eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
try { try {
// Get params const admin = adminOf(res);
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if ( if (
req.params.eventId === undefined req.params.eventId === undefined
@@ -1046,7 +1039,9 @@ eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
createdDate: new Date(), createdDate: new Date(),
location: '', location: '',
createdBy: '', createdBy: '',
createdById: user.userId ?? -1, // LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
url: '', url: '',
wholeDay: false, wholeDay: false,
repeatFrequency: '', repeatFrequency: '',
+12 -8
View File
@@ -30,6 +30,10 @@ dotenv.config();
* 3. The admin module's `user.name`, looked up live for rows that carry an * 3. The admin module's `user.name`, looked up live for rows that carry an
* admin id. It wins because it is the only one that follows a rename. * admin id. It wins because it is the only one that follows a rename.
* *
* Writes only ever set the admin id: since the step 4 cutover there is no
* calendar user id to write, which is why migration 003 made `created_by_id`
* nullable. The reads below still handle rows that predate that.
*
* Removal note: everything marked LEGACY below comes out in step 5, together * Removal note: everything marked LEGACY below comes out in step 5, together
* with the `users`/`sessions` tables and the `created_by_id` columns. The * with the `users`/`sessions` tables and the `created_by_id` columns. The
* snapshot stays - it is the reason step 5 can drop them. * snapshot stays - it is the reason step 5 can drop them.
@@ -247,11 +251,11 @@ export const createEvent = async (event: Event): Promise<number> => {
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
let eventUUID = Guid.create().toString(); let eventUUID = Guid.create().toString();
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id'; const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_user_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]); const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]); await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -272,8 +276,8 @@ export const updateEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]); const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
@@ -294,8 +298,8 @@ export const deleteEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_user_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]); const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]);
await conn.commit(); await conn.commit();
+3 -2
View File
@@ -86,11 +86,12 @@ describe('APP_ORIGINS', () => {
// These reach better-auth's trustedOrigins, and the step 4 cutover made the // These reach better-auth's trustedOrigins, and the step 4 cutover made the
// tickets and feedback origins load-bearing: without them their sign-out // tickets and feedback origins load-bearing: without them their sign-out
// call is rejected while everything else still works. // call is rejected while everything else still works.
it('defaults to the two production frontends', async () => { it('defaults to the three production frontends', async () => {
const config = await loadConfig(); const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([ expect(config.APP_ORIGINS).toEqual([
'https://tickets.nachklang.art', 'https://tickets.nachklang.art',
'https://feedback.nachklang.art' 'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
]); ]);
}); });
+21 -25
View File
@@ -1,37 +1,26 @@
import {describe, expect, it, vi, beforeEach} from 'vitest'; import {describe, expect, it, beforeEach} from 'vitest';
vi.mock('../../src/models/calendar/users/users.service.js', () => ({
checkSession: vi.fn()
}));
import * as UserService from '../../src/models/calendar/users/users.service.js';
import * as CredentialService from '../../src/models/calendar/events/credentials.service.js'; import * as CredentialService from '../../src/models/calendar/events/credentials.service.js';
/** /**
* The public calendar is read anonymously by nachklang.art to show the next * The public calendar is read anonymously by nachklang.art to show the next
* upcoming event. That is a load-bearing property, not an accident: the * upcoming event. That is a load-bearing property, not an accident: the step 4
* calendar auth migration (docs/calendar-auth-migration.md) keeps the shared * cutover moved every signed-in path onto session cookies and left these shared
* credentials only for the iCal export and moves everything else onto session * passwords behind only for iCal subscriptions, and the failure mode of getting
* cookies, and the failure mode of getting that wrong is the public website * it wrong is the public website silently losing its events feed.
* silently losing its events feed.
* *
* So this pins both halves: public needs nothing, and the restricted calendars * So this pins both halves: public needs nothing, and the restricted calendars
* still need something. * still need something.
*/ */
describe('hasAccess', () => { describe('hasAccess', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks();
process.env.MEMBER_CREDENTIAL = 'member-secret'; process.env.MEMBER_CREDENTIAL = 'member-secret';
process.env.CHOIR_CREDENTIAL = 'choir-secret'; process.env.CHOIR_CREDENTIAL = 'choir-secret';
process.env.MANAGEMENT_CREDENTIAL = 'management-secret'; process.env.MANAGEMENT_CREDENTIAL = 'management-secret';
}); });
it('lets anyone read the public calendar with no session and no password', async () => { it('lets anyone read the public calendar with no password at all', async () => {
await expect(CredentialService.hasAccess('public', '', '', '', '127.0.0.1')).resolves.toBe(true); await expect(CredentialService.hasAccess('public', '')).resolves.toBe(true);
// It must not even reach the session check - an anonymous read of the
// public calendar should not depend on the users table being available.
expect(UserService.checkSession).not.toHaveBeenCalled();
}); });
it.each([ it.each([
@@ -39,15 +28,22 @@ describe('hasAccess', () => {
['choir', 'choir-secret'], ['choir', 'choir-secret'],
['management', 'management-secret'], ['management', 'management-secret'],
['birthdays', 'choir-secret'] ['birthdays', 'choir-secret']
])('refuses %s without a credential and allows it with one', async (calendar, secret) => { ])('refuses %s without the credential and allows it with one', async (calendar, secret) => {
(UserService.checkSession as any).mockResolvedValue(null); await expect(CredentialService.hasAccess(calendar, '')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, 'wrong')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, '', '', '', '127.0.0.1')).resolves.toBe(false); await expect(CredentialService.hasAccess(calendar, secret)).resolves.toBe(true);
await expect(CredentialService.hasAccess(calendar, '', '', 'wrong', '127.0.0.1')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, '', '', secret, '127.0.0.1')).resolves.toBe(true);
}); });
it('refuses an unknown calendar outright', async () => { it('refuses an unknown calendar outright', async () => {
await expect(CredentialService.hasAccess('nope', '', '', 'member-secret', '127.0.0.1')).resolves.toBe(false); await expect(CredentialService.hasAccess('nope', 'member-secret')).resolves.toBe(false);
});
it('refuses a calendar whose credential is not configured', async () => {
// An unset MEMBER_CREDENTIAL must not become "any password works", and in
// particular must not become "an absent password works".
delete process.env.MEMBER_CREDENTIAL;
await expect(CredentialService.hasAccess('members', '')).resolves.toBe(false);
await expect(CredentialService.hasAccess('members', undefined as any)).resolves.toBe(false);
}); });
}); });
+187
View File
@@ -0,0 +1,187 @@
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';
(EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(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);
});
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);
});
});