Read calendar event creators from the admin module, and archive the old ones

Steps 1 and 3 of docs/calendar-auth-migration.md. The calendar is the last
module still authenticating against its own users/sessions tables; this is
the groundwork that lets step 4 swap it for the shared admin identity.

An event now records its creator twice: created_by_id, the legacy INT into
the calendar database's own users table, and created_by_user_id, the admin
module's VARCHAR(36) id. The two live in different databases, so there is no
foreign key and no join - a cross-schema reference would tie the schemas'
lifecycles together, and the name is instead resolved through one lookup per
result set against the admin database.

The creator is only ever rendered as a name; nothing authorises on it. That
is what makes the planned account backfill unnecessary - dropped by decision -
and what makes the read degrade rather than fail: an admin id that no longer
resolves falls back, and an unreachable admin database costs a name rather
than the response. The public calendar is read anonymously by nachklang.art
and has never depended on the admin database being up.

Since there is no backfill, step 5 dropping the legacy users table would have
erased the authorship of every pre-cutover event. Migration 002 brings that
part of step 5 forward and snapshots the names onto the events themselves, so
the data is safe well before the table holding it goes away.

The same SELECT and row mapper existed in four copies; collapsed to one of
each first, so the dual read is written once rather than four times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:09:21 +02:00
parent 3c892d02ed
commit 61d3883479
9 changed files with 680 additions and 186 deletions
+53
View File
@@ -0,0 +1,53 @@
import {describe, expect, it, vi, beforeEach} from 'vitest';
vi.mock('../../src/models/calendar/users/users.service.js', () => ({
checkSession: vi.fn()
}));
import * as UserService from '../../src/models/calendar/users/users.service.js';
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
* calendar auth migration (docs/calendar-auth-migration.md) keeps the shared
* credentials only for the iCal export and moves everything else onto session
* cookies, and the failure mode of getting that 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(() => {
vi.resetAllMocks();
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 session and no password', async () => {
await expect(CredentialService.hasAccess('public', '', '', '', '127.0.0.1')).resolves.toBe(true);
// It must not even reach the session check - an anonymous read of the
// public calendar should not depend on the users table being available.
expect(UserService.checkSession).not.toHaveBeenCalled();
});
it.each([
['members', 'member-secret'],
['choir', 'choir-secret'],
['management', 'management-secret'],
['birthdays', 'choir-secret']
])('refuses %s without a credential and allows it with one', async (calendar, secret) => {
(UserService.checkSession as any).mockResolvedValue(null);
await expect(CredentialService.hasAccess(calendar, '', '', '', '127.0.0.1')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, '', '', 'wrong', '127.0.0.1')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, '', '', secret, '127.0.0.1')).resolves.toBe(true);
});
it('refuses an unknown calendar outright', async () => {
await expect(CredentialService.hasAccess('nope', '', '', 'member-secret', '127.0.0.1')).resolves.toBe(false);
});
});
+191
View File
@@ -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');
});
});