mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
This commit is contained in:
1 parent
a4b3d2fcd0
commit
40f7196c20
115 files changed
+3014
-1084
No files matched your search
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* v1.16.6 — admin CRUD contract for the operator-wide AI key
|
||||
* (`/api/admin/ai-settings`). The endpoint predates its UI; the new
|
||||
* admin "AI server key" card now drives it, so the contract gets
|
||||
* pinned: admin-only access, encrypted storage, key removal, the
|
||||
* HTTPS + hostname allowlist on the base URL, and the masked
|
||||
* `keyPreview` in every response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/ai-settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/ai-settings", () => {
|
||||
it("propagates the admin gate (non-admin cannot read)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(GET()).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it("returns defaults + hasKey=false when nothing is configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
hasKey: false,
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns hasKey=true with a masked preview when a key is stored", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc(sk-test-12cd)",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.hasKey).toBe(true);
|
||||
expect(body.data.keyPreview).toBe("...12cd");
|
||||
expect(body.data.model).toBe("gpt-4o-mini");
|
||||
// The plaintext key never leaks into the envelope.
|
||||
expect(JSON.stringify(body)).not.toContain("sk-test-12cd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/ai-settings", () => {
|
||||
function upsertEcho() {
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation(
|
||||
(async (args: { update: Record<string, unknown> }) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("propagates the admin gate (non-admin cannot write)", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("encrypts and stores a new key, audit-logged", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(jsonReq({ apiKey: "sk-new-key-89ab" }));
|
||||
const body = await res.json();
|
||||
|
||||
expect(encrypt).toHaveBeenCalledWith("sk-new-key-89ab");
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: { adminAiKeyEncrypted: "enc(sk-new-key-89ab)" },
|
||||
}),
|
||||
);
|
||||
expect(body.data.hasKey).toBe(true);
|
||||
expect(body.data.keyPreview).toBe("...89ab");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.ai-settings.update",
|
||||
expect.objectContaining({ userId: "admin-1" }),
|
||||
);
|
||||
// The audit detail records THAT the key changed, never the key.
|
||||
const details = vi.mocked(auditLog).mock.calls[0][1] as {
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
expect(JSON.stringify(details)).not.toContain("sk-new-key-89ab");
|
||||
});
|
||||
|
||||
it("clears the key when apiKey is empty", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(jsonReq({ apiKey: "" }));
|
||||
const body = await res.json();
|
||||
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ update: { adminAiKeyEncrypted: null } }),
|
||||
);
|
||||
expect(body.data.hasKey).toBe(false);
|
||||
expect(body.data.keyPreview).toBeNull();
|
||||
});
|
||||
|
||||
it("updates model and baseUrl for an allowlisted https host", async () => {
|
||||
upsertEcho();
|
||||
const res = await PUT(
|
||||
jsonReq({ model: "gpt-4o-mini", baseUrl: "https://openrouter.ai/v1" }),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.model).toBe("gpt-4o-mini");
|
||||
expect(body.data.baseUrl).toBe("https://openrouter.ai/v1");
|
||||
});
|
||||
|
||||
it("rejects a plain-http base URL", async () => {
|
||||
await expect(
|
||||
PUT(jsonReq({ baseUrl: "http://api.openai.com/v1" })),
|
||||
).rejects.toMatchObject({ statusCode: 422 });
|
||||
});
|
||||
|
||||
it("rejects a base URL host outside the allowlist", async () => {
|
||||
await expect(
|
||||
PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })),
|
||||
).rejects.toMatchObject({ statusCode: 422 });
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty model", async () => {
|
||||
await expect(PUT(jsonReq({ model: " " }))).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a body with no recognised fields", async () => {
|
||||
await expect(PUT(jsonReq({ unrelated: true }))).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 429 when the admin surface is rate-limited", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user