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:
Marc-André Bombeck committed 2026-06-11 11:45:58 +02:00
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);
});
});