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:
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'] : [],
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user