mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Add a Sharing section under Settings (between API and Export) where the owner creates a read-only clinician share link, sees their active and inactive links, and revokes one. Create wires to the C4 lifecycle API: it sends the label, a rolling history window, an expiry (capped at 90 days, validated before the round trip), an optional scoped FHIR-API toggle, and the FHIR resource types. The raw token is revealed exactly once on create as a copy-able `/c/<token>` URL; the list only ever carries the server's safe projection (no token, since only its hash is stored). Revoke confirms through an alert dialog and invalidates the list. RSC page mounts a client island; reads unwrap the envelope and key off the centralised query factory. New `settings.sharing` / `settings.sections.sharing` strings land in all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
/**
|
|
* v1.11.0 — Settings → Sharing (Epic C, C7).
|
|
*
|
|
* The owner share-link surface. The security-load-bearing guarantees the
|
|
* test pins:
|
|
* 1. The list never carries a raw `hls_` token — the server stores only the
|
|
* hash, so the list response shape has no token field and the rendered
|
|
* markup must not surface one.
|
|
* 2. The one-time token reveal card (`share-token-reveal`) is absent until a
|
|
* create succeeds — on first paint (no token in state) it must not render.
|
|
* 3. Active links render with a revoke control; revoked/expired links fold
|
|
* into the inactive list.
|
|
* 4. The expiry input is bounded at the server cap (90 days).
|
|
*
|
|
* SSR-static render only (no jsdom), matching the sibling settings-section
|
|
* tests. The TanStack hooks are mocked so the query returns a fixed link list.
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
vi.mock("@/hooks/use-auth", () => ({
|
|
useAuth: () => ({
|
|
user: { id: "u1", role: "USER" },
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
refetch: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
const FIXTURE_LINKS = [
|
|
{
|
|
id: "link-active",
|
|
label: "Cardiology referral",
|
|
rangeStart: "2026-01-01T00:00:00.000Z",
|
|
rangeEnd: null,
|
|
resourceTypes: ["Patient", "Observation"],
|
|
allowFhirApi: true,
|
|
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
createdAt: "2026-06-01T00:00:00.000Z",
|
|
revokedAt: null,
|
|
lastAccessAt: "2026-06-02T10:00:00.000Z",
|
|
accessCount: 3,
|
|
active: true,
|
|
},
|
|
{
|
|
id: "link-revoked",
|
|
label: "Old GP link",
|
|
rangeStart: "2026-01-01T00:00:00.000Z",
|
|
rangeEnd: null,
|
|
resourceTypes: [],
|
|
allowFhirApi: false,
|
|
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
createdAt: "2026-05-01T00:00:00.000Z",
|
|
revokedAt: "2026-05-15T00:00:00.000Z",
|
|
lastAccessAt: null,
|
|
accessCount: 0,
|
|
active: false,
|
|
},
|
|
];
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQuery: () => ({ data: FIXTURE_LINKS, isLoading: false }),
|
|
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
|
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
|
}));
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
import { SharingSection } from "../sharing-section";
|
|
|
|
function render(locale: "en" | "de" = "en") {
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale={locale}>
|
|
<SharingSection />
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
describe("<SharingSection> — owner share-link surface (C7)", () => {
|
|
it("renders the create form with a 90-day-capped expiry input", () => {
|
|
const html = render();
|
|
expect(html).toContain("New share link");
|
|
// The expiry field carries the server's max-days cap.
|
|
expect(html).toContain('max="90"');
|
|
expect(html).toContain("Create link");
|
|
});
|
|
|
|
it("does not reveal a token before a create succeeds", () => {
|
|
const html = render();
|
|
// The one-time reveal card only mounts once `newToken` state is set.
|
|
expect(html).not.toContain('data-testid="share-token-reveal"');
|
|
});
|
|
|
|
it("lists the active link with a revoke control and never a raw token", () => {
|
|
const html = render();
|
|
expect(html).toContain('data-testid="share-active-list"');
|
|
expect(html).toContain("Cardiology referral");
|
|
expect(html).toContain("Revoke");
|
|
// The list response shape carries no token; the markup must not surface
|
|
// an `hls_` string anywhere.
|
|
expect(html).not.toContain("hls_");
|
|
// The FHIR badge surfaces for the FHIR-enabled link.
|
|
expect(html).toContain("FHIR");
|
|
});
|
|
|
|
it("folds revoked links into the inactive list (not the active list)", () => {
|
|
const html = render();
|
|
// The active list must NOT contain the revoked link's label.
|
|
const activeListStart = html.indexOf('data-testid="share-active-list"');
|
|
const activeListEnd = html.indexOf('data-testid="share-inactive-list"');
|
|
// Inactive list is collapsed by default, so the label only appears once
|
|
// the disclosure is open — but the active list must never carry it.
|
|
const activeSlice =
|
|
activeListEnd > activeListStart
|
|
? html.slice(activeListStart, activeListEnd)
|
|
: html.slice(activeListStart);
|
|
expect(activeSlice).not.toContain("Old GP link");
|
|
// The inactive-count disclosure surfaces.
|
|
expect(html).toContain("Revoked and expired");
|
|
});
|
|
|
|
it("renders localized section copy", () => {
|
|
const html = render("de");
|
|
expect(html).toContain("Neuer Freigabe-Link");
|
|
});
|
|
});
|