mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
* docs(ui): add UI guidelines as single source of truth + shadcn MCP server - `docs/ui-guidelines.md` lays out Dracula tokens, spacing, typography, component picks (button/input/dialog/sheet decision tree), layout rules (settings split target, dashboard one-row), accessibility baseline (WCAG 2.1 AA + axe-core in CI), the autofill / honeypot pattern for measurement forms, and a reusable "Test button" UX for every integration. New components in v1.4 reference this doc. - `.mcp.json` wires the official `shadcn` MCP server so the design system can be browsed and components installed without leaving the editor. - Adds `@vitest/coverage-v8` to dev dependencies — `pnpm test --coverage` now reports real per-file coverage (the v1.4 cycle uses this to lift critical-path coverage). Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(settings/ai): connection-test honours the unsaved provider selection Changing the AI provider in /settings, then clicking "Verbindung testen" without saving first, used to silently run the test against the *saved* provider instead — so the result felt unrelated to what the user had on screen. This often surfaced as a confusing OK / failure depending on whichever provider had previously been persisted. The test endpoint now accepts an optional JSON body that mirrors the form fields (provider, model, baseUrl, anthropicKey, localKey). When present, the server resolves a transient provider from those values — falling back to the persisted, encrypted credentials per field, so a user with a stored Anthropic key can swap models in the dropdown without re-typing the key. Plaintext keys never persist; the existing SSRF guard for the Local provider's base URL is reused, the existing rate limit (5/min) and the V3 error-leak shielding stay in place. Empty body keeps the previous behaviour for any other caller. Tests cover the new override path (provider forwarded, unknown provider rejected with 422, empty body uses persisted config, config errors surface the resolver's status). Whitespace in `src/app/settings/page.tsx` is reformatted by Prettier as a side-effect; logic touches only the AI section. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * feat(ui): add Skeleton and EmptyState primitives Two pieces every page in v1.4 needs and the codebase didn't have: - `<Skeleton>` — a presentation `<div>` with the standard pulse animation, `aria-hidden`, and `motion-reduce:animate-none`. Used to shape the page while data loads instead of dropping a spinner. The visible layout no longer jumps when the first measurement arrives. - `<EmptyState>` — the shape every list, tile, and chart panel reaches for when it has no data: optional icon, title, description, primary action; `card` (default) and `plain` variants; `default`/`compact` density. Lives inside an `aria-live="polite"` region so screen readers announce the message and its action. Tests cover screen-reader semantics, prettier-merged class names, density/variant switches. These two land first because the v1.4 pages — the split settings sections, the new dashboard hero, the redesigned admin status grid — all assume the primitives exist. The patterns are documented in `docs/ui-guidelines.md` §4.1–4.2. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(ui): block password-manager autofill on health-data inputs by default Browsers and password managers were happily autofilling the user's account credentials into measurement, medication, mood, and AI-token fields whenever those forms didn't explicitly set `autoComplete`. The codebase had zero `autoComplete` attributes across the data-entry surfaces, so this happened often enough to be both a UX papercut (account password landing in a "Notiz" field) and a real privacy issue (secrets persisting through CSV/JSON export). The base `<Input>` primitive now: - Defaults `autoComplete` to `"off"` when the caller doesn't supply a value. - Adds `data-lpignore="true"` and `data-1p-ignore="true"` whenever autoComplete resolves to `"off"`, so LastPass and 1Password skip the field too. - Keeps the password manager active for any form that already passes a semantic `autoComplete` (`"username"`, `"email"`, `"current-password"`, `"new-password"`) — the existing login, register, and AI-key inputs continue to work unchanged. Tests cover the three meaningful states (default off, explicit semantic value, login-flow `username`). Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
200 lines
6.3 KiB
TypeScript
200 lines
6.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Mocks must be hoisted before importing the route.
|
|
vi.mock("@/lib/api-handler", () => ({
|
|
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
|
requireAuth: vi.fn(async () => ({
|
|
user: { id: "u-1" },
|
|
session: { id: "s-1" },
|
|
})),
|
|
}));
|
|
|
|
vi.mock("@/lib/ai/provider", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@/lib/ai/provider")>(
|
|
"@/lib/ai/provider",
|
|
);
|
|
return {
|
|
...actual,
|
|
resolveProviderForTest: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/rate-limit", () => ({
|
|
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
annotate: vi.fn(),
|
|
}));
|
|
|
|
import { POST } from "../route";
|
|
import { resolveProviderForTest } from "@/lib/ai/provider";
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(resolveProviderForTest).mockReset();
|
|
});
|
|
|
|
interface ApiErrorEnvelope {
|
|
data: null;
|
|
error: string;
|
|
}
|
|
|
|
interface ApiSuccessEnvelope<T> {
|
|
data: T;
|
|
error: null;
|
|
}
|
|
|
|
function emptyRequest(): Request {
|
|
return new Request("http://localhost/api/ai/test", {
|
|
method: "POST",
|
|
headers: { "content-length": "0" },
|
|
});
|
|
}
|
|
|
|
function jsonRequest(body: unknown): Request {
|
|
const text = JSON.stringify(body);
|
|
return new Request("http://localhost/api/ai/test", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"content-length": String(text.length),
|
|
},
|
|
body: text,
|
|
});
|
|
}
|
|
|
|
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
|
|
// directly to the client, leaking provider URLs / partial keys / internal
|
|
// headers. Server now logs full details via annotate() and responds with
|
|
// a categorised, generic message.
|
|
describe("POST /api/ai/test — provider error leak guard", () => {
|
|
function makeProviderThatThrows(
|
|
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
|
) {
|
|
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
|
type: "openai",
|
|
generateCompletion: vi.fn(async () => {
|
|
throw err;
|
|
}),
|
|
} as never);
|
|
}
|
|
|
|
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
|
|
const err = Object.assign(
|
|
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
|
|
{
|
|
httpStatus: 401 as const,
|
|
bodyExcerpt: '{"error":"invalid api key sk-secret"}',
|
|
},
|
|
);
|
|
makeProviderThatThrows(err);
|
|
|
|
const response = await POST(emptyRequest() as never);
|
|
const body = (await response.json()) as ApiErrorEnvelope;
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(body.error ?? "").not.toMatch(/sk-/);
|
|
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
|
|
expect(body.error ?? "").not.toMatch(/invalid api key/i);
|
|
expect(body.error).toBe("Provider rejected the credentials");
|
|
});
|
|
|
|
it("returns the 429-categorised message when the provider rate-limits", async () => {
|
|
makeProviderThatThrows(
|
|
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
|
|
);
|
|
const response = await POST(emptyRequest() as never);
|
|
expect(response.status).toBe(502);
|
|
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
|
"Provider rate-limited the request",
|
|
);
|
|
});
|
|
|
|
it("returns the 5xx-categorised message when the provider has a server error", async () => {
|
|
makeProviderThatThrows(
|
|
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
|
|
);
|
|
const response = await POST(emptyRequest() as never);
|
|
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
|
"Provider returned a server error",
|
|
);
|
|
});
|
|
|
|
it("returns the unknown-error fallback otherwise", async () => {
|
|
makeProviderThatThrows(new Error("ECONNRESET"));
|
|
const response = await POST(emptyRequest() as never);
|
|
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
|
"Provider connection failed",
|
|
);
|
|
});
|
|
});
|
|
|
|
// v1.4 fix: the dropdown in /settings was not honoured — the test always
|
|
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
|
|
// The route now accepts a JSON override body and forwards the user's
|
|
// unsaved selection to resolveProviderForTest().
|
|
describe("POST /api/ai/test — dropdown-aware override", () => {
|
|
function makeProviderThatSucceeds(model: string) {
|
|
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
|
type: "anthropic",
|
|
generateCompletion: vi.fn(async () => ({
|
|
content: '{"ok":true}',
|
|
providerType: "anthropic",
|
|
model,
|
|
tokensUsed: 5,
|
|
})),
|
|
} as never);
|
|
}
|
|
|
|
it("forwards a dropdown override to resolveProviderForTest", async () => {
|
|
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
|
const response = await POST(
|
|
jsonRequest({
|
|
provider: "ANTHROPIC",
|
|
model: "claude-3-5-sonnet-latest",
|
|
}) as never,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
|
|
provider: "ANTHROPIC",
|
|
model: "claude-3-5-sonnet-latest",
|
|
});
|
|
const body = (await response.json()) as ApiSuccessEnvelope<{
|
|
providerType: string;
|
|
model: string;
|
|
}>;
|
|
expect(body.data.providerType).toBe("anthropic");
|
|
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
|
|
});
|
|
|
|
it("rejects unknown provider values with 422", async () => {
|
|
const response = await POST(
|
|
jsonRequest({ provider: "MALICIOUS" }) as never,
|
|
);
|
|
expect(response.status).toBe(422);
|
|
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the persisted config when the body is empty", async () => {
|
|
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
|
const response = await POST(emptyRequest() as never);
|
|
expect(response.status).toBe(200);
|
|
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {});
|
|
});
|
|
|
|
it("surfaces config errors as the same status as the resolver throws", async () => {
|
|
const { AITestConfigError } = await import("@/lib/ai/provider");
|
|
vi.mocked(resolveProviderForTest).mockRejectedValueOnce(
|
|
new AITestConfigError(422, "Anthropic API key not configured"),
|
|
);
|
|
const response = await POST(
|
|
jsonRequest({ provider: "ANTHROPIC" }) as never,
|
|
);
|
|
expect(response.status).toBe(422);
|
|
const body = (await response.json()) as ApiErrorEnvelope;
|
|
expect(body.error).toBe("Anthropic API key not configured");
|
|
});
|
|
});
|