mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix: enforce notification delivery authority
- Require active Guardian authority before cross-record delivery\n- Serialize managed delivery checks with lifecycle changes
This commit is contained in:
1 parent
b78d64cb98
commit
1ba73a4d51
3 files changed
+58
-9
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transactionMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
notificationChannel: {
|
||||
@@ -9,6 +11,7 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: (...args: unknown[]) => transactionMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -76,6 +79,20 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.notificationChannel.update).mockResolvedValue({
|
||||
consecutiveFailures: 0,
|
||||
} as never);
|
||||
transactionMock.mockImplementation(
|
||||
async (operation: (tx: unknown) => Promise<unknown>) =>
|
||||
operation({
|
||||
$queryRaw: vi.fn(),
|
||||
user: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
|
||||
where.id === recordUserId
|
||||
? { managedProfileAt: new Date() }
|
||||
: { managedProfileAt: null },
|
||||
),
|
||||
},
|
||||
accountGrant: { findFirst: vi.fn(async () => ({ id: "grant" })) },
|
||||
}),
|
||||
);
|
||||
sendViaWebPushMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
@@ -110,6 +127,7 @@ describe("notification delivery identity", () => {
|
||||
message: "Record schedule",
|
||||
}),
|
||||
);
|
||||
expect(transactionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses a managed record without an explicit recipient", async () => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
activeGuardianWhere,
|
||||
withManagedProfileLock,
|
||||
} from "@/lib/managed-profiles/lifecycle";
|
||||
import type { NotificationPayload } from "@/lib/notifications/types";
|
||||
|
||||
export interface NotificationDeliveryIdentity {
|
||||
@@ -10,7 +14,13 @@ export interface NotificationDeliveryIdentity {
|
||||
/**
|
||||
* Resolve notification identities without treating a managed record as its
|
||||
* own destination. Legacy payloads remain valid only for ordinary self
|
||||
* delivery; a managed record must name its recipient explicitly.
|
||||
* delivery; a managed record must name an active Guardian explicitly.
|
||||
*
|
||||
* Cross-principal authorization linearizes at the locked grant read below,
|
||||
* immediately before the dispatcher selects a channel. A revocation that
|
||||
* commits before that read prevents delivery; one that commits afterwards
|
||||
* affects later dispatches but cannot recall provider work already in flight.
|
||||
* The provider call deliberately runs outside the short database transaction.
|
||||
*/
|
||||
export async function resolveNotificationDeliveryIdentity(
|
||||
payload: NotificationPayload,
|
||||
@@ -32,14 +42,35 @@ export async function resolveNotificationDeliveryIdentity(
|
||||
});
|
||||
if (!record) return null;
|
||||
|
||||
const managed = record.managedProfileAt != null;
|
||||
if (managed) {
|
||||
if (record.managedProfileAt == null) {
|
||||
return recipientUserId === recordUserId
|
||||
? null
|
||||
: { recordUserId, recipientUserId, managed };
|
||||
? { recordUserId, recipientUserId, managed: false }
|
||||
: null;
|
||||
}
|
||||
|
||||
return recipientUserId === recordUserId
|
||||
? { recordUserId, recipientUserId, managed }
|
||||
: null;
|
||||
if (recipientUserId === recordUserId) return null;
|
||||
|
||||
return prisma.$transaction(async (tx) =>
|
||||
withManagedProfileLock(tx, recordUserId, async (lockedRecord) => {
|
||||
if (lockedRecord?.managedProfileAt == null) return null;
|
||||
|
||||
const recipient = await tx.user.findUnique({
|
||||
where: { id: recipientUserId },
|
||||
select: { managedProfileAt: true },
|
||||
});
|
||||
if (!recipient || recipient.managedProfileAt != null) return null;
|
||||
|
||||
const guardian = await tx.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: recordUserId,
|
||||
granteeId: recipientUserId,
|
||||
...activeGuardianWhere(new Date()),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guardian) return null;
|
||||
|
||||
return { recordUserId, recipientUserId, managed: true };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ async function createGuardianGrant(input: {
|
||||
grantorId: input.recordUserId,
|
||||
granteeId: input.recipientUserId,
|
||||
access: input.access ?? "MANAGE",
|
||||
acceptedAt: input.acceptedAt ?? new Date(),
|
||||
acceptedAt: input.acceptedAt === undefined ? new Date() : input.acceptedAt,
|
||||
revokedAt: input.revokedAt ?? null,
|
||||
revokedBy: input.revokedAt ? "GRANTOR" : null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
|
||||
Reference in new issue
Block a user