mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(02-11): release guardian lock before provider egress
- Commit a final authorization claim under the lifecycle lock\n- Keep provider calls and retry outcomes outside database transactions\n- Retain authorization claims with notification delivery diagnostics
This commit is contained in:
1 parent
1818bd874b
commit
4b79f83cf7
9 files changed
+560
-110
No files matched your search
@@ -17,6 +17,8 @@ const sent = vi.hoisted(() => ({
|
||||
const ntfyEgress = vi.hoisted(() => ({
|
||||
block: null as Promise<void> | null,
|
||||
onStart: null as (() => void) | null,
|
||||
error: null as Error | null,
|
||||
outcome: null as { ok: false; reason: string } | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/senders/ntfy", async () => {
|
||||
@@ -37,6 +39,8 @@ vi.mock("@/lib/notifications/senders/ntfy", async () => {
|
||||
) => {
|
||||
ntfyEgress.onStart?.();
|
||||
if (ntfyEgress.block) await ntfyEgress.block;
|
||||
if (ntfyEgress.error) throw ntfyEgress.error;
|
||||
if (ntfyEgress.outcome) return ntfyEgress.outcome;
|
||||
const recipientUserId = payload.recipientUserId ?? payload.userId;
|
||||
sent.ntfy.push({
|
||||
recipientUserId,
|
||||
@@ -199,6 +203,8 @@ async function addChannel(
|
||||
beforeEach(async () => {
|
||||
ntfyEgress.block = null;
|
||||
ntfyEgress.onStart = null;
|
||||
ntfyEgress.error = null;
|
||||
ntfyEgress.outcome = null;
|
||||
sent.deliveries.length = 0;
|
||||
sent.ntfy.length = 0;
|
||||
sent.apns.length = 0;
|
||||
@@ -375,6 +381,22 @@ describe("Guardian fan-out (real Postgres)", () => {
|
||||
expect(attempts).toHaveLength(expected.length);
|
||||
expect(attempts).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
const claims =
|
||||
await getPrismaClient().notificationEgressAuthorization.findMany({
|
||||
where: { eventType: "MEDICATION_REMINDER" },
|
||||
select: {
|
||||
channel: true,
|
||||
recordUserId: true,
|
||||
recipientUserId: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
const expectedClaims = expected.map((claim) => ({
|
||||
...claim,
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
}));
|
||||
expect(claims).toHaveLength(expectedClaims.length);
|
||||
expect(claims).toEqual(expect.arrayContaining(expectedClaims));
|
||||
|
||||
await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
@@ -391,7 +413,7 @@ describe("Guardian fan-out (real Postgres)", () => {
|
||||
).resolves.toBe(expected.length);
|
||||
});
|
||||
|
||||
it("keeps a Guardian revocation from committing while its channel egress is in flight", async () => {
|
||||
it("allows Guardian revocation to commit while a claimed channel egress is in flight", async () => {
|
||||
const [record, recipient, reserveGuardian] = await Promise.all([
|
||||
createUser({ label: "record", locale: "en", managed: true }),
|
||||
createUser({ label: "recipient", locale: "en" }),
|
||||
@@ -424,19 +446,27 @@ describe("Guardian fan-out (real Postgres)", () => {
|
||||
message: "record message",
|
||||
});
|
||||
await egressStarted;
|
||||
await expect(
|
||||
getPrismaClient().notificationEgressAuthorization.count({
|
||||
where: {
|
||||
recordUserId: record.id,
|
||||
recipientUserId: recipient.id,
|
||||
channel: "NTFY",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
const revocation = revokeGrant({
|
||||
grantId: recipientGrant.id,
|
||||
grantorId: record.id,
|
||||
});
|
||||
await expectPromiseToRemainPending(revocation);
|
||||
await expectPromiseToSettle(revocation);
|
||||
|
||||
releaseEgress();
|
||||
await delivery;
|
||||
await revocation;
|
||||
});
|
||||
|
||||
it("keeps a Guardian expiry transition from committing while its channel egress is in flight", async () => {
|
||||
it("allows Guardian expiry to commit while a claimed channel egress is in flight", async () => {
|
||||
const [record, recipient, reserveGuardian] = await Promise.all([
|
||||
createUser({ label: "record", locale: "en", managed: true }),
|
||||
createUser({ label: "recipient", locale: "en" }),
|
||||
@@ -469,6 +499,15 @@ describe("Guardian fan-out (real Postgres)", () => {
|
||||
message: "record message",
|
||||
});
|
||||
await egressStarted;
|
||||
await expect(
|
||||
getPrismaClient().notificationEgressAuthorization.count({
|
||||
where: {
|
||||
recordUserId: record.id,
|
||||
recipientUserId: recipient.id,
|
||||
channel: "NTFY",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
const expiry = getPrismaClient().$transaction((tx) =>
|
||||
withManagedProfileLock(tx, record.id, () =>
|
||||
@@ -478,21 +517,131 @@ describe("Guardian fan-out (real Postgres)", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expectPromiseToRemainPending(expiry);
|
||||
await expectPromiseToSettle(expiry);
|
||||
|
||||
releaseEgress();
|
||||
await delivery;
|
||||
await expiry;
|
||||
});
|
||||
|
||||
it("records a timed-out provider after the authorization claim has committed", async () => {
|
||||
const [record, recipient, reserveGuardian] = await Promise.all([
|
||||
createUser({ label: "record", locale: "en", managed: true }),
|
||||
createUser({ label: "recipient", locale: "en" }),
|
||||
createUser({ label: "reserve-guardian", locale: "de" }),
|
||||
]);
|
||||
await Promise.all([
|
||||
addGuardian(record.id, recipient.id),
|
||||
addGuardian(record.id, reserveGuardian.id),
|
||||
addChannel(recipient.id, "NTFY"),
|
||||
]);
|
||||
ntfyEgress.error = new Error("provider timeout");
|
||||
|
||||
await expect(
|
||||
dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: record.id,
|
||||
title: "record reminder",
|
||||
message: "record message",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getPrismaClient().notificationEgressAuthorization.findFirstOrThrow({
|
||||
where: {
|
||||
recordUserId: record.id,
|
||||
recipientUserId: recipient.id,
|
||||
channel: "NTFY",
|
||||
},
|
||||
select: { eventType: true, outcome: true, completedAt: true },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
outcome: "sender_threw",
|
||||
completedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a new claim for each retry and stops after Guardian revocation", async () => {
|
||||
const [record, recipient, reserveGuardian] = await Promise.all([
|
||||
createUser({ label: "record", locale: "en", managed: true }),
|
||||
createUser({ label: "recipient", locale: "en" }),
|
||||
createUser({ label: "reserve-guardian", locale: "de" }),
|
||||
]);
|
||||
await Promise.all([
|
||||
addGuardian(record.id, recipient.id),
|
||||
addGuardian(record.id, reserveGuardian.id),
|
||||
addChannel(recipient.id, "NTFY"),
|
||||
]);
|
||||
const grant = await getPrismaClient().accountGrant.findFirstOrThrow({
|
||||
where: { grantorId: record.id, granteeId: recipient.id },
|
||||
select: { id: true },
|
||||
});
|
||||
const payload = {
|
||||
eventType: "MEDICATION_REMINDER" as const,
|
||||
userId: record.id,
|
||||
recordUserId: record.id,
|
||||
recipientUserId: recipient.id,
|
||||
title: "record reminder",
|
||||
message: "record message",
|
||||
};
|
||||
|
||||
ntfyEgress.error = new Error("provider timeout");
|
||||
await dispatchNotification(payload);
|
||||
await getPrismaClient().notificationChannel.update({
|
||||
where: { userId_type: { userId: recipient.id, type: "NTFY" } },
|
||||
data: { consecutiveFailures: 0, nextRetryAt: null },
|
||||
});
|
||||
ntfyEgress.error = null;
|
||||
ntfyEgress.outcome = { ok: false, reason: "provider_unavailable" };
|
||||
await dispatchNotification(payload);
|
||||
|
||||
const beforeRevocation =
|
||||
await getPrismaClient().notificationEgressAuthorization.findMany({
|
||||
where: {
|
||||
recordUserId: record.id,
|
||||
recipientUserId: recipient.id,
|
||||
channel: "NTFY",
|
||||
},
|
||||
select: { id: true, outcome: true },
|
||||
orderBy: { authorizedAt: "asc" },
|
||||
});
|
||||
expect(beforeRevocation).toEqual([
|
||||
{ id: expect.any(String), outcome: "sender_threw" },
|
||||
{ id: expect.any(String), outcome: "transient_failure" },
|
||||
]);
|
||||
expect(beforeRevocation[0]!.id).not.toBe(beforeRevocation[1]!.id);
|
||||
|
||||
await revokeGrant({ grantId: grant.id, grantorId: record.id });
|
||||
ntfyEgress.outcome = null;
|
||||
await expect(dispatchNotification(payload)).resolves.toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
expect(sent.ntfy).toEqual([]);
|
||||
await expect(
|
||||
getPrismaClient().notificationEgressAuthorization.count({
|
||||
where: {
|
||||
recordUserId: record.id,
|
||||
recipientUserId: recipient.id,
|
||||
channel: "NTFY",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
async function expectPromiseToRemainPending(promise: Promise<unknown>) {
|
||||
async function expectPromiseToSettle(promise: Promise<unknown>) {
|
||||
const settled = await Promise.race([
|
||||
promise.then(
|
||||
() => true,
|
||||
() => true,
|
||||
),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 25)),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 250)),
|
||||
]);
|
||||
expect(settled).toBe(false);
|
||||
expect(settled).toBe(true);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import {
|
||||
claimManagedGuardianEgressAuthorization,
|
||||
resolveNotificationDeliveryIdentity,
|
||||
withManagedGuardianEgressAuthorization,
|
||||
} from "@/lib/notifications/delivery-identity";
|
||||
import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
@@ -157,7 +157,42 @@ describe("notification attribution (real Postgres)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an expired Guardian after identity preflight but before provider egress", async () => {
|
||||
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({
|
||||
@@ -179,15 +214,18 @@ describe("notification attribution (real Postgres)", () => {
|
||||
where: { id: grant.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1) },
|
||||
});
|
||||
let sent = false;
|
||||
|
||||
await expect(
|
||||
withManagedGuardianEgressAuthorization(delivery!, async () => {
|
||||
sent = true;
|
||||
return { ok: true };
|
||||
}),
|
||||
claimManagedGuardianEgressAuthorization(
|
||||
delivery!,
|
||||
"NTFY",
|
||||
"MEDICATION_REMINDER",
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(sent).toBe(false);
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user