13a0c07d1b
Jenkins Production Deployment
Reviewed-on: #14 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
225 lines
8.0 KiB
TypeScript
225 lines
8.0 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.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);
|
|
});
|
|
});
|