mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys
The self-context adopt runs read-modify-write inside one transaction, the admin shell renders nothing until the role is confirmed, and the json-body helper merges Headers instances correctly. The compose image reference accepts a pinned digest through an env var, and a promote script resolves the published digest, verifies its signature and hands the operator the exact pinning call — the documented path away from a moving latest tag.
This commit is contained in:
1 parent
599b7e8c99
commit
f13aa684f9
12 files changed
+876
-79
No files matched your search
@@ -21,6 +21,17 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathnameRef.value,
|
||||
}));
|
||||
|
||||
// The shell gates its own frame on the confirmed ADMIN role — a
|
||||
// non-admin (or a still-loading auth state) must not see the section
|
||||
// nav while AuthShell's redirect effect is catching up.
|
||||
const mockUserRef = { value: { role: "ADMIN", username: "op" } as {
|
||||
role: string;
|
||||
username: string;
|
||||
} | null };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: mockUserRef.value }),
|
||||
}));
|
||||
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -31,8 +42,11 @@ import { ADMIN_SECTIONS, AdminShell } from "../admin-shell";
|
||||
function renderShell(props: {
|
||||
active?: (typeof ADMIN_SECTIONS)[number]["slug"];
|
||||
pathname?: string;
|
||||
user?: { role: string; username: string } | null;
|
||||
}) {
|
||||
mockPathnameRef.value = props.pathname ?? "/admin/system-status";
|
||||
mockUserRef.value =
|
||||
props.user === undefined ? { role: "ADMIN", username: "op" } : props.user;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AdminShell active={props.active}>
|
||||
@@ -133,6 +147,22 @@ describe("<AdminShell>", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders neither frame nor children until the role is confirmed ADMIN", () => {
|
||||
// Non-admin: no section nav, no children — the frame must not
|
||||
// flash while AuthShell's redirect effect moves the user away.
|
||||
const asUser = renderShell({
|
||||
active: "system-status",
|
||||
user: { role: "USER", username: "u" },
|
||||
});
|
||||
expect(asUser).toBe("");
|
||||
// Auth still in flight (user === null): same blank render.
|
||||
const loading = renderShell({ active: "system-status", user: null });
|
||||
expect(loading).toBe("");
|
||||
// Admin: frame + children render.
|
||||
const asAdmin = renderShell({ active: "system-status" });
|
||||
expect(asAdmin).toContain("section body");
|
||||
});
|
||||
|
||||
it("does NOT mark any section active on `/admin` overview", () => {
|
||||
const html = renderShell({ pathname: "/admin" });
|
||||
// Overview-link only — no section gets aria-current.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* v1.16.5 — admin invite section: multi-use surface contracts.
|
||||
*
|
||||
* SSR smoke tests per project convention (`renderToStaticMarkup` +
|
||||
* seeded QueryClient; no `@testing-library/react`). Pinned:
|
||||
*
|
||||
* 1. The table renders `uses / maxUses` for a multi-use invite.
|
||||
* 2. uses == maxUses surfaces the "Aufgebraucht" (exhausted) chip.
|
||||
* 3. Multiple redemptions render the "+N weitere" popover trigger;
|
||||
* a single redemption renders inline without one.
|
||||
* 4. `clampMaxUses` pins the create-dialog field to the 1–50 range
|
||||
* (the same clamp the submit path sends).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
InviteTokensSection,
|
||||
clampMaxUses,
|
||||
} from "@/components/admin/invite-tokens-section";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const FUTURE = new Date(Date.now() + 7 * 86_400_000).toISOString();
|
||||
const PAST = new Date(Date.now() - 86_400_000).toISOString();
|
||||
|
||||
interface SeedInvite {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
usedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
uses: number;
|
||||
maxUses: number;
|
||||
creator: { id: string; username: string } | null;
|
||||
consumer: { id: string; username: string } | null;
|
||||
redemptions: Array<{
|
||||
id: string;
|
||||
redeemedAt: string;
|
||||
user: { id: string; username: string; email: string | null } | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function makeInvite(overrides: Partial<SeedInvite>): SeedInvite {
|
||||
return {
|
||||
id: "inv-1",
|
||||
createdAt: PAST,
|
||||
expiresAt: FUTURE,
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
uses: 0,
|
||||
maxUses: 1,
|
||||
creator: { id: "admin-1", username: "operator" },
|
||||
consumer: null,
|
||||
redemptions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(invites: SeedInvite[]): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryData(queryKeys.adminInvites(), invites);
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<QueryClientProvider client={client}>
|
||||
<InviteTokensSection />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("InviteTokensSection (multi-use)", () => {
|
||||
it("renders uses / maxUses for a partially used multi-use invite", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 2,
|
||||
maxUses: 5,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u2", username: "beta", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("/ 5");
|
||||
expect(html).toContain("invite-status-active");
|
||||
});
|
||||
|
||||
it("surfaces the exhausted chip when uses reaches maxUses", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 3,
|
||||
maxUses: 3,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u2", username: "beta", email: null },
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u3", username: "gamma", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("invite-status-exhausted");
|
||||
expect(html).toContain("Aufgebraucht");
|
||||
});
|
||||
|
||||
it("multiple redemptions render the popover trigger with the +N summary", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 3,
|
||||
maxUses: 5,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u2", username: "beta", email: null },
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u3", username: "gamma", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("admin-invite-redemptions-trigger");
|
||||
// de: "{username} + {count} weitere" — newest redemption leads.
|
||||
expect(html).toContain("alpha + 2 weitere");
|
||||
});
|
||||
|
||||
it("a single redemption renders inline without a popover trigger", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 1,
|
||||
maxUses: 5,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("alpha");
|
||||
expect(html).not.toContain("admin-invite-redemptions-trigger");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampMaxUses", () => {
|
||||
it("passes the 1–50 range through", () => {
|
||||
expect(clampMaxUses("1")).toBe(1);
|
||||
expect(clampMaxUses("25")).toBe(25);
|
||||
expect(clampMaxUses("50")).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps above the cap to 50", () => {
|
||||
expect(clampMaxUses("51")).toBe(50);
|
||||
expect(clampMaxUses("9999")).toBe(50);
|
||||
});
|
||||
|
||||
it("falls back to 1 for zero, negatives, and garbage", () => {
|
||||
expect(clampMaxUses("0")).toBe(1);
|
||||
expect(clampMaxUses("-3")).toBe(1);
|
||||
expect(clampMaxUses("")).toBe(1);
|
||||
expect(clampMaxUses("abc")).toBe(1);
|
||||
});
|
||||
|
||||
it("truncates decimals to the integer part", () => {
|
||||
expect(clampMaxUses("2.9")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
||||
|
||||
@@ -172,6 +173,7 @@ function deriveActiveSlug(
|
||||
export function AdminShell({ active, children }: AdminShellProps) {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so
|
||||
@@ -199,6 +201,13 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
// The section bodies are already role-gated, but the shell frame
|
||||
// itself (the full section nav in two layouts) used to paint for a
|
||||
// non-admin during the frames between auth resolving and AuthShell's
|
||||
// redirect effect replacing the route. Render nothing — frame AND
|
||||
// children — until the role is confirmed ADMIN.
|
||||
if (!user || user.role !== "ADMIN") return null;
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
// already, so this inner shell only carries the wider max-width.
|
||||
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
|
||||
|
||||
@@ -123,6 +123,18 @@ export function deriveInviteStatus(
|
||||
const TTL_CHOICES = [7, 14, 30] as const;
|
||||
const MAX_USES_CAP = 50;
|
||||
|
||||
/**
|
||||
* Clamp the raw max-uses field to the mintable range (integer 1–50).
|
||||
* Shared by the submit path and the blur echo so the admin always sees
|
||||
* exactly what the POST will carry. Exported for unit tests.
|
||||
*/
|
||||
export function clampMaxUses(raw: string): number {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 1
|
||||
? Math.min(parsed, MAX_USES_CAP)
|
||||
: 1;
|
||||
}
|
||||
|
||||
/** Whole days (ceil) from `now` until `iso`; negative when past. */
|
||||
function daysUntil(iso: string, now: Date): number {
|
||||
const ms = new Date(iso).getTime() - now.getTime();
|
||||
@@ -253,12 +265,7 @@ export function InviteTokensSection() {
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const parsedMax = Number.parseInt(maxUses, 10);
|
||||
const safeMax =
|
||||
Number.isFinite(parsedMax) && parsedMax >= 1
|
||||
? Math.min(parsedMax, MAX_USES_CAP)
|
||||
: 1;
|
||||
create.mutate({ expiresInDays: ttlDays, maxUses: safeMax });
|
||||
create.mutate({ expiresInDays: ttlDays, maxUses: clampMaxUses(maxUses) });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -591,8 +598,14 @@ export function InviteTokensSection() {
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
@@ -753,7 +766,12 @@ function RedemptionsCell({
|
||||
count: invite.redemptions.length,
|
||||
})}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the
|
||||
list height so the popover never outgrows the viewport. */}
|
||||
<ul
|
||||
className="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
data-testid="admin-invite-redemptions-list"
|
||||
>
|
||||
{invite.redemptions.map((entry) => (
|
||||
<li key={entry.id} className="text-sm leading-tight">
|
||||
<div>
|
||||
|
||||
Reference in new issue
Block a user