Read calendar event creators from the admin module, and archive the old ones #14
+18
-17
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 'birthdays':
|
||||||
|
return process.env.CHOIR_CREDENTIAL;
|
||||||
case 'management':
|
case 'management':
|
||||||
return await checkManagementPrivileges(sessionId, sessionKey, password, ip);
|
return process.env.MANAGEMENT_CREDENTIAL;
|
||||||
case 'birthdays':
|
|
||||||
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
|
|
||||||
default:
|
default:
|
||||||
return false;
|
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 password === expected;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user