Files
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

312 lines
9.7 KiB
TypeScript

/**
* APNs dispatch integration test (v1.4.23 Wave 3 / F4).
*
* Drives `dispatchNotification` against a real Postgres testcontainer
* with a stubbed `@parse/node-apn` Provider. Asserts:
*
* 1. Round-trip: a user with an APNS NotificationChannel + a Device
* row that carries `apnsToken` + `apnsEnvironment` receives an APNs
* push and the channel-state machine records success.
* 2. Failure path: when APNs returns `Unregistered` for every device,
* the Device row is deleted, the channel is auto-disabled with the
* captured reason, and an audit-log entry is written.
* 3. Cascade: when APNs is auto-disabled, the dispatcher falls through
* to Telegram on the next dispatch (different audit / different
* sender invoked).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Crypto module reads ENCRYPTION_KEY lazily on first encrypt(); seed a
// deterministic 32-byte test key before any test imports @/lib/crypto.
// Mirrors the pattern used by admin-backups-download.test.ts et al.
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const apnsSendMock = vi.fn();
const apnsShutdownMock = vi.fn();
vi.mock("@parse/node-apn", () => {
class Provider {
constructor(_opts: unknown) {
void _opts;
}
send(...args: unknown[]) {
return apnsSendMock(...args);
}
shutdown() {
return apnsShutdownMock();
}
}
class Notification {
public topic = "";
public alert: unknown;
public sound: string | undefined;
public badge: number | undefined;
public threadId: string | undefined;
public collapseId: string | undefined;
public payload: unknown;
}
return { default: { Provider, Notification }, Provider, Notification };
});
const telegramSendMock = vi.fn();
vi.mock("@/lib/notifications/senders/telegram", () => ({
sendViaTelegram: (...args: unknown[]) => telegramSendMock(...args),
}));
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-apns-dispatch-test";
// v1.4.47.2 — `loadApnsConfig` verifies the key parses as an ES256-compatible
// asymmetric key via `crypto.createPrivateKey`, so the fixture must be a real
// EC P-256 PEM (test-only, generated for this suite). Mirrors the constant
// used by the unit-level apns.test.ts so both layers exercise the live guard.
const TEST_EC_PEM_LINES = [
"-----BEGIN PRIVATE KEY-----",
"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN",
"pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU",
"1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ",
"bw81BG89",
"-----END PRIVATE KEY-----",
];
const APNS_ENV = {
APNS_KEY_ID: "ABCDE12345",
APNS_TEAM_ID: "TEAM123456",
APNS_BUNDLE_ID: "test.healthlog.ios",
// 12-factor `\n`-escaped single-line form a typical Coolify / docker-compose
// `env_file` round-trip produces; `normaliseApnsPem` turns it back into the
// multi-line PEM the EC parser expects.
APNS_KEY: TEST_EC_PEM_LINES.join("\\n"),
};
beforeEach(async () => {
vi.clearAllMocks();
for (const [k, v] of Object.entries(APNS_ENV)) {
process.env[k] = v;
}
delete process.env.APNS_PRODUCTION;
// Reset the apns-sender module so the env-var loader re-reads.
const apns = await import("@/lib/notifications/senders/apns");
apns.resetApnsForTesting();
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "apns-dispatch",
email: "apns-dispatch@example.test",
},
});
});
afterEach(async () => {
for (const k of [
"APNS_KEY_ID",
"APNS_TEAM_ID",
"APNS_BUNDLE_ID",
"APNS_KEY",
"APNS_KEY_FILE",
"APNS_PRODUCTION",
]) {
delete process.env[k];
}
const apns = await import("@/lib/notifications/senders/apns");
apns.resetApnsForTesting();
});
async function seedApnsChannel(): Promise<string> {
const { encrypt } = await import("@/lib/crypto");
const channel = await getPrismaClient().notificationChannel.create({
data: {
userId: TEST_USER_ID,
type: "APNS",
enabled: true,
// APNS channel config is the empty record by design (per-device
// token + environment live on the Device row), but the dispatcher
// still decrypts it before the type switch — store an encrypted
// empty JSON object so the path stays symmetric.
config: encrypt("{}"),
},
});
return channel.id;
}
async function seedDevice(opts: {
apnsToken: string;
apnsEnvironment?: "sandbox" | "production";
token?: string;
}): Promise<string> {
const device = await getPrismaClient().device.create({
data: {
userId: TEST_USER_ID,
platform: "ios",
token: opts.token ?? `legacy-${opts.apnsToken}`,
bundleId: APNS_ENV.APNS_BUNDLE_ID,
apnsToken: opts.apnsToken,
apnsEnvironment: opts.apnsEnvironment ?? "sandbox",
},
});
return device.id;
}
describe("APNs dispatcher integration", () => {
it("delivers a push end-to-end, records success, and clears cooldown", async () => {
await seedApnsChannel();
await seedDevice({ apnsToken: "abc123" });
apnsSendMock.mockResolvedValueOnce({
sent: [{ device: "abc123" }],
failed: [],
});
const { dispatchNotification } =
await import("@/lib/notifications/dispatcher");
await dispatchNotification({
userId: TEST_USER_ID,
eventType: "MEDICATION_REMINDER",
title: "Time for your meds",
message: "Tylenol — 1 tablet",
});
expect(apnsSendMock).toHaveBeenCalledTimes(1);
const note = apnsSendMock.mock.calls[0][0];
expect(note.topic).toBe(APNS_ENV.APNS_BUNDLE_ID);
expect(note.alert.title).toBe("Time for your meds");
expect(note.alert.body).toBe("Tylenol — 1 tablet");
expect(note.payload).toMatchObject({ eventType: "MEDICATION_REMINDER" });
const ch = await getPrismaClient().notificationChannel.findFirst({
where: { userId: TEST_USER_ID, type: "APNS" },
});
expect(ch?.enabled).toBe(true);
expect(ch?.consecutiveFailures).toBe(0);
expect(ch?.lastSuccessAt).toBeInstanceOf(Date);
expect(ch?.nextRetryAt).toBeNull();
});
it("auto-disables the channel + deletes the dead Device on Unregistered", async () => {
await seedApnsChannel();
await seedDevice({ apnsToken: "dead-tok" });
apnsSendMock.mockResolvedValueOnce({
sent: [],
failed: [
{
device: "dead-tok",
status: 410,
response: { reason: "Unregistered" },
},
],
});
const { dispatchNotification } =
await import("@/lib/notifications/dispatcher");
await dispatchNotification({
userId: TEST_USER_ID,
eventType: "SYSTEM_ALERT",
title: "test",
message: "test",
});
const ch = await getPrismaClient().notificationChannel.findFirst({
where: { userId: TEST_USER_ID, type: "APNS" },
});
expect(ch?.enabled).toBe(false);
expect(ch?.disabledReason).toBe("Unregistered");
const deadDevice = await getPrismaClient().device.findFirst({
where: { apnsToken: "dead-tok" },
});
expect(deadDevice).toBeNull();
const audit = await getPrismaClient().auditLog.findFirst({
where: {
userId: TEST_USER_ID,
action: "notification.channel.auto_disabled",
},
});
expect(audit).toBeTruthy();
});
it("bounds a hung APNs provider and falls through to Telegram", async () => {
const { encrypt } = await import("@/lib/crypto");
await seedApnsChannel();
await seedDevice({ apnsToken: "hung-apns-token" });
await getPrismaClient().notificationChannel.create({
data: {
userId: TEST_USER_ID,
type: "TELEGRAM",
enabled: true,
config: encrypt(
JSON.stringify({ botToken: "telegram-token", chatId: "chat-id" }),
),
},
});
apnsSendMock.mockImplementationOnce(() => new Promise(() => undefined));
telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 });
const { dispatchNotification } =
await import("@/lib/notifications/dispatcher");
const result = await dispatchNotification({
userId: TEST_USER_ID,
eventType: "MEDICATION_REMINDER",
title: "fallback",
message: "fallback",
});
expect(apnsSendMock).toHaveBeenCalledTimes(1);
expect(telegramSendMock).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
dispatched: true,
channelsAttempted: 2,
channelsSucceeded: 1,
});
}, 7_000);
it("falls through to Telegram when APNS is disabled", async () => {
const { encrypt } = await import("@/lib/crypto");
// Pre-existing disabled APNS channel from a previous failure run.
await getPrismaClient().notificationChannel.create({
data: {
userId: TEST_USER_ID,
type: "APNS",
enabled: false,
disabledReason: "Unregistered",
config: encrypt("{}"),
},
});
// Healthy Telegram channel.
await getPrismaClient().notificationChannel.create({
data: {
userId: TEST_USER_ID,
type: "TELEGRAM",
enabled: true,
config: encrypt(JSON.stringify({ botToken: "tok", chatId: "chat" })),
},
});
telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 });
const { dispatchNotification } =
await import("@/lib/notifications/dispatcher");
await dispatchNotification({
userId: TEST_USER_ID,
eventType: "MEDICATION_REMINDER",
title: "fallback",
message: "fallback",
});
expect(apnsSendMock).not.toHaveBeenCalled();
expect(telegramSendMock).toHaveBeenCalledTimes(1);
const tg = await getPrismaClient().notificationChannel.findFirst({
where: { userId: TEST_USER_ID, type: "TELEGRAM" },
});
expect(tg?.lastSuccessAt).toBeInstanceOf(Date);
});
});