mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(ingest): answer a scoped token the same for unknown and out-of-scope names
A token scoped to one medication got 404 for a name that matched none of the owner's medications and 403 for one that matched outside its scope, so it could confirm which medications exist by name. Its idempotency replay also ran before the scope check and returned any of the owner's doses stored under the key. A token without the wildcard now gets the same 403 for both, the replay is looked up only after the scope check, and a key already spent on another medication answers 409 without that dose. The wildcard token keeps its 404.
This commit is contained in:
1 parent
989381b3f6
commit
e3598985f5
4 files changed
+92
-30
No files matched your search
@@ -348,6 +348,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
// The replay probe finds the original event.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "evt-original",
|
||||
medicationId: "med-1",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
@@ -359,6 +360,53 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
/**
|
||||
* A token handed to an automation for one medication used to tell a name
|
||||
* it may not touch (403) from one that does not exist (404), and to replay
|
||||
* any of the owner's doses by key before checking its scope.
|
||||
*/
|
||||
function narrowToken(scopes: string[]) {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "tok-2",
|
||||
userId: "user-1",
|
||||
permissions: ["medication:ingest", ...scopes],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
} as Awaited<ReturnType<typeof prisma.apiToken.findUnique>>);
|
||||
}
|
||||
|
||||
it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => {
|
||||
wireHappyPath();
|
||||
narrowToken(["medication:med-other:ingest"]);
|
||||
const known = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-known-1" }),
|
||||
);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null);
|
||||
const unknown = await POST(
|
||||
postReq({ medicationName: "Nonexistent", idempotencyKey: "k-unknown-1" }),
|
||||
);
|
||||
expect(known.status).toBe(403);
|
||||
expect(unknown.status).toBe(403);
|
||||
expect((await unknown.json()).error).toBe((await known.json()).error);
|
||||
// No replay was looked up for a medication outside the scope.
|
||||
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never replays another medication's dose to a scoped token", async () => {
|
||||
wireHappyPath();
|
||||
narrowToken(["medication:med-1:ingest"]);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "evt-other",
|
||||
medicationId: "med-other",
|
||||
takenAt: new Date(),
|
||||
} as Awaited<ReturnType<typeof prisma.medicationIntakeEvent.findFirst>>);
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-spent-1" }),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(JSON.stringify(await res.json())).not.toContain("evt-other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/ingest/medication — idempotency-key stability floor", () => {
|
||||
|
||||
@@ -186,17 +186,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { medicationName, takenAt, idempotencyKey } = parsed.data;
|
||||
|
||||
// Idempotency check (scoped to token owner to prevent cross-user lookups)
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
idempotencyKey,
|
||||
userId: apiToken.userId,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return apiSuccess(existing);
|
||||
}
|
||||
|
||||
// Find medication by name for this user
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: {
|
||||
@@ -206,16 +195,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
// A scoped token gets one answer for unknown and out-of-scope names; only a
|
||||
// full-access token, which can list them anyway, learns a name is unknown.
|
||||
const fullAccess = apiToken.permissions.includes("*");
|
||||
if (
|
||||
!medication ||
|
||||
(!fullAccess &&
|
||||
!apiToken.permissions.includes(`medication:${medication.id}:ingest`))
|
||||
) {
|
||||
return !medication && fullAccess
|
||||
? apiError("Medication not found", 404)
|
||||
: apiError("API endpoint for this medication is disabled", 403);
|
||||
}
|
||||
|
||||
const medicationScope = `medication:${medication.id}:ingest`;
|
||||
if (
|
||||
!apiToken.permissions.includes("*") &&
|
||||
!apiToken.permissions.includes(medicationScope)
|
||||
) {
|
||||
return apiError("API endpoint for this medication is disabled", 403);
|
||||
// Replay after the scope check, for this medication only (key is per user).
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: { idempotencyKey, userId: apiToken.userId },
|
||||
});
|
||||
if (existing) {
|
||||
return existing.medicationId === medication.id
|
||||
? apiSuccess(existing)
|
||||
: apiError("Idempotency key already used for another medication", 409);
|
||||
}
|
||||
|
||||
// A record-only medication takes no new dose — the same refusal every
|
||||
|
||||
Reference in new issue
Block a user