Read calendar event creators from the admin module, and archive the old ones (#14)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #14 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
|
||||
(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);
|
||||
});
|
||||
|
||||
// The endpoint www.nachklang.art actually calls for its next-event teaser.
|
||||
// Tested separately from /json because it takes a different code path - it
|
||||
// has no admin view and no editor branch - so covering /json proves nothing
|
||||
// about it, and its failure is invisible until someone notices the website
|
||||
// has gone quiet.
|
||||
it('serves the next upcoming event anonymously on the public calendar', async () => {
|
||||
await request(app).get('/calendar/events/public/json/next').expect(200);
|
||||
|
||||
// And without asking the admin database who the caller is: the public
|
||||
// feed must not acquire a dependency it has never had.
|
||||
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses the next upcoming event on a restricted calendar without a credential', async () => {
|
||||
await request(app).get('/calendar/events/members/json/next').expect(403);
|
||||
});
|
||||
|
||||
it('serves the next upcoming event to a shared password', async () => {
|
||||
await request(app)
|
||||
.get('/calendar/events/members/json/next')
|
||||
.query({password: 'member-secret'})
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('serves the next upcoming event to a signed-in editor', async () => {
|
||||
signedInAs(['calendar']);
|
||||
|
||||
await request(app).get('/calendar/events/members/json/next').expect(200);
|
||||
});
|
||||
|
||||
it('does not consult the admin database for the anonymous public iCal export', async () => {
|
||||
await request(app).get('/calendar/events/public/ical').expect(200);
|
||||
|
||||
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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