feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

@@ -0,0 +1,48 @@
import { BackupSchedulerService } from '../backup/backup-scheduler.service';
import { AppBackupJob } from './app-backup.job';
describe('AppBackupJob', () => {
let scheduler: { runIfDue: jest.Mock };
let job: AppBackupJob;
beforeEach(() => {
scheduler = { runIfDue: jest.fn() };
job = new AppBackupJob(scheduler as unknown as BackupSchedulerService);
});
it('is the app-backup job', () => {
expect(job.name).toBe('app-backup');
});
it('writes a backup when one is due and reports it', async () => {
scheduler.runIfDue.mockResolvedValue({
written: true,
fileName: 'apicore-backup-2026-10-13T01-00-04-000Z.json',
sizeBytes: 2048,
pruned: 1,
});
await expect(job.run()).resolves.toEqual({
written: true,
fileName: 'apicore-backup-2026-10-13T01-00-04-000Z.json',
sizeBytes: 2048,
pruned: 1,
});
expect(scheduler.runIfDue).toHaveBeenCalledWith();
});
it('reports why nothing was written', async () => {
scheduler.runIfDue.mockResolvedValue({ written: false, reason: 'not-due' });
await expect(job.run()).resolves.toEqual({
written: false,
reason: 'not-due',
});
});
it('fails the run when the backup fails', async () => {
scheduler.runIfDue.mockRejectedValue(new Error('disk full'));
await expect(job.run()).rejects.toThrow('disk full');
});
});
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { BackupSchedulerService } from '../backup/backup-scheduler.service';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/**
* The hourly CronJob check of the automatic backup: writes a backup only if
* the schedule (env or GUI, Europe/Berlin) says one is due. Without a
* configured path it succeeds without writing; a failed backup fails the
* run, which notifies the holders of backup:configure.
*/
@Injectable()
export class AppBackupJob implements Job {
readonly name: JobName = 'app-backup';
constructor(private readonly scheduler: BackupSchedulerService) {}
run(): Promise<JobResult> {
return this.scheduler.runIfDue();
}
}
@@ -0,0 +1,140 @@
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import {
CleanupJob,
DOMAIN_EVENT_RETENTION_DAYS,
FAILED_RETENTION_DAYS,
JOB_RUN_RETENTION_DAYS,
JOB_RUN_STALE_HOURS,
WEBHOOK_DELIVERY_RETENTION_DAYS,
} from './cleanup.job';
type RawCall = [readonly string[], ...unknown[]];
describe('CleanupJob', () => {
let executeRaw: jest.Mock<Promise<number>, RawCall>;
let idempotencyCleanup: { removeExpired: jest.Mock };
let jobRuns: { abortStale: jest.Mock };
let job: CleanupJob;
const statements = (): { sql: string; values: unknown[] }[] =>
executeRaw.mock.calls.map(([strings, ...values]) => ({
sql: strings.join('?').replace(/\s+/g, ' '),
values,
}));
const statementOn = (table: string, status?: string) => {
const found = statements().filter(
({ sql }) =>
sql.includes(`DELETE FROM "${table}"`) &&
(status === undefined || sql.includes(`"status" = '${status}'`)),
);
expect(found).toHaveLength(1);
return found[0];
};
beforeEach(() => {
let count = 10;
executeRaw = jest.fn<Promise<number>, RawCall>(() =>
Promise.resolve(count++),
);
idempotencyCleanup = {
removeExpired: jest
.fn()
.mockResolvedValue({ idempotencyRecords: 2, consumedTokens: 3 }),
};
jobRuns = { abortStale: jest.fn().mockResolvedValue(1) };
job = new CleanupJob(
{ $executeRaw: executeRaw } as unknown as PrismaService,
idempotencyCleanup as unknown as IdempotencyCleanupService,
jobRuns as unknown as JobRunService,
);
});
it('keeps the retention periods of the backbone spec', () => {
expect(DOMAIN_EVENT_RETENTION_DAYS).toBe(30);
expect(WEBHOOK_DELIVERY_RETENTION_DAYS).toBe(30);
expect(FAILED_RETENTION_DAYS).toBe(90);
expect(JOB_RUN_RETENTION_DAYS).toBe(90);
expect(JOB_RUN_STALE_HOURS).toBe(6);
});
it('cleanup removes only expired rows and reports counts', async () => {
const result = await job.run();
expect(job.name).toBe('cleanup');
expect(idempotencyCleanup.removeExpired).toHaveBeenCalledTimes(1);
expect(jobRuns.abortStale).toHaveBeenCalledWith(JOB_RUN_STALE_HOURS);
// One count per statement, in the order the statements ran.
expect(result).toEqual({
idempotencyRecords: 2,
consumedTokens: 3,
throttlerHits: 10,
'domainEvents.done': 11,
'domainEvents.failed': 12,
'webhookDeliveries.delivered': 13,
'webhookDeliveries.failed': 14,
'jobRuns.aborted': 1,
'jobRuns.deleted': 15,
});
});
// An expired window can still carry an active block (blockDuration
// outlasts the window); deleting it would lift the block early.
it('deletes a throttler counter only when neither its window nor a block is active', async () => {
await job.run();
const { sql, values } = statementOn('ThrottlerHit');
expect(sql).toContain('"expiresAt" < now()');
expect(sql).toContain('("blockedUntil" IS NULL OR "blockedUntil" < now())');
expect(values).toEqual([]);
});
it('keeps finished outbox entries 30 days and failed ones 90 days, by status', async () => {
await job.run();
expect(statementOn('DomainEvent', 'DONE').values).toEqual([
DOMAIN_EVENT_RETENTION_DAYS,
]);
expect(statementOn('DomainEvent', 'FAILED').values).toEqual([
FAILED_RETENTION_DAYS,
]);
expect(statementOn('WebhookDelivery', 'DELIVERED').values).toEqual([
WEBHOOK_DELIVERY_RETENTION_DAYS,
]);
expect(statementOn('WebhookDelivery', 'FAILED').values).toEqual([
FAILED_RETENTION_DAYS,
]);
expect(statementOn('DomainEvent', 'DONE').sql).toContain(
'"occurredAt" < now() - (? * interval \'1 day\')',
);
expect(statementOn('WebhookDelivery', 'FAILED').sql).toContain(
'"createdAt" < now() - (? * interval \'1 day\')',
);
// PENDING and PROCESSING entries are never touched, however old.
for (const { sql } of statements()) {
expect(sql).not.toMatch(/PENDING|PROCESSING/);
}
});
it('deletes job runs after 90 days, only after stale runs were aborted', async () => {
await job.run();
const { sql, values } = statementOn('JobRun');
expect(sql).toContain('"startedAt" < now() - (? * interval \'1 day\')');
expect(values).toEqual([JOB_RUN_RETENTION_DAYS]);
const deleteOrder = executeRaw.mock.invocationCallOrder.at(-1) ?? 0;
expect(jobRuns.abortStale.mock.invocationCallOrder[0]).toBeLessThan(
deleteOrder,
);
});
it('compares only against the database clock', async () => {
await job.run();
for (const { sql, values } of statements()) {
expect(sql).toContain('now()');
expect(values.some((value) => value instanceof Date)).toBe(false);
}
});
});
+78
View File
@@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
// DONE events must outlive the catch-up window of the daily evaluation
// (7 days): a deleted event frees its dedupeKey, and an occurrence still in
// the window would be recorded a second time.
export const DOMAIN_EVENT_RETENTION_DAYS = 30;
export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
/** FAILED events and deliveries stay longer, for diagnosis and retry. */
export const FAILED_RETENTION_DAYS = 90;
export const JOB_RUN_RETENTION_DAYS = 90;
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
export const JOB_RUN_STALE_HOURS = 6;
/**
* Daily housekeeping (CronJob 04:00). Every age is compared against now(),
* the database clock. PENDING and PROCESSING entries are never removed,
* however old: they are work that has not been done yet.
*/
@Injectable()
export class CleanupJob implements Job {
readonly name: JobName = 'cleanup';
constructor(
private readonly prisma: PrismaService,
private readonly idempotencyCleanup: IdempotencyCleanupService,
private readonly jobRuns: JobRunService,
) {}
async run(): Promise<JobResult> {
const jobRunsAborted = await this.jobRuns.abortStale(JOB_RUN_STALE_HOURS);
const { idempotencyRecords, consumedTokens } =
await this.idempotencyCleanup.removeExpired();
// An expired window can still carry an active block (blockDuration
// outlasts the window, e.g. the failed-attempt lock of API keys);
// deleting that row would lift the block early.
const throttlerHits = await this.prisma.$executeRaw`
DELETE FROM "ThrottlerHit"
WHERE "expiresAt" < now()
AND ("blockedUntil" IS NULL OR "blockedUntil" < now())`;
// Deliveries of a deleted event go with it (ON DELETE CASCADE).
const domainEventsDone = await this.prisma.$executeRaw`
DELETE FROM "DomainEvent"
WHERE "status" = 'DONE'
AND "occurredAt" < now() - (${DOMAIN_EVENT_RETENTION_DAYS} * interval '1 day')`;
const domainEventsFailed = await this.prisma.$executeRaw`
DELETE FROM "DomainEvent"
WHERE "status" = 'FAILED'
AND "occurredAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
const webhookDeliveriesDelivered = await this.prisma.$executeRaw`
DELETE FROM "WebhookDelivery"
WHERE "status" = 'DELIVERED'
AND "createdAt" < now() - (${WEBHOOK_DELIVERY_RETENTION_DAYS} * interval '1 day')`;
const webhookDeliveriesFailed = await this.prisma.$executeRaw`
DELETE FROM "WebhookDelivery"
WHERE "status" = 'FAILED'
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
// After abortStale: no run this old can still be RUNNING.
const jobRunsDeleted = await this.prisma.$executeRaw`
DELETE FROM "JobRun"
WHERE "startedAt" < now() - (${JOB_RUN_RETENTION_DAYS} * interval '1 day')`;
return {
idempotencyRecords,
consumedTokens,
throttlerHits,
'domainEvents.done': domainEventsDone,
'domainEvents.failed': domainEventsFailed,
'webhookDeliveries.delivered': webhookDeliveriesDelivered,
'webhookDeliveries.failed': webhookDeliveriesFailed,
'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted,
};
}
}
@@ -0,0 +1,146 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
type RawCall = [readonly string[], ...unknown[]];
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('DailyEvaluationJob', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
/** The database clock: a day the JS clock of the test never shows. */
const dbNow = new Date('2026-06-10T02:30:00.000Z');
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
let job: DailyEvaluationJob;
beforeEach(() => {
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(([sql]) =>
Promise.resolve(
sql.includes('now()')
? [{ now: dbNow }]
: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
),
);
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: queryRaw,
$transaction: transaction,
task: { findMany: taskFindMany },
queen: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
} as unknown as PrismaService;
job = new DailyEvaluationJob(prisma, {
recordAll,
} as unknown as DomainEventsService);
});
afterEach(() => jest.restoreAllMocks());
it('is the daily-evaluation job', () => {
expect(job.name).toBe('daily-evaluation');
});
it('evaluates the day of the database clock and reports new events per type', async () => {
const result = await job.run();
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10 - not in the one of the test's own clock.
expect(result).toEqual({
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
await job.run();
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
});
it('a second run on the same day records nothing new', async () => {
await job.run();
expect(await job.run()).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it('a failing rule does not stop the others, but fails the run', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
await expect(job.evaluate(dbNow)).rejects.toThrow(
'Daily evaluation failed for: TaskDue',
);
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
);
});
});
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
} from './daily-evaluation.rules';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/**
* Records the time-based domain events (CronJob 04:30): one rule per type,
* see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within
* CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed
* run is caught up and a repeated one adds nothing. The result counts the
* events that were new, per type.
*/
@Injectable()
export class DailyEvaluationJob implements Job {
readonly name: JobName = 'daily-evaluation';
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
) {}
/** Evaluates the current day of the database clock. */
async run(): Promise<JobResult> {
const [{ now }] = await this.prisma.$queryRaw<
{ now: Date }[]
>`SELECT now() AS "now"`;
return this.evaluate(now);
}
/**
* Runs every rule for `today`. A failing rule is logged and does not stop
* the others - their events are committed - but fails the run, so it
* shows on the System page; the next run catches up.
*/
async evaluate(today: Date): Promise<JobResult> {
const counts: [string, number][] = [];
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts.push([rule.type, await this.evaluateRule(rule, today)]);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`);
}
return Object.fromEntries(counts);
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(rule: DailyRule, today: Date): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today),
today,
);
if (events.length === 0) {
return 0;
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
}
}
@@ -0,0 +1,447 @@
import { PrismaService } from '../prisma/prisma.service';
import {
ACTIVE_SEASON_MONTHS,
CATCH_UP_DAYS,
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyEvent,
inCatchUpWindow,
INSPECTION_INTERVAL_DAYS,
latestCycleOccurrence,
QUEEN_AGED_MIN_YEARS,
VARROA_MEASUREMENT_INTERVAL_DAYS,
withdrawalEndDay,
} from './daily-evaluation.rules';
const DAY_MS = 24 * 60 * 60 * 1000;
/** Midnight UTC of a 'YYYY-MM-DD' day. */
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
/** Every day from `from` to `to`, both included. */
function daysBetween(from: string, to: string): string[] {
const days: string[] = [];
for (let t = d(from).getTime(); t <= d(to).getTime(); t += DAY_MS) {
days.push(new Date(t).toISOString().slice(0, 10));
}
return days;
}
interface FakeRows {
treatments?: {
id: string;
colonyId: string;
date: Date;
endDate: Date | null;
withdrawalPeriodDays: number | null;
}[];
tasks?: { id: string; dueDate: Date | null }[];
queens?: {
id: string;
hatchYear: number | null;
colony: { id: string } | null;
}[];
colonies?: { id: string; createdAt: Date }[];
/** The latest varroa measurement per colony. */
varroa?: { colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
}
interface FakeDb {
db: PrismaService;
queryRaw: jest.Mock;
taskFindMany: jest.Mock;
queenFindMany: jest.Mock;
colonyFindMany: jest.Mock;
}
/**
* Returns the given rows whatever the query filters - the rules decide by
* day themselves, the database filters only narrow the candidates.
*/
function fakeDb(rows: FakeRows): FakeDb {
const latest = (records: { colonyId: string; date: Date }[] = []) =>
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
const db = {
$queryRaw: queryRaw,
task: { findMany: taskFindMany },
queen: { findMany: queenFindMany },
colony: { findMany: colonyFindMany },
varroaMeasurement: {
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
},
inspection: {
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
},
} as unknown as PrismaService;
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
}
/** One job run on `day`: every rule's events in the catch-up window. */
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
const events: DailyEvent[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day)));
}
return events;
}
/**
* Runs the evaluation on each of `days` into a store keyed like the unique
* DomainEvent.dedupeKey: an event whose key exists is skipped, as
* recordAll's createMany(skipDuplicates) does.
*/
async function runDaily(
db: PrismaService,
days: readonly string[],
store = new Map<string, DailyEvent>(),
): Promise<Map<string, DailyEvent>> {
for (const day of days) {
for (const event of await eventsOn(db, day)) {
if (!store.has(event.dedupeKey)) {
store.set(event.dedupeKey, event);
}
}
}
return store;
}
describe('daily evaluation rules', () => {
it('keeps the values of the backbone plan', () => {
expect(CATCH_UP_DAYS).toBe(7);
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
});
it('runs one rule per time-based event type', () => {
expect(DAILY_EVALUATION_RULES.map((rule) => rule.type)).toEqual([
'WithdrawalEnded',
'TaskDue',
'QueenAged',
'VarroaMeasurementDue',
'InspectionOverdue',
]);
});
describe('withdrawalEndDay', () => {
it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: d('2026-10-01'),
withdrawalPeriodDays: 14,
}),
).toBe('2026-10-15'));
it('counts from the treatment day when the treatment has no end date', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: null,
withdrawalPeriodDays: 14,
}),
).toBe('2026-10-12'));
it('ends on the treatment day itself without a waiting time and ignores the time of day', () =>
expect(
withdrawalEndDay({
date: new Date('2026-09-28T22:15:00.000Z'),
endDate: null,
withdrawalPeriodDays: 0,
}),
).toBe('2026-09-28'));
it('has no end when the treatment has no withdrawal period', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: d('2026-10-01'),
withdrawalPeriodDays: null,
}),
).toBeNull());
});
describe('latestCycleOccurrence', () => {
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
'2026-06-12',
);
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
'2026-05-22',
);
});
it('is null before the first full interval and for a base in the future', () => {
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
).toBeNull();
});
it('counts whole UTC days, not hours', () =>
expect(
latestCycleOccurrence(
new Date('2026-05-01T20:00:00.000Z'),
21,
at('2026-05-22'),
),
).toBe('2026-05-22'));
it('refuses an interval that is not a positive whole number of days', () => {
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
).toThrow(RangeError);
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
).toThrow(RangeError);
});
});
describe('inCatchUpWindow', () => {
it('spans today and the 7 days before it', () => {
const today = at('2026-06-10');
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
expect(inCatchUpWindow('2026-06-02', today)).toBe(false);
expect(inCatchUpWindow('2026-06-11', today)).toBe(false);
});
it('works across a year boundary', () => {
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
});
});
it('builds each event from its rule: aggregate, payload and daily dedupe key', async () => {
const { db } = fakeDb({
treatments: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-15'),
endDate: d('2026-05-20'),
withdrawalPeriodDays: 5,
},
],
tasks: [{ id: 'task-1', dueDate: new Date('2026-05-27T09:00:00Z') }],
colonies: [
// Measured and inspected: the intervals count from the records.
{ id: 'colony-1', createdAt: d('2026-03-01') },
// Never measured or inspected: they count from its creation.
{ id: 'colony-2', createdAt: d('2026-04-12') },
],
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
});
expect(await eventsOn(db, '2026-05-29')).toEqual([
{
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
aggregateId: 'treatment-1',
payload: { colonyId: 'colony-1', endedOn: '2026-05-25' },
dedupeKey: 'WithdrawalEnded:treatment-1:2026-05-25',
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 'task-1',
payload: { dueOn: '2026-05-27' },
dedupeKey: 'TaskDue:task-1:2026-05-27',
},
// colony-2: 2026-04-12 + 28 = 2026-05-10, outside the window.
{
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
},
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-2',
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
},
]);
});
it('catches up occurrences of the last 7 days exactly once', async () => {
// The job last ran on 2026-06-01 and is back on 2026-06-10 (CronJob
// down for a week). Then it runs daily again.
const { db } = fakeDb({
treatments: [
// Ends 2026-06-02: 8 days before the comeback, outside the window.
{
id: 'missed',
colonyId: 'colony-1',
date: d('2026-05-19'),
endDate: null,
withdrawalPeriodDays: 14,
},
// Ends 2026-06-03: 7 days before the comeback, caught up.
{
id: 'caught-up',
colonyId: 'colony-1',
date: d('2026-05-20'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
tasks: [
{ id: 'due-while-down', dueDate: d('2026-06-06') },
{ id: 'due-later', dueDate: d('2026-06-14') },
],
});
const store = await runDaily(db, ['2026-06-01']);
expect([...store.keys()]).toEqual([]);
const comeback = await eventsOn(db, '2026-06-10');
expect(comeback.map((event) => event.dedupeKey)).toEqual([
'WithdrawalEnded:caught-up:2026-06-03',
'TaskDue:due-while-down:2026-06-06',
]);
// Every later run within the window repeats the same keys, so the
// unique dedupeKey keeps one event per occurrence.
await runDaily(db, daysBetween('2026-06-10', '2026-06-30'), store);
expect([...store.keys()].sort()).toEqual(
[
'TaskDue:due-later:2026-06-14',
'TaskDue:due-while-down:2026-06-06',
'WithdrawalEnded:caught-up:2026-06-03',
].sort(),
);
// Once the window has passed, the occurrence is no longer produced.
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
});
it('emits no inspection or varroa event outside March–October', async () => {
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
});
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
const dueDays = (type: string) =>
[...store.values()]
.filter((event) => event.type === type)
.map((event) => event.dedupeKey.split(':')[2]);
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
expect(dueDays('VarroaMeasurementDue')).toEqual([
'2026-03-07',
'2026-04-04',
'2026-05-02',
'2026-05-30',
'2026-06-27',
'2026-07-25',
'2026-08-22',
'2026-09-19',
'2026-10-17',
]);
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
expect(dueDays('InspectionOverdue')).toEqual([
'2026-03-14',
'2026-04-04',
'2026-04-25',
'2026-05-16',
'2026-06-06',
'2026-06-27',
'2026-07-18',
'2026-08-08',
'2026-08-29',
'2026-09-19',
'2026-10-10',
'2026-10-31',
]);
});
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
const { db } = fakeDb({
queens: [
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
{ id: 'queen-2027', hatchYear: 2027, colony: null },
{ id: 'queen-unknown', hatchYear: null, colony: null },
],
});
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
expect([...store.values()]).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2026',
payload: { hatchYear: 2026, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
},
expect.objectContaining({
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
}) as DailyEvent,
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2027',
payload: { hatchYear: 2027, colonyId: null },
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
},
]);
});
it('asks the database only for the candidates of the catch-up window', async () => {
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
fakeDb({});
await eventsOn(db, '2026-06-10');
expect(taskFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
dueDate: { gte: d('2026-06-03'), lt: d('2026-06-11') },
},
}),
);
expect(colonyFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
}),
);
const [strings, ...values] = queryRaw.mock.calls[0] as [
readonly string[],
...unknown[],
];
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
expect(values).toEqual(['2026-06-03', '2026-06-10']);
// QueenAged only looks on January 1-8.
expect(queenFindMany).not.toHaveBeenCalled();
await eventsOn(db, '2027-01-08');
expect(queenFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}),
);
});
});
@@ -0,0 +1,337 @@
import { TaskStatus, Treatment } from '../../generated/prisma';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import {
AnyNewDomainEvent,
dailyDedupeKey,
utcDay,
} from '../domain-events/domain-event-types';
import { PrismaService } from '../prisma/prisma.service';
/** How many days back a run still records an occurrence it missed. */
export const CATCH_UP_DAYS = 7;
/** A queen counts as aged from this many years after her hatch year. */
export const QUEEN_AGED_MIN_YEARS = 2;
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
export const INSPECTION_INTERVAL_DAYS = 21;
/** March to October: varroa and inspection reminders only in these months. */
export const ACTIVE_SEASON_MONTHS: readonly number[] = [
3, 4, 5, 6, 7, 8, 9, 10,
];
const DAY_MS = 24 * 60 * 60 * 1000;
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
/** The event types the daily evaluation records. */
export type DailyEventType =
| 'WithdrawalEnded'
| 'TaskDue'
| 'QueenAged'
| 'VarroaMeasurementDue'
| 'InspectionOverdue';
/** An event as the job records it: always with its daily dedupe key. */
export type DailyEvent<T extends DailyEventType = DailyEventType> =
AnyNewDomainEvent<T> & { dedupeKey: string };
/** One occurrence of a rule: the event and the UTC day it happens on. */
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
day: string;
event: AnyNewDomainEvent<T>;
}
/**
* One row of the daily evaluation. `occurrences` reads the candidates and
* names the day of each; the job keeps those in the catch-up window
* (catchUpEvents) and records them under their daily dedupe key. A rule
* may narrow its query to the window, but must not rely on that: the day
* it reports decides.
*/
export interface DailyRule<T extends DailyEventType = DailyEventType> {
readonly type: T;
occurrences(db: PrismaService, today: Date): Promise<DailyOccurrence<T>[]>;
}
/** Midnight UTC of the UTC day of `date`. */
function dayStart(date: Date): Date {
return new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
}
/** The UTC day `days` days after the UTC day of `date`. */
function dayAfter(date: Date, days: number): Date {
return new Date(dayStart(date).getTime() + days * DAY_MS);
}
/** The UTC day the withdrawal period ends on, null without one. */
export function withdrawalEndDay(
t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
): string | null {
if (t.withdrawalPeriodDays === null) {
return null;
}
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
}
/**
* The latest day base + k × intervalDays (k ≥ 1) that is not after today,
* in whole UTC days; null if not even the first interval has passed.
*/
export function latestCycleOccurrence(
base: Date,
intervalDays: number,
today: Date,
): string | null {
if (!Number.isInteger(intervalDays) || intervalDays < 1) {
throw new RangeError(
`Interval must be a positive whole number of days, got ${intervalDays}`,
);
}
const elapsedDays = Math.round(
(dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS,
);
const cycles = Math.floor(elapsedDays / intervalDays);
if (cycles < 1) {
return null;
}
return utcDay(dayAfter(base, cycles * intervalDays));
}
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
export function inCatchUpWindow(day: string, today: Date): boolean {
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
}
function inActiveSeason(day: string): boolean {
return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7)));
}
/**
* The occurrences in the catch-up window, as events with their daily dedupe
* key: every run within the window repeats the same key, so the unique
* DomainEvent.dedupeKey keeps one event per occurrence.
*/
export function catchUpEvents(
occurrences: readonly DailyOccurrence[],
today: Date,
): DailyEvent[] {
return occurrences
.filter(({ day }) => inCatchUpWindow(day, today))
.map(({ day, event }) => ({
...event,
dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day),
}));
}
interface TreatmentRow {
id: string;
colonyId: string;
date: Date;
endDate: Date | null;
withdrawalPeriodDays: number | null;
}
/** Treatment: the day after which honey may be harvested again. */
const withdrawalEnded: DailyRule<'WithdrawalEnded'> = {
type: 'WithdrawalEnded',
async occurrences(db, today) {
// Prisma cannot compare column arithmetic; the days are compared as
// dates, so the session time zone plays no part.
const treatments = await db.$queryRaw<TreatmentRow[]>`
SELECT "id", "colonyId", "date", "endDate", "withdrawalPeriodDays"
FROM "Treatment"
WHERE "withdrawalPeriodDays" IS NOT NULL
AND (COALESCE("endDate", "date") + "withdrawalPeriodDays" * interval '1 day')::date
BETWEEN ${utcDay(dayAfter(today, -CATCH_UP_DAYS))}::date AND ${utcDay(today)}::date`;
return treatments.flatMap((treatment) => {
const day = withdrawalEndDay(treatment);
if (day === null) {
return [];
}
return [
{
day,
event: {
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
aggregateId: treatment.id,
payload: { colonyId: treatment.colonyId, endedOn: day },
},
},
];
});
},
};
/** Open task: its due day. */
const taskDue: DailyRule<'TaskDue'> = {
type: 'TaskDue',
async occurrences(db, today) {
const tasks = await db.task.findMany({
where: {
status: { in: OPEN_TASK_STATUSES },
dueDate: {
gte: dayAfter(today, -CATCH_UP_DAYS),
lt: dayAfter(today, 1),
},
},
select: { id: true, dueDate: true },
});
return tasks.flatMap(({ id, dueDate }) => {
if (dueDate === null) {
return [];
}
const day = utcDay(dueDate);
return [
{
day,
event: {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: id,
payload: { dueOn: day },
},
},
];
});
},
};
/** Active queen from her second year after hatching: once a year, on January 1. */
const queenAged: DailyRule<'QueenAged'> = {
type: 'QueenAged',
async occurrences(db, today) {
const year = today.getUTCFullYear();
const day = `${year}-01-01`;
if (!inCatchUpWindow(day, today)) {
return [];
}
const queens = await db.queen.findMany({
where: {
status: 'ACTIVE',
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
},
select: { id: true, hatchYear: true, colony: { select: { id: true } } },
});
return queens.flatMap(({ id, hatchYear, colony }) => {
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
return [];
}
return [
{
day,
event: {
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: id,
payload: { hatchYear, colonyId: colony?.id ?? null },
},
},
];
});
},
};
interface CycleDue {
colonyId: string;
day: string;
/** The UTC day of the last record, null if the colony has none. */
lastOn: string | null;
}
/**
* Living colonies whose cycle comes due in the active season: counted from
* the last record, or from the colony's creation if it has none.
*/
async function colonyCyclesDue(
db: PrismaService,
lastRecordOn: ReadonlyMap<string, Date>,
intervalDays: number,
today: Date,
): Promise<CycleDue[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, createdAt: true },
});
return colonies.flatMap(({ id, createdAt }) => {
const last = lastRecordOn.get(id) ?? null;
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
if (day === null || !inActiveSeason(day)) {
return [];
}
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
});
}
function latestByColony(
rows: { colonyId: string; _max: { date: Date | null } }[],
): ReadonlyMap<string, Date> {
return new Map(
rows.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, _max.date] as const],
),
);
}
/** Living colony: varroa measurement due again in the active season. */
const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = {
type: 'VarroaMeasurementDue',
async occurrences(db, today) {
const latest = await db.varroaMeasurement.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
VARROA_MEASUREMENT_INTERVAL_DAYS,
today,
);
return due.map(({ colonyId, day, lastOn }) => ({
day,
event: {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastMeasuredOn: lastOn },
},
}));
},
};
/** Living colony: inspection overdue in the active season. */
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
type: 'InspectionOverdue',
async occurrences(db, today) {
const latest = await db.inspection.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
INSPECTION_INTERVAL_DAYS,
today,
);
return due.map(({ colonyId, day, lastOn }) => ({
day,
event: {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastInspectedOn: lastOn },
},
}));
},
};
/** The rules of the daily evaluation, one row per time-based event type. */
export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
withdrawalEnded,
taskDue,
queenAged,
varroaMeasurementDue,
inspectionOverdue,
];
@@ -0,0 +1,39 @@
import { PERMISSION_CATALOG } from '../auth/permissions/permission-catalog';
import {
JOB_ALLOWED_ARGS,
JOB_FAILURE_PERMISSION,
JOB_NAMES,
} from './job-names';
// Failure permissions that join the catalog with the System page. The
// change that adds them to the catalog must empty this list: from then on
// the second test fails until it is empty.
const PENDING_FAILURE_PERMISSIONS: readonly string[] = [];
const catalogKeys = new Set<string>(PERMISSION_CATALOG.map((e) => e.key));
describe('job names', () => {
it('every job failure permission is in the permission catalog, except the pending ones', () => {
const unknown = Object.values(JOB_FAILURE_PERMISSION).filter(
(key) =>
!catalogKeys.has(key) && !PENDING_FAILURE_PERMISSIONS.includes(key),
);
expect(unknown).toEqual([]);
});
it('the pending list holds only permissions still missing from the catalog', () => {
expect(
PENDING_FAILURE_PERMISSIONS.filter((key) => catalogKeys.has(key)),
).toEqual([]);
});
it('only rekey accepts an argument, and only --dry-run', () => {
expect(Object.keys(JOB_ALLOWED_ARGS)).toEqual([...JOB_NAMES]);
for (const name of JOB_NAMES) {
expect(JOB_ALLOWED_ARGS[name]).toEqual(
name === 'rekey' ? ['--dry-run'] : [],
);
}
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { PermissionKey } from '../auth/permissions/permission-catalog';
/** The jobs of `node dist/cli.js <job> [args]`, one CronJob each (deploy/k8s). */
export const JOB_NAMES = [
'daily-evaluation',
'task-templates',
'cleanup',
'app-backup',
'rekey',
] as const;
export type JobName = (typeof JOB_NAMES)[number];
/** How the UI and the failure notification name a job. */
export const JOB_LABELS_DE: Record<JobName, string> = {
'daily-evaluation': 'Tägliche Auswertung',
'task-templates': 'Aufgaben aus Vorlagen',
cleanup: 'Aufräumen',
'app-backup': 'App-Backup',
rekey: 'Schlüssel umstellen',
};
/**
* Who is told about a failed run: the holders of the permission that
* manages what the job does. system:manage joins the permission catalog
* together with the System page; until then a failed cleanup or rekey run
* notifies nobody and shows in JobRun and the pod log only.
*/
export const JOB_FAILURE_PERMISSION: Record<JobName, PermissionKey> = {
'daily-evaluation': 'automation:update',
'task-templates': 'automation:update',
cleanup: 'system:manage',
'app-backup': 'backup:configure',
rekey: 'system:manage',
};
/**
* The arguments each job accepts after its name. Anything else is a wrong
* call (exit code 2) and starts nothing: a typo such as `rekey --dry-rn`
* must not run the real job.
*/
export const JOB_ALLOWED_ARGS: Record<JobName, readonly string[]> = {
'daily-evaluation': [],
'task-templates': [],
cleanup: [],
'app-backup': [],
rekey: ['--dry-run'],
};
export function isJobName(value: string): value is JobName {
return (JOB_NAMES as readonly string[]).includes(value);
}
@@ -0,0 +1,52 @@
import { Test } from '@nestjs/testing';
import { Job, JobResult } from './job';
import { JOB_NAMES, JobName } from './job-names';
import { JobRegistry, JOBS } from './job-registry';
import { JobsModule } from './jobs.module';
function stubJob(name: string): Job {
return {
name: name as JobName,
run: (): Promise<JobResult> => Promise.resolve({}),
};
}
const allStubs = (): Job[] => JOB_NAMES.map(stubJob);
describe('JobRegistry', () => {
// Compiled only - no init, so nothing connects to the database.
it('the registry covers every JOB_NAME exactly once', async () => {
const moduleRef = await Test.createTestingModule({
imports: [JobsModule],
}).compile();
try {
const registry = moduleRef.get(JobRegistry);
for (const name of JOB_NAMES) {
expect(registry.get(name).name).toBe(name);
}
expect(moduleRef.get<Job[]>(JOBS)).toHaveLength(JOB_NAMES.length);
} finally {
await moduleRef.close();
}
});
it('refuses a job registered twice', () => {
expect(() => new JobRegistry([...allStubs(), stubJob('cleanup')])).toThrow(
'Job "cleanup" is registered twice',
);
});
it('refuses a registry that misses a job', () => {
const withoutRekey = allStubs().filter((job) => job.name !== 'rekey');
expect(() => new JobRegistry(withoutRekey)).toThrow(
'No job registered for: rekey',
);
});
it('refuses a job whose name is not in JOB_NAMES', () => {
expect(() => new JobRegistry([...allStubs(), stubJob('nope')])).toThrow(
'Unknown job name "nope"',
);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { Inject, Injectable } from '@nestjs/common';
import { Job } from './job';
import { isJobName, JOB_NAMES, JobName } from './job-names';
/** Injection token of every Job provider of JobsModule. */
export const JOBS = Symbol('JOBS');
function indexJobs(jobs: readonly Job[]): ReadonlyMap<JobName, Job> {
const byName = new Map<JobName, Job>();
for (const job of jobs) {
if (!isJobName(job.name)) {
throw new Error(`Unknown job name "${String(job.name)}"`);
}
if (byName.has(job.name)) {
throw new Error(`Job "${job.name}" is registered twice`);
}
byName.set(job.name, job);
}
const missing = JOB_NAMES.filter((name) => !byName.has(name));
if (missing.length > 0) {
throw new Error(`No job registered for: ${missing.join(', ')}`);
}
return byName;
}
/**
* The job behind each JOB_NAME. Checked once when the context is built: a
* name without a job, a job registered twice or a job with a name outside
* JOB_NAMES stops every run instead of failing only the affected CronJob.
*/
@Injectable()
export class JobRegistry {
private readonly jobs: ReadonlyMap<JobName, Job>;
constructor(@Inject(JOBS) jobs: readonly Job[]) {
this.jobs = indexJobs(jobs);
}
get(name: JobName): Job {
const job = this.jobs.get(name);
if (!job) {
// Unreachable: indexJobs requires a job for every JOB_NAME.
throw new Error(`No job registered for: ${name}`);
}
return job;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { JobName } from './job-names';
/**
* What a run reports, stored as JobRun.result and shown on the System page:
* counts, flags and short names - never personal data or secrets.
*/
export type JobResult = Record<string, number | string | boolean | null>;
/**
* One job of dist/cli.js. `args` are the command line arguments after the
* job name (e.g. ['--dry-run']). A job fails its run by throwing; the
* message is stored via loggableError().
*/
export interface Job {
readonly name: JobName;
run(args: readonly string[]): Promise<JobResult>;
}
+73
View File
@@ -0,0 +1,73 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { SystemModule } from '../system/system.module';
import { TasksModule } from '../tasks/tasks.module';
import { AppBackupJob } from './app-backup.job';
import { CleanupJob } from './cleanup.job';
import { DailyEvaluationJob } from './daily-evaluation.job';
import { Job } from './job';
import { JobRegistry, JOBS } from './job-registry';
import { RekeyJob } from './rekey.job';
import { TaskTemplatesJob } from './task-templates.job';
/**
* PermissionResolverService for the job context (BackupService clears its
* cache after a restore, which no job does). Not PermissionsModule: its
* PermissionCatalogSyncService would rewrite the permission catalog on
* every job start. Global like PermissionsModule, so modules that inject
* the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
class JobPermissionsModule {}
const JOB_CLASSES = [
DailyEvaluationJob,
TaskTemplatesJob,
CleanupJob,
AppBackupJob,
RekeyJob,
];
/**
* Root module of dist/cli.js, started with
* NestFactory.createApplicationContext for exactly one run: the modules
* the jobs need, without the HTTP layer (no guards, interceptors, throttler
* or routes - controllers of the imported modules are never served) and
* without the worker loops. No in-process scheduler: no timer may run
* in a job pod.
*/
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuditModule,
NotificationsModule,
DomainEventsModule,
TasksModule,
JobPermissionsModule,
BackupModule,
SystemModule,
],
providers: [
...JOB_CLASSES,
{
provide: JOBS,
useFactory: (...jobs: Job[]): Job[] => jobs,
inject: JOB_CLASSES,
},
JobRegistry,
IdempotencyCleanupService,
],
})
export class JobsModule {}
+569
View File
@@ -0,0 +1,569 @@
import { Logger } from '@nestjs/common';
import * as mfaCrypto from '../auth/mfa/mfa-crypto.util';
import {
decryptMfaSecret,
encryptMfaSecret,
} from '../auth/mfa/mfa-crypto.util';
import { gcmSeal, legacyJwtKey } from '../common/crypto/key-ring';
import { PrismaService } from '../prisma/prisma.service';
import {
openWebhookSecret,
sealWebhookSecret,
} from '../webhooks/webhook-secret';
import { RekeyJob } from './rekey.job';
// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40
// (the same bytes as in mfa-crypto.util.spec.ts and webhook-secret.spec.ts).
const LEGACY_MFA =
'zJD2lAy1b/wslLyQ.hn8scDeaxIADXlRFtcGIyg==.3+5qbYM52lKKN/VXpInUOQ==';
const LEGACY_MFA_PLAINTEXT = 'JBSWY3DPEHPK3PXP';
const LEGACY_WEBHOOK =
'enc:v1:MlOPzAuy67RTMMb9.YslrBRO2dTYumby+P28INg==.KFmilRVOsJtQ';
const LEGACY_WEBHOOK_PLAINTEXT = 'topsecret';
// From before webhook secrets were encrypted at all.
const PLAIN_WEBHOOK = '3f9c2a7d41e8b6c05a1d9e7f3b2c8a64';
interface UserRow {
id: string;
mfaSecret: string | null;
}
interface SubscriptionRow {
id: string;
secret: string;
}
interface UpdateResult {
count: number;
}
interface UserUpdate {
where: { id: string; mfaSecret: string };
data: { mfaSecret: string };
}
interface SubscriptionUpdate {
where: { id: string; secret: string };
data: { secret: string };
}
/** How the code before the key ring sealed: AES-256-GCM under sha256(JWT_ACCESS_SECRET). */
function legacyCiphertext(plaintext: string): string {
const { iv, tag, ciphertext } = gcmSeal(
legacyJwtKey(),
Buffer.from(plaintext, 'utf8'),
);
return [iv, tag, ciphertext].map((part) => part.toString('base64')).join('.');
}
/** Seals with the key that `<variable>_PREVIOUS` names, as if before the rotation. */
function sealedWithPrevious(variable: string, seal: () => string): string {
const current = process.env[variable];
process.env[variable] = process.env[`${variable}_PREVIOUS`];
try {
return seal();
} finally {
process.env[variable] = current;
}
}
/**
* The two tables, in memory. updateMany behaves like the SQL compare-and-
* swap: it writes only while id and old value still match.
*/
function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
const userRows = new Map(users.map((row) => [row.id, row]));
const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row]));
const user = {
findMany: jest.fn(() =>
Promise.resolve(
[...userRows.values()].filter((row) => row.mfaSecret !== null),
),
),
updateMany: jest.fn<Promise<UpdateResult>, [UserUpdate]>(
({ where, data }) => {
const row = userRows.get(where.id);
if (!row || row.mfaSecret !== where.mfaSecret) {
return Promise.resolve({ count: 0 });
}
userRows.set(where.id, { ...row, mfaSecret: data.mfaSecret });
return Promise.resolve({ count: 1 });
},
),
};
const webhookSubscription = {
findMany: jest.fn(() => Promise.resolve([...subscriptionRows.values()])),
updateMany: jest.fn<Promise<UpdateResult>, [SubscriptionUpdate]>(
({ where, data }) => {
const row = subscriptionRows.get(where.id);
if (!row || row.secret !== where.secret) {
return Promise.resolve({ count: 0 });
}
subscriptionRows.set(where.id, { ...row, secret: data.secret });
return Promise.resolve({ count: 1 });
},
),
};
return {
prisma: { user, webhookSubscription } as unknown as PrismaService,
user,
webhookSubscription,
userRows,
subscriptionRows,
mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '',
webhookSecret: (id: string): string =>
subscriptionRows.get(id)?.secret ?? '',
};
}
const keyIdOf = (stored: string): string => stored.split(':')[0];
describe('RekeyJob', () => {
const originalEnv = { ...process.env };
let warn: jest.SpyInstance<void, unknown[]>;
let error: jest.SpyInstance<void, unknown[]>;
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
process.env.MFA_ENCRYPTION_KEY = 'm'.repeat(40);
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40);
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40);
warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
error = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...originalEnv };
jest.restoreAllMocks();
});
/** Everything the job wrote to the log, as one string per call. */
const logged = (): string[] =>
[...warn.mock.calls, ...error.mock.calls].map((call) =>
call.map(String).join(' '),
);
describe('a full set of old and current secrets', () => {
let previousMfa: string;
let currentMfa: string;
let previousWebhook: string;
let currentWebhook: string;
let db: ReturnType<typeof fakeDb>;
beforeEach(() => {
previousMfa = sealedWithPrevious('MFA_ENCRYPTION_KEY', () =>
encryptMfaSecret('PREVIOUSKEYMFA22'),
);
currentMfa = encryptMfaSecret('CURRENTKEYMFA222');
previousWebhook = sealedWithPrevious('WEBHOOK_ENCRYPTION_KEY', () =>
sealWebhookSecret('previous-webhook-secret'),
);
currentWebhook = sealWebhookSecret('current-webhook-secret');
db = fakeDb(
[
{ id: 'u1', mfaSecret: LEGACY_MFA },
{ id: 'u2', mfaSecret: previousMfa },
{ id: 'u3', mfaSecret: currentMfa },
],
[
{ id: 'w1', secret: LEGACY_WEBHOOK },
{ id: 'w2', secret: PLAIN_WEBHOOK },
{ id: 'w3', secret: previousWebhook },
{ id: 'w4', secret: currentWebhook },
],
);
});
const expectedCounts = {
'mfa.total': 3,
'mfa.rekeyed': 2,
'mfa.alreadyCurrent': 1,
'mfa.plaintextSealed': 0,
'mfa.changedMeanwhile': 0,
'mfa.failed': 0,
'webhook.total': 4,
'webhook.rekeyed': 2,
'webhook.alreadyCurrent': 1,
'webhook.plaintextSealed': 1,
'webhook.changedMeanwhile': 0,
'webhook.failed': 0,
};
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => {
const job = new RekeyJob(db.prisma);
const result = await job.run([]);
expect(job.name).toBe('rekey');
expect(result).toEqual({ dryRun: false, ...expectedCounts });
// Every value is now sealed with the current key ...
for (const id of ['u1', 'u2', 'u3']) {
expect(keyIdOf(db.mfaSecret(id))).toBe(keyIdOf(currentMfa));
}
for (const id of ['w1', 'w2', 'w3', 'w4']) {
expect(keyIdOf(db.webhookSecret(id))).toBe(keyIdOf(currentWebhook));
}
// ... a current one was left exactly as it was ...
expect(db.mfaSecret('u3')).toBe(currentMfa);
expect(db.webhookSecret('w4')).toBe(currentWebhook);
expect(db.user.updateMany).toHaveBeenCalledTimes(2);
expect(db.webhookSubscription.updateMany).toHaveBeenCalledTimes(3);
// ... and each write was a compare-and-swap on the value it read.
expect(db.user.updateMany).toHaveBeenCalledWith({
where: { id: 'u1', mfaSecret: LEGACY_MFA },
data: { mfaSecret: db.mfaSecret('u1') },
});
expect(db.webhookSubscription.updateMany).toHaveBeenCalledWith({
where: { id: 'w2', secret: PLAIN_WEBHOOK },
data: { secret: db.webhookSecret('w2') },
});
// Only id and secret are read, in a stable order.
expect(db.user.findMany).toHaveBeenCalledWith({
where: { mfaSecret: { not: null } },
select: { id: true, mfaSecret: true },
orderBy: { id: 'asc' },
});
expect(db.webhookSubscription.findMany).toHaveBeenCalledWith({
select: { id: true, secret: true },
orderBy: { id: 'asc' },
});
});
it('leaves values that open to the original secrets without the old keys', async () => {
await new RekeyJob(db.prisma).run([]);
// After the run: *_PREVIOUS removed and the JWT secret rotated.
delete process.env.MFA_ENCRYPTION_KEY_PREVIOUS;
delete process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS;
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(decryptMfaSecret(db.mfaSecret('u1'))).toBe(LEGACY_MFA_PLAINTEXT);
expect(decryptMfaSecret(db.mfaSecret('u2'))).toBe('PREVIOUSKEYMFA22');
expect(decryptMfaSecret(db.mfaSecret('u3'))).toBe('CURRENTKEYMFA222');
expect(openWebhookSecret(db.webhookSecret('w1'))).toBe(
LEGACY_WEBHOOK_PLAINTEXT,
);
expect(openWebhookSecret(db.webhookSecret('w2'))).toBe(PLAIN_WEBHOOK);
expect(openWebhookSecret(db.webhookSecret('w3'))).toBe(
'previous-webhook-secret',
);
expect(openWebhookSecret(db.webhookSecret('w4'))).toBe(
'current-webhook-secret',
);
});
it('dry-run counts what a run would change and writes nothing', async () => {
const before = {
users: [...db.userRows.values()],
subscriptions: [...db.subscriptionRows.values()],
};
const result = await new RekeyJob(db.prisma).run(['--dry-run']);
expect(result).toEqual({ dryRun: true, ...expectedCounts });
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
expect([...db.userRows.values()]).toEqual(before.users);
expect([...db.subscriptionRows.values()]).toEqual(before.subscriptions);
});
it('a second run rekeys nothing', async () => {
const job = new RekeyJob(db.prisma);
await job.run([]);
db.user.updateMany.mockClear();
db.webhookSubscription.updateMany.mockClear();
const result = await job.run([]);
expect(result).toMatchObject({
'mfa.alreadyCurrent': 3,
'mfa.rekeyed': 0,
'webhook.alreadyCurrent': 4,
'webhook.rekeyed': 0,
'webhook.plaintextSealed': 0,
});
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
});
});
it('resumes after an interruption: current rows are skipped, the rest are rekeyed', async () => {
const stored = [
'AAAAAAAAAAAAAAAA',
'BBBBBBBBBBBBBBBB',
'CCCCCCCCCCCCCCCC',
].map(legacyCiphertext);
const db = fakeDb(
stored.map((mfaSecret, index) => ({ id: `u${index + 1}`, mfaSecret })),
[],
);
const job = new RekeyJob(db.prisma);
const updateMany = db.user.updateMany;
updateMany.mockRejectedValueOnce(new Error('connection reset'));
await expect(job.run([])).rejects.toThrow(
/mfa\.rekeyed=2, .*mfa\.failed=1/,
);
// The failed row still holds its old value, the others were written.
expect(db.mfaSecret('u1')).toBe(stored[0]);
expect(db.mfaSecret('u2')).not.toBe(stored[1]);
expect(db.mfaSecret('u3')).not.toBe(stored[2]);
expect(logged()).toContainEqual(
expect.stringContaining(
'User u1: mfa secret not rekeyed: connection reset',
),
);
updateMany.mockClear();
updateMany.mockResolvedValue({ count: 1 });
expect(await job.run([])).toMatchObject({
'mfa.alreadyCurrent': 2,
'mfa.rekeyed': 1,
'mfa.failed': 0,
});
expect(updateMany).toHaveBeenCalledTimes(1);
expect(updateMany).toHaveBeenCalledWith({
where: { id: 'u1', mfaSecret: stored[0] },
data: { mfaSecret: expect.stringMatching(/^k[0-9a-f]{8}:/) as string },
});
});
it('skips a row changed since it was read and reports it', async () => {
const oldMfa = legacyCiphertext('OLDMFASECRET2222');
const db = fakeDb(
[{ id: 'u1', mfaSecret: oldMfa }],
[{ id: 'w1', secret: PLAIN_WEBHOOK }],
);
// Right after the read, the user sets MFA up anew and the
// subscription is deleted.
const reenrolled = encryptMfaSecret('NEWMFASECRET2222');
db.user.findMany.mockImplementationOnce(() => {
const read = [...db.userRows.values()];
db.userRows.set('u1', { id: 'u1', mfaSecret: reenrolled });
return Promise.resolve(read);
});
db.webhookSubscription.findMany.mockImplementationOnce(() => {
const read = [...db.subscriptionRows.values()];
db.subscriptionRows.delete('w1');
return Promise.resolve(read);
});
const result = await new RekeyJob(db.prisma).run([]);
expect(result).toMatchObject({
'mfa.total': 1,
'mfa.rekeyed': 0,
'mfa.changedMeanwhile': 1,
'mfa.failed': 0,
'webhook.total': 1,
'webhook.plaintextSealed': 0,
'webhook.changedMeanwhile': 1,
'webhook.failed': 0,
});
// The new MFA secret was not overwritten with the old one.
expect(db.mfaSecret('u1')).toBe(reenrolled);
expect(db.subscriptionRows.has('w1')).toBe(false);
expect(logged()).toEqual(
expect.arrayContaining([
expect.stringContaining(
'User u1: mfa secret changed since it was read',
),
expect.stringContaining(
'WebhookSubscription w1: webhook secret changed since it was read',
),
]),
);
});
describe('rows already on the current key', () => {
/** A current-key value whose ciphertext no longer authenticates. */
function corrupted(sealed: string): string {
const parts = sealed.split('.');
const last = parts[parts.length - 1];
parts[parts.length - 1] = (last[0] === 'A' ? 'B' : 'A') + last.slice(1);
return parts.join('.');
}
let brokenMfa: string;
let db: ReturnType<typeof fakeDb>;
beforeEach(() => {
brokenMfa = corrupted(encryptMfaSecret('CURRENTKEYMFA222'));
db = fakeDb(
[
{ id: 'u1', mfaSecret: brokenMfa },
{ id: 'u2', mfaSecret: encryptMfaSecret('CURRENTKEYMFA333') },
],
[
// Plain text that happens to look like a current-key value.
{
id: 'w1',
secret: `${keyIdOf(sealWebhookSecret('probe'))}:looks-sealed`,
},
{ id: 'w2', secret: sealWebhookSecret('fine') },
],
);
});
it('dry-run opens them and fails the run on a value that does not open', async () => {
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1$/,
);
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
});
it('a real run skips them without opening and leaves them as they are', async () => {
const decrypt = jest.spyOn(mfaCrypto, 'decryptMfaSecret');
const result = await new RekeyJob(db.prisma).run([]);
expect(result).toMatchObject({
'mfa.alreadyCurrent': 2,
'mfa.failed': 0,
'webhook.alreadyCurrent': 2,
'webhook.failed': 0,
});
expect(decrypt).not.toHaveBeenCalled();
expect(db.mfaSecret('u1')).toBe(brokenMfa);
});
});
describe('secrets that cannot be opened', () => {
let unknownKey: string;
let staleLegacy: string;
let previousMfa: string;
let db: ReturnType<typeof fakeDb>;
beforeEach(() => {
// Sealed with a key that is neither current nor previous (e.g. the
// previous key was removed before the rekey ran).
unknownKey = `k00000000:${LEGACY_MFA}`;
staleLegacy = legacyCiphertext('STALELEGACYMFA22');
previousMfa = sealedWithPrevious('MFA_ENCRYPTION_KEY', () =>
encryptMfaSecret('PREVIOUSKEYMFA22'),
);
db = fakeDb(
[
{ id: 'u1', mfaSecret: unknownKey },
{ id: 'u2', mfaSecret: staleLegacy },
{ id: 'u3', mfaSecret: previousMfa },
],
[
{ id: 'w1', secret: 'enc:v1:not-a-ciphertext' },
{ id: 'w2', secret: PLAIN_WEBHOOK },
],
);
// JWT_ACCESS_SECRET was rotated before the rekey ran.
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
});
it('fails the run after every other row was rekeyed, leaving those rows as they are', async () => {
const run = new RekeyJob(db.prisma).run([]);
await expect(run).rejects.toThrow(
'rekey: 3 secret(s) could not be rekeyed - dryRun=false, mfa.total=3, mfa.rekeyed=1, mfa.alreadyCurrent=0, mfa.plaintextSealed=0, mfa.changedMeanwhile=0, mfa.failed=2, webhook.total=2, webhook.rekeyed=0, webhook.alreadyCurrent=0, webhook.plaintextSealed=1, webhook.changedMeanwhile=0, webhook.failed=1',
);
expect(db.mfaSecret('u1')).toBe(unknownKey);
expect(db.mfaSecret('u2')).toBe(staleLegacy);
expect(db.webhookSecret('w1')).toBe('enc:v1:not-a-ciphertext');
expect(decryptMfaSecret(db.mfaSecret('u3'))).toBe('PREVIOUSKEYMFA22');
expect(openWebhookSecret(db.webhookSecret('w2'))).toBe(PLAIN_WEBHOOK);
expect(logged()).toEqual(
expect.arrayContaining([
expect.stringContaining(
'User u1: mfa secret not rekeyed: unknown key id',
),
expect.stringContaining(
'User u2: mfa secret not rekeyed: ciphertext failed authentication',
),
expect.stringContaining(
'WebhookSubscription w1: webhook secret not rekeyed: malformed ciphertext',
),
]),
);
});
it('dry-run reports them too and writes nothing', async () => {
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1$/,
);
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
});
it('never logs or reports a secret or ciphertext', async () => {
const values = [
unknownKey,
staleLegacy,
previousMfa,
'enc:v1:not-a-ciphertext',
PLAIN_WEBHOOK,
'STALELEGACYMFA22',
'PREVIOUSKEYMFA22',
];
const fragments = values.flatMap((value) =>
value.split(/[:.]/).filter((part) => part.length >= 8),
);
const message = await new RekeyJob(db.prisma).run([]).then(
() => '',
(failure: Error) => failure.message,
);
expect(message).toContain('mfa.failed=2');
for (const text of [message, ...logged()]) {
for (const fragment of fragments) {
expect(text).not.toContain(fragment);
}
}
});
});
it('checks that the new value opens to the same secret before it replaces the old one', async () => {
const stored = legacyCiphertext('ORIGINALMFA22222');
const db = fakeDb([{ id: 'u1', mfaSecret: stored }], []);
const wrongSeal = encryptMfaSecret('SOMETHINGELSE222');
jest.spyOn(mfaCrypto, 'encryptMfaSecret').mockReturnValue(wrongSeal);
await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow(
'mfa.failed=1',
);
expect(db.user.updateMany).not.toHaveBeenCalled();
expect(db.mfaSecret('u1')).toBe(stored);
expect(logged()).toContainEqual(
expect.stringContaining(
'User u1: mfa secret not rekeyed: resealed secret does not open to the same value',
),
);
});
it('fails before reading any row when a key is set but unusable', async () => {
process.env.WEBHOOK_ENCRYPTION_KEY = 'too-short';
const db = fakeDb([{ id: 'u1', mfaSecret: LEGACY_MFA }], []);
await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow(
'WEBHOOK_ENCRYPTION_KEY must be at least 32 characters',
);
expect(db.user.findMany).not.toHaveBeenCalled();
expect(db.webhookSubscription.findMany).not.toHaveBeenCalled();
});
// dist/cli.js refuses these already (JOB_ALLOWED_ARGS); a typo must never
// run the real rekey, however the job is called.
it.each([[['--dry-rn']], [['--dry-run', 'extra']]])(
'refuses %j before reading any row',
async (args) => {
const db = fakeDb([{ id: 'u1', mfaSecret: LEGACY_MFA }], []);
await expect(new RekeyJob(db.prisma).run(args)).rejects.toThrow(
'rekey does not accept',
);
expect(db.user.findMany).not.toHaveBeenCalled();
},
);
});
+265
View File
@@ -0,0 +1,265 @@
import { Injectable, Logger } from '@nestjs/common';
import {
decryptMfaSecret,
encryptMfaSecret,
mfaSecretNeedsRekey,
} from '../auth/mfa/mfa-crypto.util';
import { loadKeyRing, SecretPurpose } from '../common/crypto/key-ring';
import { loggableError } from '../common/loggable-error';
import { PrismaService } from '../prisma/prisma.service';
import {
isPlaintextWebhookSecret,
openWebhookSecret,
sealWebhookSecret,
webhookSecretNeedsRekey,
} from '../webhooks/webhook-secret';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/** `rekey --dry-run`: open and count every secret, write none. */
export const REKEY_DRY_RUN_ARG = '--dry-run';
/** What one run did with the secrets of one purpose (mfa, webhook). */
export interface RekeyCounts {
/** Rows with a stored secret. */
total: number;
/** Sealed with the previous key or before the key ring, resealed. */
rekeyed: number;
/**
* Already sealed with the current key, left as they are. Only a dry-run
* opens them (a value that does not open counts as `failed`).
*/
alreadyCurrent: number;
/** Webhook secrets stored in plain text, now sealed. */
plaintextSealed: number;
/** Written by someone else since this run read them; not overwritten. */
changedMeanwhile: number;
/** Could not be opened or written; they keep their old value. */
failed: number;
}
type RowOutcome = Exclude<keyof RekeyCounts, 'total'>;
interface StoredSecret {
id: string;
stored: string;
}
/** One column of sealed secrets, with the helpers of its purpose. */
interface SecretColumn {
purpose: SecretPurpose;
/** How log lines name a row: "<entity> <id>". */
entity: string;
read(): Promise<StoredSecret[]>;
/**
* Writes `next` only while the row still holds `previous` - one UPDATE
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
* another run) is never overwritten. False if nothing matched.
*/
swap(id: string, previous: string, next: string): Promise<boolean>;
needsRekey(stored: string): boolean;
isPlaintext(stored: string): boolean;
open(stored: string): string;
seal(plaintext: string): string;
}
/** Refuses anything but --dry-run: a typo must never start the real run. */
function isDryRun(args: readonly string[]): boolean {
const refused = args.filter((arg) => arg !== REKEY_DRY_RUN_ARG);
if (refused.length > 0) {
throw new Error(
`rekey does not accept ${refused.map((arg) => JSON.stringify(arg)).join(', ')}`,
);
}
return args.length > 0;
}
function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
const count = (outcome: RowOutcome): number =>
outcomes.filter((each) => each === outcome).length;
return {
total: outcomes.length,
rekeyed: count('rekeyed'),
alreadyCurrent: count('alreadyCurrent'),
plaintextSealed: count('plaintextSealed'),
changedMeanwhile: count('changedMeanwhile'),
failed: count('failed'),
};
}
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
function toJobResult(
dryRun: boolean,
counts: readonly (readonly [SecretPurpose, RekeyCounts])[],
): JobResult {
return {
dryRun,
...Object.fromEntries(
counts.flatMap(([purpose, perPurpose]) =>
(Object.keys(perPurpose) as (keyof RekeyCounts)[]).map((key) => [
`${purpose}.${key}`,
perPurpose[key],
]),
),
),
};
}
function describeResult(result: JobResult): string {
return Object.entries(result)
.map(([key, value]) => `${key}=${String(value)}`)
.join(', ');
}
/**
* Reseals every stored MFA (TOTP) and webhook signing secret with the
* current key of its purpose (common/crypto/key-ring.ts): values sealed
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
* still stored in plain text. Run it after setting new keys, before
* removing `*_PREVIOUS` and before rotating the JWT secrets.
*
* One row at a time, each write its own compare-and-swap statement, no
* transaction around the run: an interrupted run keeps what it resealed and
* the next run skips those rows. A row that cannot be opened or written
* keeps its old value; the run goes on and fails at the end, with the
* counts in its error. Logs name rows by id, never a secret or ciphertext.
*/
@Injectable()
export class RekeyJob implements Job {
readonly name: JobName = 'rekey';
private readonly logger = new Logger(RekeyJob.name);
constructor(private readonly prisma: PrismaService) {}
async run(args: readonly string[]): Promise<JobResult> {
const dryRun = isDryRun(args);
const columns = [this.mfaColumn(), this.webhookColumn()];
// A key that is set but unusable fails the run before any row is read.
for (const column of columns) {
loadKeyRing(column.purpose);
}
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
for (const column of columns) {
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
}
const result = toJobResult(dryRun, counts);
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
if (failed > 0) {
throw new Error(
`rekey: ${failed} secret(s) could not be rekeyed - ${describeResult(result)}`,
);
}
return result;
}
private async rekeyColumn(
column: SecretColumn,
dryRun: boolean,
): Promise<RekeyCounts> {
const rows = await column.read();
const outcomes: RowOutcome[] = [];
for (const row of rows) {
outcomes.push(await this.rekeyRow(column, row, dryRun));
}
return countOutcomes(outcomes);
}
private async rekeyRow(
column: SecretColumn,
row: StoredSecret,
dryRun: boolean,
): Promise<RowOutcome> {
const current = !column.needsRekey(row.stored);
// A real run skips current rows unopened; a dry-run opens them too, so
// the closing all-zero check also catches a corrupted current value.
if (current && !dryRun) {
return 'alreadyCurrent';
}
const outcome: RowOutcome = current
? 'alreadyCurrent'
: column.isPlaintext(row.stored)
? 'plaintextSealed'
: 'rekeyed';
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
try {
// Opened in a dry-run too: it reports what a real run could not reseal.
const plaintext = column.open(row.stored);
if (dryRun) {
return outcome;
}
const resealed = column.seal(plaintext);
// The swap replaces the only copy of the old value.
if (column.open(resealed) !== plaintext) {
throw new Error('resealed secret does not open to the same value');
}
if (await column.swap(row.id, row.stored, resealed)) {
return outcome;
}
this.logger.warn(
`${subject} changed since it was read, left as it is (run rekey --dry-run to check)`,
);
return 'changedMeanwhile';
} catch (error: unknown) {
this.logger.error(
`${subject} not rekeyed: ${loggableError(error).message}`,
);
return 'failed';
}
}
private mfaColumn(): SecretColumn {
return {
purpose: 'mfa',
entity: 'User',
read: async () => {
const users = await this.prisma.user.findMany({
where: { mfaSecret: { not: null } },
select: { id: true, mfaSecret: true },
orderBy: { id: 'asc' },
});
return users.flatMap(({ id, mfaSecret }) =>
mfaSecret === null ? [] : [{ id, stored: mfaSecret }],
);
},
swap: async (id, previous, next) => {
const { count } = await this.prisma.user.updateMany({
where: { id, mfaSecret: previous },
data: { mfaSecret: next },
});
return count === 1;
},
needsRekey: mfaSecretNeedsRekey,
// TOTP secrets were never stored in plain text.
isPlaintext: () => false,
open: decryptMfaSecret,
seal: encryptMfaSecret,
};
}
private webhookColumn(): SecretColumn {
return {
purpose: 'webhook',
entity: 'WebhookSubscription',
read: async () => {
const subscriptions = await this.prisma.webhookSubscription.findMany({
select: { id: true, secret: true },
orderBy: { id: 'asc' },
});
return subscriptions.map(({ id, secret }) => ({ id, stored: secret }));
},
swap: async (id, previous, next) => {
const { count } = await this.prisma.webhookSubscription.updateMany({
where: { id, secret: previous },
data: { secret: next },
});
return count === 1;
},
needsRekey: webhookSecretNeedsRekey,
isPlaintext: isPlaintextWebhookSecret,
open: openWebhookSecret,
seal: sealWebhookSecret,
};
}
}
+216
View File
@@ -0,0 +1,216 @@
import { INestApplicationContext, Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { JobRun, Prisma } from '../../generated/prisma';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { JobResult } from './job';
import { JobName } from './job-names';
import { JobRegistry } from './job-registry';
import { JobsModule } from './jobs.module';
import { runCli } from './run-cli';
type RawCall = [readonly string[], ...unknown[]];
const STARTED_AT = new Date('2026-10-13T04:00:00.000Z');
describe('runCli', () => {
let job: { name: JobName; run: jest.Mock<Promise<JobResult>, [string[]]> };
let jobRun: { create: jest.Mock };
// The run is finished by one UPDATE … RETURNING * (finishedAt = now()).
let finish: jest.Mock<Promise<JobRun[]>, RawCall>;
let app: { get: jest.Mock; close: jest.Mock };
let createContext: jest.Mock<Promise<INestApplicationContext>, []>;
let errorSpy: jest.SpyInstance;
beforeEach(() => {
job = {
name: 'cleanup',
run: jest
.fn<Promise<JobResult>, [string[]]>()
.mockResolvedValue({ deleted: 2 }),
};
jobRun = {
create: jest.fn(({ data }: { data: { job: string } }): Promise<JobRun> =>
Promise.resolve({
id: 'run-1',
job: data.job,
status: 'RUNNING',
startedAt: STARTED_AT,
finishedAt: null,
result: null,
error: null,
}),
),
};
finish = jest.fn<Promise<JobRun[]>, RawCall>((_sql, status) =>
Promise.resolve([
{
id: 'run-1',
job: job.name,
status: status as JobRun['status'],
startedAt: STARTED_AT,
finishedAt: new Date(),
result: null,
error: null,
},
]),
);
const tx = {
$executeRaw: jest.fn().mockResolvedValue(1),
$queryRaw: jest.fn().mockResolvedValue([{ notified: false }]),
};
const prisma = {
jobRun,
$queryRaw: finish,
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
};
const notifications = {
recordForPermission: jest.fn().mockResolvedValue(1),
};
const jobRuns = new JobRunService(
prisma as unknown as PrismaService,
notifications as unknown as NotificationsService,
);
const registry = { get: jest.fn(() => job) };
app = {
get: jest.fn((token: unknown) =>
token === JobRegistry ? registry : jobRuns,
),
close: jest.fn().mockResolvedValue(undefined),
};
createContext = jest
.fn<Promise<INestApplicationContext>, []>()
.mockResolvedValue(app as unknown as INestApplicationContext);
errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('rejects an unknown job with exit code 2 and writes no JobRun', async () => {
expect(await runCli(['nope'], createContext)).toBe(2);
expect(createContext).not.toHaveBeenCalled();
expect(jobRun.create).not.toHaveBeenCalled();
});
it('rejects a call without a job name with exit code 2', async () => {
expect(await runCli([], createContext)).toBe(2);
expect(createContext).not.toHaveBeenCalled();
});
it('runs the job, records SUCCEEDED with its result and returns 0', async () => {
expect(await runCli(['cleanup'], createContext)).toBe(0);
expect(jobRun.create).toHaveBeenCalledWith({ data: { job: 'cleanup' } });
expect(finish.mock.calls[0].slice(1)).toEqual([
'SUCCEEDED',
'{"deleted":2}',
null,
'run-1',
]);
expect(app.close).toHaveBeenCalledTimes(1);
});
it('passes extra arguments such as --dry-run to the job', async () => {
job.name = 'rekey';
expect(await runCli(['rekey', '--dry-run'], createContext)).toBe(0);
expect(job.run).toHaveBeenCalledWith(['--dry-run']);
});
// A typo or a flag the job does not know must not start the real run -
// `rekey --dry-rn` would otherwise rewrite every secret.
it.each([
[['cleanup', '--dry-run']],
[['rekey', '--dry-rn']],
[['rekey', '--dry-run', '--dry-run', 'extra']],
])('rejects %j with exit code 2 before any context exists', async (argv) => {
expect(await runCli(argv, createContext)).toBe(2);
expect(createContext).not.toHaveBeenCalled();
expect(job.run).not.toHaveBeenCalled();
});
it('returns 1 and stores the error via loggableError when the job fails', async () => {
job.run.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('name "Max Mustermann"', {
code: 'P2024',
clientVersion: '7.9.1',
meta: { modelName: 'User' },
}),
);
expect(await runCli(['cleanup'], createContext)).toBe(1);
expect(finish.mock.calls[0].slice(1)).toEqual([
'FAILED',
null,
'PrismaClientKnownRequestError P2024 (User)',
'run-1',
]);
const logged = JSON.stringify(errorSpy.mock.calls);
expect(logged).not.toContain('Mustermann');
expect(app.close).toHaveBeenCalledTimes(1);
});
// No JobRun can be written then - the exit code and the log are all the
// CronJob shows.
it('returns 1 when the context cannot be created, without the Prisma message', async () => {
createContext.mockRejectedValue(
new Prisma.PrismaClientInitializationError(
'Can\'t reach database at "db.internal" for user "geheim"',
'7.9.1',
),
);
expect(await runCli(['cleanup'], createContext)).toBe(1);
const logged = JSON.stringify(errorSpy.mock.calls);
expect(logged).toContain('PrismaClientInitializationError');
expect(logged).not.toContain('geheim');
});
// Without abortOnError: false, Nest exits the process (code 1, no log
// through our logger) on a DI or registry error instead of rejecting.
it('builds the default context without aborting the process on a module error', async () => {
const createApplicationContext = jest
.spyOn(NestFactory, 'createApplicationContext')
.mockRejectedValue(new Error('No job registered for: rekey'));
expect(await runCli(['cleanup'])).toBe(1);
expect(createApplicationContext).toHaveBeenCalledWith(JobsModule, {
abortOnError: false,
});
expect(JSON.stringify(errorSpy.mock.calls)).toContain(
'No job registered for: rekey',
);
});
it('returns 1 and still closes the context when the run cannot be recorded', async () => {
jobRun.create.mockRejectedValue(new Error('connection reset'));
expect(await runCli(['cleanup'], createContext)).toBe(1);
expect(job.run).not.toHaveBeenCalled();
expect(app.close).toHaveBeenCalledTimes(1);
});
it('keeps the exit code when closing the context fails', async () => {
app.close.mockRejectedValue(new Error('pool already closed'));
expect(await runCli(['cleanup'], createContext)).toBe(0);
expect(JSON.stringify(errorSpy.mock.calls)).toContain(
'pool already closed',
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { INestApplicationContext, Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { loggableError } from '../common/loggable-error';
import { JobRunService } from '../system/job-run.service';
import { isJobName, JOB_ALLOWED_ARGS, JOB_NAMES, JobName } from './job-names';
import { JobRegistry } from './job-registry';
import { JobsModule } from './jobs.module';
/** Exit codes of dist/cli.js: run succeeded, run failed, wrong call. */
export type CliExitCode = 0 | 1 | 2;
const logger = new Logger('Cli');
const USAGE = `Usage: node dist/cli.js <job> [args] - jobs: ${JOB_NAMES.join(', ')}`;
/**
* The context of one job run. abortOnError: false makes a module error
* (DI, JobRegistry) reject, so runCli logs it and exits 1 - by default Nest
* would end the process itself. No shutdown hooks: a pod that is killed
* leaves its run RUNNING, and the cleanup job marks it aborted.
*/
export function createJobsContext(): Promise<INestApplicationContext> {
return NestFactory.createApplicationContext(JobsModule, {
abortOnError: false,
});
}
async function closeContext(app: INestApplicationContext): Promise<void> {
try {
await app.close();
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(`Closing the job context failed: ${message}`, stack);
}
}
/** True for a known job with accepted arguments; otherwise logs why not. */
function isValidCall(
name: string | undefined,
args: readonly string[],
): name is JobName {
if (name === undefined || !isJobName(name)) {
logger.error(
name === undefined
? 'No job given'
: `Unknown job ${JSON.stringify(name)}`,
);
logger.error(USAGE);
return false;
}
const allowed = JOB_ALLOWED_ARGS[name];
const refused = args.filter((arg) => !allowed.includes(arg));
if (refused.length > 0) {
logger.error(
`Job "${name}" does not accept ${refused.map((arg) => JSON.stringify(arg)).join(', ')} (allowed: ${allowed.join(', ') || 'no arguments'})`,
);
return false;
}
return true;
}
/**
* Runs `node dist/cli.js <job> [args]`: argv[0] is the job, the rest goes
* to the job unchanged. A missing or unknown job, or an argument outside
* JOB_ALLOWED_ARGS, is refused before anything connects, so it writes no
* JobRun.
*/
export async function runCli(
argv: readonly string[],
createContext: () => Promise<INestApplicationContext> = createJobsContext,
): Promise<CliExitCode> {
const [name, ...args] = argv;
if (!isValidCall(name, args)) {
return 2;
}
let app: INestApplicationContext | undefined;
try {
app = await createContext();
const job = app.get(JobRegistry).get(name);
const run = await app.get(JobRunService).run(name, () => job.run(args));
return run.status === 'SUCCEEDED' ? 0 : 1;
} catch (error: unknown) {
// The run could not even be recorded (no context, no database).
const { message, stack } = loggableError(error);
logger.error(`Job "${name}" could not run: ${message}`, stack);
return 1;
} finally {
if (app) {
await closeContext(app);
}
}
}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { TasksService } from '../tasks/tasks.service';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
/**
* Creates the due tasks of the recurring templates (CronJob 05:00), so they
* exist without anyone opening the task list first. Running twice, or
* alongside the lazy generation before GET /tasks, creates each occurrence
* once: generateFromTemplates claims it by compare-and-swap.
*/
@Injectable()
export class TaskTemplatesJob implements Job {
readonly name: JobName = 'task-templates';
constructor(private readonly tasks: TasksService) {}
async run(): Promise<JobResult> {
return { created: await this.tasks.generateFromTemplates() };
}
}