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); }); });