mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
312 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
});
|