b848d6eab9
Step 4 of docs/calendar-auth-migration.md, and the close of
DEFERRED_SECURITY.md item 1: no calendar route reads sessionId/sessionKey from
the query string any more, so a live credential no longer travels through
access logs, browser history and Referer headers.
The four write routes sit behind requireAppAccess('calendar'), which also
narrows who may edit from "any activated @nachklang.art account" to an
explicit per-user permission. They answer 401 signed out and 403 without the
permission, where they previously answered 403 for both.
The three read routes cannot use the middleware: one URL serves an anonymous
visitor, an iCal subscription holding a shared password, and a signed-in
editor who should see drafts. They resolve the session optionally instead, and
a signed-in user without the calendar permission is treated as anonymous
rather than refused - so they keep the public calendar access anyone has.
That public calendar staying anonymous is load-bearing: nachklang.art reads it
to show the next upcoming event. It is now pinned at both the password-table
and the route level, and so is the rule that a shared password can never be
used to write.
credentials.service.ts loses its session half and becomes the password table
it always wanted to be. The shared passwords survive only for iCal clients,
which cannot send a cookie.
Writes record the author as an admin user id and no longer have a legacy int
to write, which is what migration 003 makes room for.
/calendar/users/* is left in place: nothing calls it and a session it mints
opens nothing, but they are still live password-accepting endpoints, so
removing them belongs with the rest of the legacy path in step 5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.4 KiB
TypeScript
188 lines
6.4 KiB
TypeScript
import {describe, expect, it, vi, beforeEach} from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
|
|
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
|
|
getAllEvents: vi.fn(),
|
|
getAllEventsAdmin: vi.fn(),
|
|
getEventById: vi.fn(),
|
|
createEvent: vi.fn(),
|
|
updateEvent: vi.fn(),
|
|
deleteEvent: vi.fn(),
|
|
moveEvent: vi.fn(),
|
|
getNextUpcomingEvent: vi.fn()
|
|
}));
|
|
|
|
vi.mock('../../src/models/admin/admin.auth.js', () => ({
|
|
auth: {api: {getSession: vi.fn()}}
|
|
}));
|
|
|
|
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
|
loadAccess: vi.fn()
|
|
}));
|
|
|
|
import * as EventService from '../../src/models/calendar/events/events.service.js';
|
|
import {auth} from '../../src/models/admin/admin.auth.js';
|
|
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
|
import {eventsRouter} from '../../src/models/calendar/events/events.router.js';
|
|
|
|
/**
|
|
* Step 4 of docs/calendar-auth-migration.md at the route level. The unit test
|
|
* on credentials.service covers the password table; this covers the thing that
|
|
* table is wired into, which is where the interesting mistakes live:
|
|
*
|
|
* - the public calendar has to stay readable with no session and no password,
|
|
* - the shared password has to keep working for the restricted calendars,
|
|
* because iCal clients cannot send a cookie,
|
|
* - and every write has to be behind the session cookie *and* an explicit
|
|
* calendar permission, not merely behind "is signed in".
|
|
*/
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/calendar/events', eventsRouter);
|
|
|
|
const signedInAs = (apps: string[], disabled = false) => {
|
|
(auth.api.getSession as any).mockResolvedValue({user: {id: 'admin-1'}});
|
|
(UsersService.loadAccess as any).mockResolvedValue({
|
|
id: 'admin-1',
|
|
email: 'a@nachklang.art',
|
|
displayName: 'A',
|
|
disabled,
|
|
permissions: apps.map(app => ({app, role: 'access'})),
|
|
apps
|
|
});
|
|
};
|
|
|
|
const signedOut = () => {
|
|
(auth.api.getSession as any).mockResolvedValue(null);
|
|
};
|
|
|
|
const validEvent = {
|
|
calendarId: 1,
|
|
name: 'Konzert',
|
|
startDateTime: '2026-04-18T19:00:00Z',
|
|
endDateTime: '2026-04-18T21:00:00Z'
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
process.env.MEMBER_CREDENTIAL = 'member-secret';
|
|
(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);
|
|
});
|
|
});
|