Files
HealthLog/tests/integration/notification-attribution.test.ts
Marc-André Bombeck e8ce0e277a test(02-11): verify final claim uses database time
- Keep a stale application clock from authorizing an expired Guardian\n
2026-08-06 13:47:42 +02:00

654 lines
20 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
claimManagedGuardianEgressAuthorization,
resolveNotificationDeliveryIdentity,
} from "@/lib/notifications/delivery-identity";
import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
let sequence = 0;
async function createUser(label: string, managed = false) {
const suffix = sequence++;
return getPrismaClient().user.create({
data: {
username: `${label}-${suffix}`,
email: `${label}-${suffix}@example.test`,
managedProfileAt: managed ? new Date() : null,
},
});
}
async function createGuardianGrant(input: {
recordUserId: string;
recipientUserId: string;
access?: "READ" | "WRITE" | "MANAGE";
acceptedAt?: Date | null;
revokedAt?: Date | null;
expiresAt?: Date | null;
}) {
return getPrismaClient().accountGrant.create({
data: {
grantorId: input.recordUserId,
granteeId: input.recipientUserId,
access: input.access ?? "MANAGE",
acceptedAt:
input.acceptedAt === undefined ? new Date() : input.acceptedAt,
revokedAt: input.revokedAt ?? null,
revokedBy: input.revokedAt ? "GRANTOR" : null,
expiresAt: input.expiresAt ?? null,
},
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
describe("notification attribution (real Postgres)", () => {
it("persistence retains distinct record and recipient principals", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
recordPushAttempt({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
});
await vi.waitFor(async () => {
const attempt = await getPrismaClient().pushAttempt.findFirstOrThrow({
where: {
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
},
select: {
recordUserId: true,
recipientUserId: true,
channel: true,
eventType: true,
},
});
expect(attempt).toEqual({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
});
});
});
it("persistence leaves explicit self attribution unchanged", async () => {
const self = await createUser("self");
const attempt = await getPrismaClient().pushAttempt.create({
data: {
userId: self.id,
recordUserId: self.id,
recipientUserId: self.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
select: {
userId: true,
recordUserId: true,
recipientUserId: true,
},
});
expect(attempt).toEqual({
userId: self.id,
recordUserId: self.id,
recipientUserId: self.id,
});
});
it("persists a managed reminder anchor as a record event without a delivery attempt", async () => {
const recordUser = await createUser("managed-record", true);
const client = getPrismaClient();
const now = new Date();
await expect(
claimNotificationEvent(client, {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
since: new Date(now.getTime() - 48 * 60 * 60 * 1000),
}),
).resolves.toBe(true);
await expect(
claimNotificationEvent(client, {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:slot",
since: new Date(now.getTime() - 48 * 60 * 60 * 1000),
}),
).resolves.toBe(false);
const event = await client.notificationEvent.findFirstOrThrow({
where: { recordUserId: recordUser.id, dedupKey: "med:slot" },
select: { id: true },
});
expect(await client.pushAttempt.count()).toBe(0);
await client.user.delete({ where: { id: recordUser.id } });
expect(
await client.notificationEvent.findUnique({ where: { id: event.id } }),
).toBeNull();
});
it("provides the created-at index used by global notification event retention", async () => {
const indexes = await getPrismaClient().$queryRaw<
Array<{ indexname: string }>
>`
SELECT indexname
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'notification_events'
`;
expect(indexes.map(({ indexname }) => indexname)).toContain(
"notification_events_created_at_idx",
);
});
it("refuses a revoked Guardian after identity preflight but before the final egress claim", async () => {
const recordUser = await createUser("expiry-record", true);
const recipientUser = await createUser("expiry-recipient");
const grant = await createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
});
const delivery = await resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
title: "record reminder",
message: "record message",
});
expect(delivery).toMatchObject({ managed: true });
await getPrismaClient().accountGrant.update({
where: { id: grant.id },
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
});
await expect(
claimManagedGuardianEgressAuthorization(
delivery!,
"NTFY",
"MEDICATION_REMINDER",
),
).resolves.toBeNull();
expect(
await getPrismaClient().notificationEgressAuthorization.count(),
).toBe(0);
expect(await getPrismaClient().pushAttempt.count()).toBe(0);
});
it("refuses an expired Guardian after identity preflight but before the final egress claim", async () => {
const recordUser = await createUser("expiry-record", true);
const recipientUser = await createUser("expiry-recipient");
const grant = await createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
expiresAt: new Date(Date.now() + 60_000),
});
const delivery = await resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
title: "record reminder",
message: "record message",
});
expect(delivery).toMatchObject({ managed: true });
await getPrismaClient().accountGrant.update({
where: { id: grant.id },
data: { expiresAt: new Date(Date.now() - 1) },
});
await expect(
claimManagedGuardianEgressAuthorization(
delivery!,
"NTFY",
"MEDICATION_REMINDER",
),
).resolves.toBeNull();
expect(
await getPrismaClient().notificationEgressAuthorization.count(),
).toBe(0);
expect(await getPrismaClient().pushAttempt.count()).toBe(0);
});
it("uses database time for expiry at the final egress claim", async () => {
const recordUser = await createUser("database-clock-record", true);
const recipientUser = await createUser("database-clock-recipient");
const actualNow = Date.now();
await createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
expiresAt: new Date(actualNow - 1_000),
});
vi.useFakeTimers();
vi.setSystemTime(new Date(actualNow - 60_000));
try {
const delivery = await resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
title: "record reminder",
message: "record message",
});
expect(delivery).toMatchObject({ managed: true });
await expect(
claimManagedGuardianEgressAuthorization(
delivery!,
"NTFY",
"MEDICATION_REMINDER",
),
).resolves.toBeNull();
} finally {
vi.useRealTimers();
}
expect(
await getPrismaClient().notificationEgressAuthorization.count(),
).toBe(0);
expect(await getPrismaClient().pushAttempt.count()).toBe(0);
});
it("allows exactly one concurrent claim for a notification event", async () => {
const recordUser = await createUser("claim-record", true);
const client = getPrismaClient();
const since = new Date(Date.now() - 60_000);
const input = {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:race",
since,
};
const results = await Promise.all([
claimNotificationEvent(client, input),
claimNotificationEvent(client, input),
]);
expect(results.filter(Boolean)).toHaveLength(1);
expect(
await client.notificationEvent.count({
where: {
recordUserId: recordUser.id,
eventType: "MEDICATION_REMINDER",
dedupKey: "med:race",
},
}),
).toBe(1);
});
it("uses the rolling window and allows a retry after a failed claim", async () => {
const client = getPrismaClient();
const missingRecordId = `missing-claim-${sequence++}`;
const missingInput = {
recordUserId: missingRecordId,
eventType: "SYSTEM_ALERT",
dedupKey: "safety:retry",
since: new Date(Date.now() - 24 * 60 * 60 * 1000),
};
await expect(claimNotificationEvent(client, missingInput)).resolves.toBe(
false,
);
await client.user.create({
data: {
id: missingRecordId,
username: `claim-retry-${sequence++}`,
email: `claim-retry-${sequence++}@example.test`,
},
});
await expect(claimNotificationEvent(client, missingInput)).resolves.toBe(
true,
);
const recordUser = await createUser("expired-claim", true);
const now = new Date();
await client.notificationEvent.create({
data: {
recordUserId: recordUser.id,
eventType: "SYSTEM_ALERT",
dedupKey: "safety:window",
createdAt: new Date(now.getTime() - 25 * 60 * 60 * 1000),
},
});
await expect(
claimNotificationEvent(client, {
recordUserId: recordUser.id,
eventType: "SYSTEM_ALERT",
dedupKey: "safety:window",
since: new Date(now.getTime() - 24 * 60 * 60 * 1000),
}),
).resolves.toBe(true);
});
it("keeps Telegram reminder tracking independent for each recipient slot", async () => {
const recordUser = await createUser("telegram-record", true);
const firstRecipient = await createUser("telegram-recipient-a");
const secondRecipient = await createUser("telegram-recipient-b");
const client = getPrismaClient();
const medication = await client.medication.create({
data: {
userId: recordUser.id,
name: "Tracking medication",
dose: "1 tablet",
schedules: {
create: { windowStart: "08:00", windowEnd: "08:00" },
},
},
include: { schedules: true },
});
const scheduleId = medication.schedules[0]!.id;
const slot = {
medicationId: medication.id,
scheduleId,
date: "2026-08-06",
phase: "YELLOW" as const,
timeOfDay: "08:00",
};
await client.telegramReminderMessage.createMany({
data: [
{
recipientUserId: firstRecipient.id,
...slot,
chatId: "first-chat",
messageId: 101,
},
{
recipientUserId: secondRecipient.id,
...slot,
chatId: "second-chat",
messageId: 202,
},
],
});
await client.telegramReminderMessage.upsert({
where: {
recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: {
recipientUserId: firstRecipient.id,
...slot,
},
},
create: {
recipientUserId: firstRecipient.id,
...slot,
chatId: "unused-create-chat",
messageId: 303,
},
update: { chatId: "first-chat-new", messageId: 303 },
});
expect(
await client.telegramReminderMessage.findMany({
where: { medicationId: medication.id },
orderBy: { recipientUserId: "asc" },
select: { recipientUserId: true, chatId: true, messageId: true },
}),
).toEqual([
{
recipientUserId: firstRecipient.id,
chatId: "first-chat-new",
messageId: 303,
},
{
recipientUserId: secondRecipient.id,
chatId: "second-chat",
messageId: 202,
},
]);
await client.user.delete({ where: { id: firstRecipient.id } });
expect(
await client.telegramReminderMessage.count({
where: { medicationId: medication.id },
}),
).toBe(1);
});
it("persistence maps an omitted pair from the legacy recipient", async () => {
const self = await createUser("legacy");
const attempt = await getPrismaClient().pushAttempt.create({
data: {
userId: self.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
select: {
recordUserId: true,
recipientUserId: true,
},
});
expect(attempt).toEqual({
recordUserId: self.id,
recipientUserId: self.id,
});
});
it("persistence rejects a partial or contradictory principal pair atomically", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
const otherUser = await createUser("other");
await expect(
getPrismaClient().pushAttempt.create({
data: {
userId: recipientUser.id,
recordUserId: recordUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
}),
).rejects.toThrow("push attempt attribution requires both principals");
await expect(
getPrismaClient().pushAttempt.create({
data: {
userId: otherUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
}),
).rejects.toThrow("push attempt recipient must match user_id");
expect(await getPrismaClient().pushAttempt.count()).toBe(0);
});
it("persistence refuses a managed recipient and cascades an attributed recipient deletion", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
const managedRecipient = await createUser("managed-recipient", true);
await expect(
getPrismaClient().pushAttempt.create({
data: {
userId: managedRecipient.id,
recordUserId: recordUser.id,
recipientUserId: managedRecipient.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
}),
).rejects.toThrow("managed profile cannot receive notification delivery");
const attempt = await getPrismaClient().pushAttempt.create({
data: {
userId: recipientUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
channel: "NTFY",
eventType: "MEDICATION_REMINDER",
result: "ok",
},
});
await getPrismaClient().user.delete({ where: { id: recipientUser.id } });
expect(
await getPrismaClient().pushAttempt.findUnique({
where: { id: attempt.id },
}),
).toBeNull();
});
it("principal resolution requires an active MANAGE grant for managed delivery", async () => {
const recordUser = await createUser("record", true);
const recipientUser = await createUser("recipient");
const ordinaryRecord = await createUser("ordinary");
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
title: "Record content",
message: "Record schedule",
}),
).resolves.toBeNull();
await createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
});
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
title: "Record content",
message: "Record schedule",
}),
).resolves.toEqual({
recordUserId: recordUser.id,
recipientUserId: recipientUser.id,
managed: true,
});
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
title: "Record content",
message: "Record schedule",
}),
).resolves.toBeNull();
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: ordinaryRecord.id,
recordUserId: ordinaryRecord.id,
recipientUserId: recipientUser.id,
title: "Record content",
message: "Record schedule",
}),
).resolves.toBeNull();
});
it("principal resolution rejects inactive, insufficient, wrong-record, and managed-recipient grants", async () => {
const recordUser = await createUser("record", true);
const otherRecord = await createUser("other-record", true);
const pendingRecipient = await createUser("pending-recipient");
const revokedRecipient = await createUser("revoked-recipient");
const expiredRecipient = await createUser("expired-recipient");
const writeRecipient = await createUser("write-recipient");
const wrongRecordRecipient = await createUser("wrong-record-recipient");
const managedRecipient = await createUser("managed-recipient", true);
await Promise.all([
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: pendingRecipient.id,
acceptedAt: null,
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: revokedRecipient.id,
revokedAt: new Date(),
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: expiredRecipient.id,
expiresAt: new Date(Date.now() - 1_000),
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: writeRecipient.id,
access: "WRITE",
}),
createGuardianGrant({
recordUserId: otherRecord.id,
recipientUserId: wrongRecordRecipient.id,
}),
createGuardianGrant({
recordUserId: recordUser.id,
recipientUserId: managedRecipient.id,
}),
]);
for (const recipientUserId of [
pendingRecipient.id,
revokedRecipient.id,
expiredRecipient.id,
writeRecipient.id,
wrongRecordRecipient.id,
managedRecipient.id,
]) {
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: recordUser.id,
recordUserId: recordUser.id,
recipientUserId,
title: "Record content",
message: "Record schedule",
}),
).resolves.toBeNull();
}
const ordinaryUser = await createUser("ordinary-self");
await expect(
resolveNotificationDeliveryIdentity({
eventType: "MEDICATION_REMINDER",
userId: ordinaryUser.id,
title: "Personal content",
message: "Personal schedule",
}),
).resolves.toEqual({
recordUserId: ordinaryUser.id,
recipientUserId: ordinaryUser.id,
managed: false,
});
});
});