mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta.
This commit is contained in:
1 parent
de0689e419
commit
17b8d8d2c2
528 files changed
+104033
-6524
No files matched your search
@@ -107,4 +107,34 @@ describe("CoachFeedbackSection", () => {
|
||||
expect(html).toContain("Failed to load Coach feedback summary");
|
||||
expect(html).toContain('role="alert"');
|
||||
});
|
||||
|
||||
// v1.4.25 W8 regression — Marc reported a layout shift on
|
||||
// /admin/coach-feedback. Root cause was the title only rendering in the
|
||||
// empty + data branches, so the section grew taller (loading → data)
|
||||
// and snapped down. Lock the heading + outer-card structure across
|
||||
// every render state so the section maintains a constant top-of-card
|
||||
// baseline.
|
||||
it("keeps the section heading rendered across loading / error / empty states", () => {
|
||||
for (const queryState of [
|
||||
{ isLoading: true, isError: false, data: null },
|
||||
{ isLoading: false, isError: true, data: null },
|
||||
{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
generatedAt: "2026-05-10T04:00:00Z",
|
||||
windowDays: 30,
|
||||
coachBuckets: [],
|
||||
},
|
||||
},
|
||||
]) {
|
||||
useQueryMock.mockReturnValue(queryState);
|
||||
const html = render();
|
||||
// Sparkles icon + h2 title rendered in every state ⇒ constant
|
||||
// header height ⇒ no layout shift when the query resolves.
|
||||
expect(html).toContain("Coach Feedback");
|
||||
expect(html).toContain("bg-card");
|
||||
expect(html).toContain("rounded-xl");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
auditLogCsvHeaderLabels,
|
||||
buildAuditLogCsvRecords,
|
||||
type AuditCsvEntry,
|
||||
type AuditCsvLabels,
|
||||
} from "../_shared";
|
||||
import { toCSV } from "@/lib/export";
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — Login-Übersicht CSV export contract.
|
||||
*
|
||||
* The previous CSV emitted snake_case English headers and the raw UTC
|
||||
* `createdAt` string, which Excel/LibreOffice misread once the `Z`
|
||||
* suffix was stripped. This test pins:
|
||||
*
|
||||
* - The column order Marc requested:
|
||||
* `timestamp → user → IP → location → provider → outcome` (email
|
||||
* is absent from the audit-log API so it's intentionally skipped)
|
||||
* followed by `action` + `details` for triage completeness.
|
||||
* - The header row uses the translated labels from the active
|
||||
* locale, not the structural keys.
|
||||
* - The timestamp formatter is honoured (production passes
|
||||
* `formatInUserTz` with `iso-with-offset`).
|
||||
* - CSV special characters in `details` are RFC 4180-escaped.
|
||||
*/
|
||||
|
||||
const LABELS_EN: AuditCsvLabels = {
|
||||
timestamp: "Time",
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
details: "Details",
|
||||
outcomeFailed: "Failed",
|
||||
outcomeSuccess: "Success",
|
||||
unknownUser: "Unknown",
|
||||
providerLabels: {
|
||||
password: "Password",
|
||||
passkey: "Passkey",
|
||||
api_token: "API token",
|
||||
withings: "Withings",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
};
|
||||
|
||||
const LABELS_DE: AuditCsvLabels = {
|
||||
...LABELS_EN,
|
||||
timestamp: "Zeitpunkt",
|
||||
user: "Benutzer",
|
||||
ip: "IP",
|
||||
location: "Standort",
|
||||
provider: "Anbieter",
|
||||
outcome: "Ergebnis",
|
||||
action: "Aktion",
|
||||
details: "Details",
|
||||
outcomeFailed: "Fehlgeschlagen",
|
||||
outcomeSuccess: "Erfolgreich",
|
||||
unknownUser: "Unbekannt",
|
||||
providerLabels: {
|
||||
password: "Passwort",
|
||||
passkey: "Passkey",
|
||||
api_token: "API-Token",
|
||||
withings: "Withings",
|
||||
unknown: "Unbekannt",
|
||||
},
|
||||
};
|
||||
|
||||
function deterministicFormatter(iso: string): string {
|
||||
// Strip milliseconds, swap `Z` for `+00:00` so the test asserts the
|
||||
// ISO-with-offset shape without being coupled to a specific zone.
|
||||
return iso.replace(/\.\d{3}Z$/, "+00:00").replace(/Z$/, "+00:00");
|
||||
}
|
||||
|
||||
describe("buildAuditLogCsvRecords", () => {
|
||||
it("emits Marc's column order: timestamp → user → ip → location → provider → outcome (+ action, details)", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: "203.0.113.7",
|
||||
location: "Berlin, DE",
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
];
|
||||
const [record] = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
// Object.keys preserves insertion order in V8 / every modern JS engine
|
||||
// — the column order is therefore guaranteed by `Object.keys(record)`.
|
||||
expect(Object.keys(record)).toEqual([
|
||||
"timestamp",
|
||||
"user",
|
||||
"ip",
|
||||
"location",
|
||||
"provider",
|
||||
"outcome",
|
||||
"action",
|
||||
"details",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps the action to a coarse provider label using the i18n labels", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:06:00.000Z",
|
||||
action: "auth.bearer.success",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: { id: "u2", username: "ios" },
|
||||
},
|
||||
{
|
||||
createdAt: "2026-05-11T09:07:00.000Z",
|
||||
action: "auth.login.failed",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(records[0].provider).toBe("Passkey");
|
||||
expect(records[1].provider).toBe("API token");
|
||||
expect(records[2].provider).toBe("Password");
|
||||
// Failed login records the failed outcome.
|
||||
expect(records[0].outcome).toBe("Success");
|
||||
expect(records[2].outcome).toBe("Failed");
|
||||
// No user → unknown placeholder.
|
||||
expect(records[2].user).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("uses the German label set when the active locale is German", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const [record] = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_DE,
|
||||
deterministicFormatter,
|
||||
);
|
||||
expect(record.provider).toBe("Passkey");
|
||||
expect(record.outcome).toBe("Erfolgreich");
|
||||
expect(record.user).toBe("Unbekannt");
|
||||
});
|
||||
|
||||
it("applies the injected timestamp formatter (production uses formatInUserTz)", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.password",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
details: null,
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
(iso) => `formatted:${iso}`,
|
||||
);
|
||||
expect(records[0].timestamp).toBe("formatted:2026-05-11T09:05:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("auditLogCsvHeaderLabels", () => {
|
||||
it("returns translated headers keyed by the record-key contract", () => {
|
||||
const headers = auditLogCsvHeaderLabels(LABELS_EN);
|
||||
expect(headers).toEqual({
|
||||
timestamp: "Time",
|
||||
user: "Users",
|
||||
ip: "IP",
|
||||
location: "Location",
|
||||
provider: "Provider",
|
||||
outcome: "Outcome",
|
||||
action: "Action",
|
||||
details: "Details",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCSV(records, headerLabels) — audit-log integration", () => {
|
||||
it("writes a translated header row + correct column order", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.passkey",
|
||||
ipAddress: "203.0.113.7",
|
||||
location: "Berlin, DE",
|
||||
details: null,
|
||||
user: { id: "u1", username: "marc" },
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_DE,
|
||||
deterministicFormatter,
|
||||
);
|
||||
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE));
|
||||
const lines = csv.split("\n");
|
||||
expect(lines[0]).toBe(
|
||||
"Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details",
|
||||
);
|
||||
expect(lines[1]).toBe(
|
||||
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes commas, quotes, and newlines in the details column", () => {
|
||||
const entries: AuditCsvEntry[] = [
|
||||
{
|
||||
createdAt: "2026-05-11T09:05:00.000Z",
|
||||
action: "auth.login.failed",
|
||||
ipAddress: null,
|
||||
location: null,
|
||||
// Three RFC 4180 escape triggers in one value: a comma, a
|
||||
// double quote, and a newline.
|
||||
details: 'reason="invalid_password", attempts: 3\nfollowup: lock',
|
||||
user: null,
|
||||
},
|
||||
];
|
||||
const records = buildAuditLogCsvRecords(
|
||||
entries,
|
||||
LABELS_EN,
|
||||
deterministicFormatter,
|
||||
);
|
||||
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_EN));
|
||||
// The cell is wrapped in double quotes and embedded `"` is doubled.
|
||||
expect(csv).toContain(
|
||||
'"reason=""invalid_password"", attempts: 3\nfollowup: lock"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — `/admin/login-overview` no longer renders a Collapse
|
||||
* / Expand toggle. The toggle was a leftover from the v1.4 shared
|
||||
* admin page where 13 sections lived on one route; collapsing the
|
||||
* only card on the dedicated login-overview route hid the entire
|
||||
* surface. Visiting the page is itself the opt-in.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/login-overview",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
entries: [],
|
||||
meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<LoginOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginOverviewSection — no collapse button", () => {
|
||||
it("does not render a Collapse / Expand button (English)", () => {
|
||||
const html = render("en");
|
||||
expect(html).not.toMatch(/\bCollapse\b/);
|
||||
expect(html).not.toMatch(/\bExpand\b/);
|
||||
});
|
||||
|
||||
it("does not render an Einklappen / Ausklappen button (German)", () => {
|
||||
const html = render("de");
|
||||
expect(html).not.toMatch(/\bEinklappen\b/);
|
||||
expect(html).not.toMatch(/\bAusklappen\b/);
|
||||
});
|
||||
|
||||
it("never renders an `aria-expanded` button on the section header", () => {
|
||||
const html = render();
|
||||
// The toggle previously surfaced as <button aria-expanded={…}>
|
||||
// immediately after the ScrollText icon + title pair. Radix
|
||||
// Select triggers below carry `aria-expanded` legitimately, so
|
||||
// we scope the assertion to the first 800 chars of the markup
|
||||
// (the heading slice) instead of the entire SSR output.
|
||||
const heading = html.slice(0, 800);
|
||||
expect(heading).not.toMatch(/aria-expanded=/);
|
||||
});
|
||||
|
||||
it("paints the section body unconditionally (no `expanded` gate)", () => {
|
||||
// The filter pills used to live inside an `{expanded && (…)}`
|
||||
// wrapper. With the toggle gone the pills must always be in the
|
||||
// SSR output.
|
||||
const html = render("en");
|
||||
expect(html).toContain("All auth events");
|
||||
expect(html).toContain("Failed only");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { iconForAuthProvider, providerForAction } from "../_shared";
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — Provider column for the admin Login-Übersicht.
|
||||
*
|
||||
* The audit-log entries don't carry a dedicated "provider" field — the
|
||||
* action name itself is the source of truth. These tests pin the
|
||||
* coarse mapping that drives the Provider column (and any later
|
||||
* surface that reuses `useAuthProviderLabels`). New `auth.*` actions
|
||||
* that land in the audit log must be wired into `providerForAction`
|
||||
* with a corresponding regression case here.
|
||||
*/
|
||||
|
||||
describe("providerForAction", () => {
|
||||
it("classifies passkey actions as `passkey`", () => {
|
||||
expect(providerForAction("auth.login.passkey")).toBe("passkey");
|
||||
expect(providerForAction("auth.passkey.register")).toBe("passkey");
|
||||
expect(providerForAction("auth.passkey.delete")).toBe("passkey");
|
||||
});
|
||||
|
||||
it("classifies password actions and failed sign-ins as `password`", () => {
|
||||
expect(providerForAction("auth.login.password")).toBe("password");
|
||||
expect(providerForAction("auth.password.change")).toBe("password");
|
||||
// Failed logins flow through the password endpoint; treating them
|
||||
// as `password` is the truthful summary of how the credential was
|
||||
// offered. A failed passkey attempt has its own audit row.
|
||||
expect(providerForAction("auth.login.failed")).toBe("password");
|
||||
});
|
||||
|
||||
it("classifies bearer + native-token actions as `api_token`", () => {
|
||||
expect(providerForAction("auth.bearer.success")).toBe("api_token");
|
||||
expect(providerForAction("auth.bearer.failure")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.autoissue.native")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.refresh")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.refresh.failed")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.refresh.revoke")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.revoke")).toBe("api_token");
|
||||
});
|
||||
|
||||
it("classifies Withings OAuth events as `withings`", () => {
|
||||
expect(providerForAction("auth.withings.connect")).toBe("withings");
|
||||
expect(providerForAction("auth.withings.disconnect")).toBe("withings");
|
||||
});
|
||||
|
||||
it("falls back to `unknown` for unmapped or generic auth actions", () => {
|
||||
expect(providerForAction("auth.login")).toBe("unknown");
|
||||
expect(providerForAction("auth.logout")).toBe("unknown");
|
||||
expect(providerForAction("auth.register")).toBe("unknown");
|
||||
expect(providerForAction("admin.user.delete")).toBe("unknown");
|
||||
expect(providerForAction("")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("iconForAuthProvider", () => {
|
||||
it("maps each provider to the documented lucide glyph", () => {
|
||||
// Marc's brief pinned the exact icon for each provider so the
|
||||
// visual language stays consistent across the admin surface.
|
||||
expect(iconForAuthProvider("password")).toBe(KeyRound);
|
||||
expect(iconForAuthProvider("passkey")).toBe(Fingerprint);
|
||||
expect(iconForAuthProvider("api_token")).toBe(Cpu);
|
||||
expect(iconForAuthProvider("withings")).toBe(Globe);
|
||||
// `unknown` reuses the OAuth glyph rather than a `?` so the
|
||||
// column never paints a missing icon.
|
||||
expect(iconForAuthProvider("unknown")).toBe(Globe);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user