mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(tokens): retire the generic API-token mint
POST /api/tokens minted ["medication:ingest"], and that token could never do the job the settings copy advertised: /api/ingest/medication gates on the per-medication medication:<id>:ingest grant as well, which this endpoint never issued. It was refused at the ingest surface and admitted everywhere else. With Bearer scope now fail-closed the token is inert rather than dangerous, which is a worse thing to hand a user than the bug it replaces. Remove the mint. The working credential comes from the per-medication API-endpoint toggle, which issues both grants scoped to one medication. List and revoke stay so existing tokens remain visible and revocable, and the settings card now points at the per-medication toggle instead of offering a name field. Six locales updated.
This commit is contained in:
1 parent
df611227f9
commit
8012aa095b
10 files changed
+232
-314
No files matched your search
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/tokens.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/tokens — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Empty name (min 1) + `expiresInDays` above max (365).
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Missing name + `expiresInDays` not a number + `expiresInDays` below min
|
||||
// is hard since name and expiresInDays are the only knobs, so we use a
|
||||
// name that is too long (max 100) + expiresInDays as a string and as
|
||||
// a fraction (non-int).
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "x".repeat(200),
|
||||
expiresInDays: "string",
|
||||
// any extra unknown key Zod will silently strip; force a 3rd issue
|
||||
// by including a stray nested shape that the schema doesn't accept.
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
// name too long + expiresInDays not a number = 2 issues at least.
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("writes a tokens.create.validation-failed audit row", async () => {
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("tokens.create.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
+15
-80
@@ -1,23 +1,22 @@
|
||||
/**
|
||||
* `/api/tokens` — list and (via `[id]`) revoke a user's API tokens.
|
||||
*
|
||||
* The generic mint that used to live here (`POST`, minting
|
||||
* `["medication:ingest"]`) was removed alongside the fail-closed scope
|
||||
* default. That token could never do its advertised job — the external ingest
|
||||
* surface gates on the per-medication `medication:<id>:ingest` grant, which
|
||||
* this endpoint never issued — while the old fail-open default let it reach
|
||||
* every other authenticated route. The working credential is minted by the
|
||||
* per-medication API-endpoint toggle
|
||||
* (`/api/medications/[id]/api-endpoint`), which issues both grants.
|
||||
*
|
||||
* Listing and revoking stay so existing tokens remain visible and revocable.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -43,67 +42,3 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess(tokens);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "tokens.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422 + audit-ledger breadcrumb so an
|
||||
// operator can grep `tokens.create.validation-failed` when a
|
||||
// device sends a malformed mint payload.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "tokens.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// mint payload carries a free-text `name`.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "tokens.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { name, expiresInDays } = parsed.data;
|
||||
|
||||
// Generate a random token
|
||||
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
|
||||
const tokenHashValue = hashToken(rawToken);
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name,
|
||||
tokenHash: tokenHashValue,
|
||||
permissions: ["medication:ingest"],
|
||||
expiresAt: expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 86400000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Return the raw token ONCE — it can never be retrieved again
|
||||
return apiSuccess({ token: rawToken, name }, 201);
|
||||
});
|
||||
Reference in new issue
Block a user