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:
@@ -86,11 +86,12 @@ describe('APP_ORIGINS', () => {
|
||||
// These reach better-auth's trustedOrigins, and the step 4 cutover made the
|
||||
// tickets and feedback origins load-bearing: without them their sign-out
|
||||
// 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();
|
||||
expect(config.APP_ORIGINS).toEqual([
|
||||
'https://tickets.nachklang.art',
|
||||
'https://feedback.nachklang.art'
|
||||
'https://feedback.nachklang.art',
|
||||
'https://calendar.nachklang.art'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {describe, expect, it, beforeEach} from 'vitest';
|
||||
|
||||
import * as CredentialService from '../../src/models/calendar/events/credentials.service.js';
|
||||
|
||||
/**
|
||||
* 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 step 4
|
||||
* cutover moved every signed-in path onto session cookies and left these shared
|
||||
* passwords behind only for iCal subscriptions, and the failure mode of getting
|
||||
* it wrong is the public website silently losing its events feed.
|
||||
*
|
||||
* So this pins both halves: public needs nothing, and the restricted calendars
|
||||
* still need something.
|
||||
*/
|
||||
describe('hasAccess', () => {
|
||||
beforeEach(() => {
|
||||
process.env.MEMBER_CREDENTIAL = 'member-secret';
|
||||
process.env.CHOIR_CREDENTIAL = 'choir-secret';
|
||||
process.env.MANAGEMENT_CREDENTIAL = 'management-secret';
|
||||
});
|
||||
|
||||
it('lets anyone read the public calendar with no password at all', async () => {
|
||||
await expect(CredentialService.hasAccess('public', '')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['members', 'member-secret'],
|
||||
['choir', 'choir-secret'],
|
||||
['management', 'management-secret'],
|
||||
['birthdays', 'choir-secret']
|
||||
])('refuses %s without the credential and allows it with one', async (calendar, secret) => {
|
||||
await expect(CredentialService.hasAccess(calendar, '')).resolves.toBe(false);
|
||||
await expect(CredentialService.hasAccess(calendar, 'wrong')).resolves.toBe(false);
|
||||
await expect(CredentialService.hasAccess(calendar, secret)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('refuses an unknown calendar outright', async () => {
|
||||
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,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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import {describe, expect, it, vi, beforeEach} from 'vitest';
|
||||
|
||||
const connection = {
|
||||
query: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
beginTransaction: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
rollback: vi.fn(),
|
||||
end: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../../src/models/calendar/Calendar.db.js', () => ({
|
||||
NachklangCalendarDB: {getConnection: vi.fn(async () => connection)}
|
||||
}));
|
||||
|
||||
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||
findDisplayNames: vi.fn()
|
||||
}));
|
||||
|
||||
import {NachklangCalendarDB} from '../../src/models/calendar/Calendar.db.js';
|
||||
import * as AdminUsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||
import * as EventService from '../../src/models/calendar/events/events.service.js';
|
||||
|
||||
/**
|
||||
* Step 3 of docs/calendar-auth-migration.md. The property under test is that
|
||||
* an event's creator resolves from whichever of its three possible sources is
|
||||
* strongest - the live admin name, then the snapshot from migration 002, then
|
||||
* the legacy join - and that a failure to reach the admin database costs a name
|
||||
* rather than the whole response: the public calendar is read anonymously by
|
||||
* the website and has never depended on the admin database being up.
|
||||
*/
|
||||
|
||||
// One row of the shape the shared SELECT produces.
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
event_id: 1,
|
||||
calendar_id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Konzert',
|
||||
description: '',
|
||||
start_datetime: new Date('2026-04-18T19:00:00Z'),
|
||||
end_datetime: new Date('2026-04-18T21:00:00Z'),
|
||||
created_date: new Date('2026-01-01T00:00:00Z'),
|
||||
version_created_at: new Date('2026-01-02T00:00:00Z'),
|
||||
location: '',
|
||||
created_by_id: 7,
|
||||
created_by_user_id: null,
|
||||
created_by_name: null,
|
||||
legacy_created_by_name: 'Legacy Person',
|
||||
version_created_by_id: 7,
|
||||
version_created_by_user_id: null,
|
||||
version_created_by_name: null,
|
||||
legacy_last_modified_by_name: 'Legacy Person',
|
||||
url: '',
|
||||
whole_day: 0,
|
||||
repeat_frequency: '',
|
||||
status: 'PUBLIC',
|
||||
...over
|
||||
});
|
||||
|
||||
/** getAllEvents runs the calendars lookup first, then the events query. */
|
||||
const givenEvents = (...rows: unknown[]) => {
|
||||
connection.query.mockReset();
|
||||
connection.query
|
||||
.mockResolvedValueOnce([{calendar_id: 1, includes_calendars: '[]'}])
|
||||
.mockResolvedValueOnce(rows);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
connection.end.mockResolvedValue(undefined);
|
||||
(NachklangCalendarDB.getConnection as any).mockResolvedValue(connection);
|
||||
});
|
||||
|
||||
describe('creator names', () => {
|
||||
it('uses the legacy join when the row has no admin id', async () => {
|
||||
givenEvents(row());
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events[0].createdBy).toBe('Legacy Person');
|
||||
expect(events[0].createdById).toBe(7);
|
||||
expect(events[0].createdByUserId).toBeNull();
|
||||
// Nothing to resolve, so the admin database is not touched at all.
|
||||
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the admin name when the row carries an admin id', async () => {
|
||||
givenEvents(row({
|
||||
created_by_user_id: 'admin-1',
|
||||
version_created_by_user_id: 'admin-2'
|
||||
}));
|
||||
(AdminUsersService.findDisplayNames as any).mockResolvedValue(
|
||||
new Map([['admin-1', 'Neue Person'], ['admin-2', 'Andere Person']])
|
||||
);
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events[0].createdBy).toBe('Neue Person');
|
||||
expect(events[0].lastModifiedBy).toBe('Andere Person');
|
||||
// The legacy id is still reported during the transition.
|
||||
expect(events[0].createdById).toBe(7);
|
||||
expect(events[0].createdByUserId).toBe('admin-1');
|
||||
});
|
||||
|
||||
it('prefers the snapshot over the legacy join', async () => {
|
||||
givenEvents(row({
|
||||
created_by_name: 'Archived Person',
|
||||
version_created_by_name: 'Archived Person'
|
||||
}));
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events[0].createdBy).toBe('Archived Person');
|
||||
expect(events[0].lastModifiedBy).toBe('Archived Person');
|
||||
});
|
||||
|
||||
it('prefers the live admin name over the snapshot', async () => {
|
||||
// A renamed account has to win over an archive that was correct when it
|
||||
// was taken - otherwise renaming someone would leave stale names behind.
|
||||
givenEvents(row({created_by_user_id: 'admin-1', created_by_name: 'Archived Person'}));
|
||||
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events[0].createdBy).toBe('Neue Person');
|
||||
});
|
||||
|
||||
it('keeps the snapshot when step 5 has removed the legacy join', async () => {
|
||||
// What a post-step-5 row looks like: no legacy id, no join, snapshot only.
|
||||
givenEvents(row({
|
||||
created_by_id: null,
|
||||
legacy_created_by_name: undefined,
|
||||
legacy_last_modified_by_name: undefined,
|
||||
created_by_name: 'Archived Person',
|
||||
version_created_by_name: 'Archived Person'
|
||||
}));
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events[0].createdBy).toBe('Archived Person');
|
||||
expect(events[0].lastModifiedBy).toBe('Archived Person');
|
||||
});
|
||||
|
||||
it('falls back to the legacy name when the admin account is gone', async () => {
|
||||
givenEvents(row({created_by_user_id: 'deleted'}));
|
||||
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events[0].createdBy).toBe('Legacy Person');
|
||||
});
|
||||
|
||||
it('resolves a mixed result set in a single lookup', async () => {
|
||||
givenEvents(
|
||||
row({event_id: 1}),
|
||||
row({event_id: 2, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'}),
|
||||
row({event_id: 3, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'})
|
||||
);
|
||||
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events.map(e => e.createdBy)).toEqual(['Legacy Person', 'Neue Person', 'Neue Person']);
|
||||
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still returns the events when the admin database is unreachable', async () => {
|
||||
givenEvents(row({created_by_user_id: 'admin-1'}));
|
||||
(AdminUsersService.findDisplayNames as any).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const events = await EventService.getAllEvents(1);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].name).toBe('Konzert');
|
||||
// Degrades to the legacy name rather than failing the request.
|
||||
expect(events[0].createdBy).toBe('Legacy Person');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('is omitted from the public listing and present in the admin one', async () => {
|
||||
givenEvents(row());
|
||||
const publicEvents = await EventService.getAllEvents(1);
|
||||
expect(publicEvents[0].status).toBeUndefined();
|
||||
|
||||
connection.query.mockReset();
|
||||
connection.query.mockResolvedValueOnce([row()]);
|
||||
const adminEvents = await EventService.getAllEventsAdmin(1);
|
||||
expect(adminEvents[0].status).toBe('PUBLIC');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user