mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
- Keep a stale application clock from authorizing an expired Guardian\n
654 lines
20 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|