mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-28 16:02:33 +00:00
feat: add explicit native admin CLI consent with memory-only approval and persistent redacted node evidence plus separate validated broker scopes and localhost proof source
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { APIError } from "@/lib/api/client";
|
||||
import type * as ClientModule from "@/lib/api/client";
|
||||
import DeviceApprovalPage from "./DeviceApprovalPage";
|
||||
|
||||
const api = vi.hoisted(() => ({ describe: vi.fn(), decide: vi.fn(), reauth: vi.fn() }));
|
||||
vi.mock("@/lib/api/client/admin/device", () => ({ describeAdminDevice: api.describe, decideAdminDevice: api.decide }));
|
||||
vi.mock("@/lib/api/client", async (importOriginal) => ({ ...await importOriginal<typeof ClientModule>(), promptForReauth: api.reauth }));
|
||||
vi.mock("@/lib/env", () => ({ API_URL: "http://localhost:8080" }));
|
||||
vi.mock("@/hooks/useMe", () => ({ useMe: () => ({ data: { id: "fixture-admin", admin_permissions: 16 } }) }));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
const consentToken = "c".repeat(43);
|
||||
const description = () => ({ request: { client_name: "Fixture laptop", instance_url: "http://localhost:8080", status: "pending", expires_at: new Date(Date.now() + 60000).toISOString(), consent_token: consentToken }, requested_access: "Your live platform administrator permissions through a separate revocable session", admin_permissions: 16 });
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear(); sessionStorage.clear();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
api.describe.mockResolvedValue(description());
|
||||
api.decide.mockImplementation((_code, _token, decision) => Promise.resolve({ status: decision }));
|
||||
api.reauth.mockResolvedValue(undefined);
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
await act(async () => root.render(<MemoryRouter initialEntries={["/device?user_code=ABCD-EFGH&approve=true"]}><DeviceApprovalPage /></MemoryRouter>));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount()); host.remove(); vi.useRealTimers(); vi.unstubAllGlobals();
|
||||
});
|
||||
function button(name: string) {
|
||||
const found = [...host.querySelectorAll("button")].find((b) => b.textContent === name);
|
||||
if (!found) throw new Error(`Button missing: ${name}`);
|
||||
return found;
|
||||
}
|
||||
async function review() {
|
||||
await act(async () => {
|
||||
const input = host.querySelector("input")!;
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "ABCD-EFGH");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
}
|
||||
|
||||
describe("explicit admin device consent", () => {
|
||||
it("does not describe or approve URL codes on load", () => {
|
||||
expect(api.describe).not.toHaveBeenCalled(); expect(api.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
it("shows instance/client/capability/expiry without exposing or persisting consent", async () => {
|
||||
await review();
|
||||
expect(api.describe).toHaveBeenCalledWith("ABCD-EFGH");
|
||||
expect(api.decide).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain("Fixture laptop"); expect(host.textContent).toContain("http://localhost:8080");
|
||||
expect(host.textContent).toContain("live platform administrator permissions"); expect(host.textContent).toContain("Expires");
|
||||
expect(host.innerHTML).not.toContain(consentToken); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0);
|
||||
await act(async () => button("Approve CLI sign-in").click());
|
||||
expect(api.decide).toHaveBeenCalledWith("ABCD-EFGH", consentToken, "approved");
|
||||
expect(host.textContent).toContain("Request approved");
|
||||
});
|
||||
it("supports an explicit denial instead of an approval", async () => {
|
||||
await review(); await act(async () => button("Deny request").click());
|
||||
expect(api.decide).toHaveBeenCalledWith("ABCD-EFGH", consentToken, "denied");
|
||||
expect(host.textContent).toContain("Request denied");
|
||||
});
|
||||
it("requires a new explicit decision after native reauthentication", async () => {
|
||||
api.decide.mockRejectedValueOnce(new APIError("Confirm identity", 403, { code: "reauth_required" }));
|
||||
await review(); await act(async () => button("Approve CLI sign-in").click());
|
||||
expect(api.decide).toHaveBeenCalledTimes(1); expect(api.reauth).not.toHaveBeenCalled();
|
||||
await act(async () => button("Confirm identity").click());
|
||||
expect(api.reauth).toHaveBeenCalledTimes(1); expect(api.decide).toHaveBeenCalledTimes(1);
|
||||
await act(async () => button("Approve CLI sign-in").click());
|
||||
expect(api.decide).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("expires pending consent without an automatic decision", async () => {
|
||||
vi.useFakeTimers(); await review();
|
||||
await act(async () => { vi.advanceTimersByTime(60001); });
|
||||
expect(host.textContent).toContain("Request expired"); expect(api.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["admin_device_expired", "admin_device_resolved"])("keeps %s fail-closed", async (code) => {
|
||||
api.decide.mockRejectedValueOnce(new APIError("Cannot use code", code.endsWith("expired") ? 404 : 409, { code }));
|
||||
await review(); await act(async () => button("Approve CLI sign-in").click());
|
||||
expect(host.textContent).toContain(code.endsWith("expired") ? "Request expired" : "Request already resolved");
|
||||
expect(host.textContent).not.toContain("Request approved");
|
||||
});
|
||||
it.each(["https://other.example.test", "http://user:password@localhost:8080", "http://localhost:8080?token=hidden"])("withholds consent for ambiguous/mismatched instance %s", async (instance_url) => {
|
||||
api.describe.mockResolvedValue({ ...description(), request: { ...description().request, instance_url } });
|
||||
await review(); expect(host.textContent).toContain("could not be verified"); expect(host.innerHTML).not.toContain(instance_url); expect(api.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not silently retry an uncertain failed decision", async () => {
|
||||
api.decide.mockRejectedValueOnce(new Error("Network unavailable"));
|
||||
await review(); await act(async () => button("Approve CLI sign-in").click());
|
||||
expect(api.decide).toHaveBeenCalledTimes(1); expect(host.textContent).toContain("Review request"); expect(host.textContent).not.toContain("Request approved");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Callout, Panel, Property, PropertyList, StatusBadge } from "@/components/ui/kit";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { APIError, promptForReauth } from "@/lib/api/client";
|
||||
import { describeAdminDevice, decideAdminDevice, type DeviceDescription } from "@/lib/api/client/admin/device";
|
||||
import { API_URL } from "@/lib/env";
|
||||
import { AdminPerm } from "@/lib/auth/permissions";
|
||||
import { validDeviceDescription } from "@/lib/adminDevice";
|
||||
|
||||
type Phase = "entry" | "pending" | "approved" | "denied" | "expired" | "resolved";
|
||||
type Review = Omit<DeviceDescription["request"], "consent_token"> & { permissions: number; requestedAccess: string };
|
||||
|
||||
export default function DeviceApprovalPage() {
|
||||
const [code, setCode] = useState("");
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("entry");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [needsReauth, setNeedsReauth] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const consent = useRef("");
|
||||
const submittedCode = useRef("");
|
||||
const inFlight = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; consent.current = ""; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (phase !== "pending" || !review) return;
|
||||
const expire = () => { consent.current = ""; setPhase("expired"); setNeedsReauth(false); };
|
||||
const remaining = Date.parse(review.expires_at) - Date.now();
|
||||
const timer = window.setTimeout(expire, Math.max(0, remaining));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [phase, review]);
|
||||
|
||||
function failed(err: unknown) {
|
||||
setError(err);
|
||||
if (err instanceof APIError && err.code === "reauth_required") {
|
||||
setNeedsReauth(true);
|
||||
} else {
|
||||
consent.current = "";
|
||||
if (err instanceof APIError && err.code === "admin_device_expired") setPhase("expired");
|
||||
else if (err instanceof APIError && err.code === "admin_device_resolved") setPhase("resolved");
|
||||
else setPhase("entry");
|
||||
}
|
||||
}
|
||||
|
||||
async function describe(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setBusy(true); setError(null); setReview(null); setNeedsReauth(false); consent.current = "";
|
||||
try {
|
||||
const normalized = code.trim().toUpperCase();
|
||||
const result = await describeAdminDevice(normalized);
|
||||
if (!mounted.current) return;
|
||||
if (!validDeviceDescription(result, API_URL)) throw new Error("The request identity or response could not be verified. Check this admin panel's configured backend before trying again.");
|
||||
const { consent_token, ...details } = result.request;
|
||||
consent.current = consent_token;
|
||||
submittedCode.current = normalized;
|
||||
setReview({ ...details, permissions: result.admin_permissions, requestedAccess: result.requested_access });
|
||||
setPhase(Date.parse(details.expires_at) <= Date.now() ? "expired" : "pending");
|
||||
if (Date.parse(details.expires_at) <= Date.now()) consent.current = "";
|
||||
} catch (err) { if (mounted.current) failed(err); }
|
||||
finally { inFlight.current = false; if (mounted.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function decide(decision: "approved" | "denied") {
|
||||
if (inFlight.current || phase !== "pending" || !review || !consent.current || needsReauth) return;
|
||||
if (Date.parse(review.expires_at) <= Date.now()) { consent.current = ""; setPhase("expired"); return; }
|
||||
inFlight.current = true; setBusy(true); setError(null);
|
||||
try {
|
||||
const result = await decideAdminDevice(submittedCode.current, consent.current, decision);
|
||||
if (!mounted.current) return;
|
||||
if (result.status !== decision) throw new Error("The decision was not confirmed. Review a new request before trying again.");
|
||||
consent.current = ""; setPhase(result.status);
|
||||
} catch (err) { if (mounted.current) failed(err); }
|
||||
finally { inFlight.current = false; if (mounted.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function reauthenticate() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); setError(null);
|
||||
try {
|
||||
await promptForReauth();
|
||||
if (mounted.current) setNeedsReauth(false);
|
||||
} catch (err) { if (mounted.current) setError(err); }
|
||||
finally { inFlight.current = false; if (mounted.current) setBusy(false); }
|
||||
}
|
||||
|
||||
return <div className="max-w-3xl">
|
||||
<PageHeader title="Approve CLI sign-in" description="Authorize a separate platform administrator session only for a CLI request you started." />
|
||||
<Callout tone="warning" icon={ShieldCheck} title="Only approve your own request" className="mb-6">
|
||||
Never enter a code someone else sent you. Check the instance and requester against your terminal. Client names are requester-supplied, not verified device identities.
|
||||
</Callout>
|
||||
{error != null && <ErrorState error={error} title="Sign-in request could not be completed" className="mb-6" />}
|
||||
{phase === "entry" && <Panel title="Enter the code from your CLI">
|
||||
<form onSubmit={describe} className="space-y-4">
|
||||
<Label htmlFor="cli-code">User code</Label>
|
||||
<Input id="cli-code" value={code} onChange={(e) => setCode(e.target.value)} autoComplete="off" autoCapitalize="characters" maxLength={9} placeholder="ABCD-EFGH" data-ph-mask="" disabled={busy} />
|
||||
<p className="text-sm text-muted-foreground">Reviewing a code does not approve it. No CLI credentials are displayed or stored here.</p>
|
||||
<Button type="submit" disabled={busy || !/^[A-Z2-9]{4}-?[A-Z2-9]{4}$/i.test(code.trim())}>{busy ? "Looking up…" : "Review request"}</Button>
|
||||
</form>
|
||||
</Panel>}
|
||||
{phase === "pending" && review && <Panel title="Review requested access">
|
||||
<StatusBadge tone="warning">Pending explicit approval</StatusBadge>
|
||||
<PropertyList className="mt-4">
|
||||
<Property label="Instance"><span className="break-all font-mono text-xs">{review.instance_url}</span></Property>
|
||||
<Property label="Requester client">{review.client_name}</Property>
|
||||
<Property label="Capability">{review.requestedAccess}</Property>
|
||||
<Property label="Permissions"><span>{Object.entries(AdminPerm).filter(([, bit]) => (review.permissions & bit) === bit).map(([name]) => name.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()).join(", ")} (mask {review.permissions})</span></Property>
|
||||
<Property label="Expires"><time dateTime={review.expires_at}>{new Date(review.expires_at).toLocaleString()}</time></Property>
|
||||
</PropertyList>
|
||||
<p className="my-4 text-sm text-muted-foreground">The CLI receives a separate, normally revocable session. Approval is not confirmation that the CLI has claimed it. Its original login proof can expire while it waits.</p>
|
||||
{needsReauth ? <div className="space-y-3">
|
||||
<p role="status" className="text-sm">Confirm your identity, then explicitly choose approve or deny again. Confirmation alone never approves this request.</p>
|
||||
<Button disabled={busy} onClick={() => void reauthenticate()}>Confirm identity</Button>
|
||||
</div> : <div className="flex gap-2">
|
||||
<Button disabled={busy} onClick={() => void decide("approved")}>{busy ? "Submitting…" : "Approve CLI sign-in"}</Button>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void decide("denied")}>Deny request</Button>
|
||||
</div>}
|
||||
</Panel>}
|
||||
{["approved", "denied", "expired", "resolved"].includes(phase) && <Panel title={phase === "approved" ? "Request approved" : phase === "denied" ? "Request denied" : phase === "expired" ? "Request expired or unavailable" : "Request already resolved"}>
|
||||
<p role="status" className="text-sm text-muted-foreground">{phase === "approved" ? "Return to your CLI to finish sign-in. No access or refresh credentials are returned to this page. If claiming fails or the original proof expires, start a new request." : phase === "denied" ? "This request cannot sign in. Start a new request in your CLI if needed." : "This code can no longer be used. Start a new request in your CLI, then review its new code here."}</p>
|
||||
<Button variant="outline" className="mt-4" disabled={busy} onClick={() => { consent.current = ""; setReview(null); setCode(""); setError(null); setPhase("entry"); }}>Enter another code</Button>
|
||||
</Panel>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// One machine in the fleet.
|
||||
//
|
||||
// Everything shown here is reported BY the node or resolved FOR it. There is
|
||||
// no install, restart, log or reboot button, because nothing reaches into a
|
||||
// no install, restart or reboot button, because nothing reaches into a
|
||||
// machine any more: a node enrols with a token, heartbeats, and pulls the
|
||||
// version it should run. What an operator can actually do is rename it, hold
|
||||
// it at a version, and forget it.
|
||||
@@ -56,6 +56,8 @@ import { TONE_PANEL, TONE_TEXT } from "@/lib/tones";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeStatePill } from "./fleet/tones";
|
||||
import { ResourceUsage } from "./fleet/ResourceUsage";
|
||||
import { NodeDiagnostics } from "./fleet/NodeDiagnostics";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
|
||||
const CRUMBS = [{ label: "Workers", to: "/workers" }];
|
||||
|
||||
@@ -134,6 +136,7 @@ export default function WorkerDetailPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (nodeQ.isError) return <div><PageHeader breadcrumbs={CRUMBS} title="Node unavailable" /><ErrorState error={nodeQ.error} onRetry={() => void nodeQ.refetch()} /></div>;
|
||||
if (!node) {
|
||||
return (
|
||||
<div>
|
||||
@@ -168,7 +171,7 @@ export default function WorkerDetailPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => nodeQ.refetch()}>
|
||||
<Button size="sm" variant="ghost" onClick={() => { void nodeQ.refetch(); void qc.invalidateQueries({ queryKey: ["admin", "node-diagnostics", id] }); }}>
|
||||
<RefreshCw className={cn("size-3.5", nodeQ.isFetching && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -178,7 +181,7 @@ export default function WorkerDetailPage() {
|
||||
<div className="min-w-0">
|
||||
{node.last_error && (
|
||||
<Callout tone="danger" icon={AlertTriangle} title="Last error" className="mb-8">
|
||||
<span className="break-words font-mono text-xs">{node.last_error}</span>
|
||||
<span>The node reported an error on its last heartbeat. Inspect redacted evidence below; raw node error text is not displayed.</span>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
@@ -277,6 +280,8 @@ export default function WorkerDetailPage() {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<NodeDiagnostics node={node} mailboxes={mailboxes} />
|
||||
|
||||
<Section
|
||||
title="Version"
|
||||
description="The node pulls whatever the fleet is set to. Pin it to hold this one machine back, or to canary a release on it before the rest follow."
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { ErrorState } from "@/components/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { EmptyState, Panel, Property, PropertyList, Section, StatusBadge } from "@/components/ui/kit";
|
||||
import { useMe } from "@/hooks/useMe";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { AdminPerm, hasAdminPerm } from "@/lib/auth/permissions";
|
||||
import type { FleetNode } from "@/lib/api/client/admin/fleetNodes";
|
||||
import type { AdminWorkerEmail } from "@/lib/api/models/admin";
|
||||
import { getNodeLogs, getNodeBroker, type NodeBrokerSource, type NodeLogFilters } from "@/lib/api/client/admin/nodeDiagnostics";
|
||||
import { diagnosticReason, emptyLogFilters, eventLabels, isUUID, logFilterError, parseLogFilters, safeCount, safeLogEvent, validTime, validatedBroker } from "@/lib/nodeDiagnostics";
|
||||
|
||||
function Timestamp({ value }: { value?: string | null }) {
|
||||
return validTime(value) ? <time dateTime={value}>{new Date(value).toLocaleString()}</time> : <>Not observed</>;
|
||||
}
|
||||
const count = (value: unknown) => safeCount(value) ? value.toLocaleString() : "Unknown";
|
||||
|
||||
function SourceBadge({ availability }: { availability?: string }) {
|
||||
return <StatusBadge tone={availability === "fresh" ? "neutral" : "warning"}>{availability === "fresh" ? "Fresh observation" : availability === "stale" ? "Stale observation" : "Unavailable observation"}</StatusBadge>;
|
||||
}
|
||||
|
||||
function BrokerScope({ title, source, group, topic }: { title: string; source: NodeBrokerSource; group: string; topic: string }) {
|
||||
const observation = validatedBroker(source, group, topic);
|
||||
const coverage = ["complete", "partial", "unavailable"].includes(source.coverage) ? source.coverage : "unknown";
|
||||
return <Panel title={title}>
|
||||
<SourceBadge availability={source.availability} />
|
||||
<PropertyList className="mt-3">
|
||||
<Property label="Consumer group"><span className="font-mono text-xs break-all">{group}</span></Property>
|
||||
<Property label="Topic"><span className="font-mono text-xs break-all">{topic}</span></Property>
|
||||
<Property label="Coverage">{coverage} ({count(source.measured_scopes)} / {count(source.expected_scopes)} scopes)</Property>
|
||||
<Property label="Checked"><Timestamp value={source.checked_at} /></Property>
|
||||
<Property label="Observed"><Timestamp value={source.observed_at} /></Property>
|
||||
<Property label="Committed lag"><span className="font-mono">{observation ? `${observation.lag.toLocaleString()} messages` : "Unknown / unavailable"}</span></Property>
|
||||
<Property label="Group members">{observation ? count(observation.members) : "Unknown / unavailable"}</Property>
|
||||
</PropertyList>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{observation ? "Validated retained offsets at the observation time. Broker health and delivery remain unknown." : diagnosticReason(source.metrics?.find((m) => m.reason)?.reason ?? source.reason)}</p>
|
||||
{observation && <details className="mt-4 text-xs">
|
||||
<summary className="cursor-pointer font-medium">Validated partition offsets</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-left tabular-nums" aria-label={`${title} partition offsets`}>
|
||||
<thead><tr className="border-b text-muted-foreground"><th className="p-2">Partition</th><th className="p-2">Earliest</th><th className="p-2">Committed</th><th className="p-2">Latest</th><th className="p-2">Lag</th></tr></thead>
|
||||
<tbody>{observation.detail.partitions.map((p) => <tr key={p.partition} className="border-b"><td className="p-2">{p.partition}</td><td className="p-2">{p.earliest}</td><td className="p-2">{p.committed}</td><td className="p-2">{p.latest}</td><td className="p-2">{p.committed_lag}</td></tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>}
|
||||
</Panel>;
|
||||
}
|
||||
|
||||
export function NodeDiagnostics({ node, mailboxes }: { node: FleetNode; mailboxes: AdminWorkerEmail[] }) {
|
||||
const { data: me } = useMe();
|
||||
const canViewBroker = !!me && hasAdminPerm(me.admin_permissions, AdminPerm.ViewAnalytics);
|
||||
const canViewMailboxes = !!me && hasAdminPerm(me.admin_permissions, AdminPerm.ViewUsers);
|
||||
const [filters, setFilters] = useBrowseState(`node-logs:${node.id}`, emptyLogFilters, parseLogFilters);
|
||||
const filterError = logFilterError(filters);
|
||||
const logs = useQuery({
|
||||
queryKey: ["admin", "node-diagnostics", node.id, "logs", filters],
|
||||
queryFn: () => getNodeLogs(node.id, filters),
|
||||
enabled: !filterError,
|
||||
retry: false, refetchInterval: 30_000,
|
||||
});
|
||||
const broker = useQuery({
|
||||
queryKey: ["admin", "node-diagnostics", node.id, "broker"],
|
||||
queryFn: () => getNodeBroker(node.id),
|
||||
enabled: node.role === "worker" && canViewBroker,
|
||||
retry: false, refetchInterval: 30_000,
|
||||
});
|
||||
const history = logs.data;
|
||||
const matchedHistory = history?.node_id === node.id ? history : undefined;
|
||||
const readable = !!matchedHistory && ["fresh", "stale"].includes(matchedHistory.availability) && matchedHistory.capture?.protocol === 1;
|
||||
const events = readable ? (matchedHistory.events ?? []).filter(safeLogEvent) : [];
|
||||
const hidden = readable && (matchedHistory.events?.length ?? 0) > events.length;
|
||||
const setFilter = (key: keyof NodeLogFilters, value: string) => setFilters((previous) => ({ ...previous, [key]: value }));
|
||||
|
||||
return <>
|
||||
<Section title="Recent redacted node logs" description="Partial allowlisted evidence, not a full journal, mailbox health check or confirmation of delivery. Snapshots refresh every 30 seconds."
|
||||
actions={<Button size="xs" variant="ghost" disabled={logs.isFetching || !!filterError} onClick={() => void logs.refetch()}><RefreshCw className="size-3" />{logs.isFetching ? "Refreshing logs…" : "Refresh logs"}</Button>}>
|
||||
<Panel>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5"><Label htmlFor="node-log-level">Log level</Label>
|
||||
<Select value={filters.level || "all"} onValueChange={(v) => setFilter("level", v === "all" ? "" : v)}>
|
||||
<SelectTrigger id="node-log-level"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="all">All levels</SelectItem><SelectItem value="info">Info</SelectItem><SelectItem value="warn">Warn</SelectItem><SelectItem value="error">Error</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label htmlFor="node-log-after">After (local time)</Label><Input id="node-log-after" type="datetime-local" value={filters.after} onChange={(e) => setFilter("after", e.target.value)} /></div>
|
||||
<div className="space-y-1.5"><Label htmlFor="node-log-before">Before (local time)</Label><Input id="node-log-before" type="datetime-local" value={filters.before} onChange={(e) => setFilter("before", e.target.value)} /></div>
|
||||
<div className="space-y-1.5"><Label htmlFor="node-log-mailbox">Mailbox UUID</Label><Input id="node-log-mailbox" placeholder="Any mailbox" maxLength={36} value={filters.mailbox} onChange={(e) => setFilter("mailbox", e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3"><Button variant="ghost" size="xs" onClick={() => setFilters(emptyLogFilters)}>Clear log filters</Button><span className="text-xs text-muted-foreground">Inclusive time bounds; up to 200 newest matching events. Filters are saved for this admin and node in this browser tab.</span></div>
|
||||
{filterError && <p role="alert" className="mt-3 text-sm text-destructive">{filterError}</p>}
|
||||
{!filterError && logs.isLoading && <Skeleton className="mt-4 h-32 w-full" />}
|
||||
{logs.isError && !filterError && <ErrorState className="mt-4" error={logs.error} title="Node evidence could not be refreshed" onRetry={() => void logs.refetch()} />}
|
||||
{!filterError && history && !matchedHistory && <p role="alert" className="mt-4 text-sm">Evidence does not match this node. No evidence is displayed.</p>}
|
||||
{!filterError && matchedHistory && <>
|
||||
{logs.isError && <p className="mt-3 text-xs text-muted-foreground">Last successful snapshot below. The latest refresh failed; it is not a current observation.</p>}
|
||||
<div className="mt-5 flex flex-wrap gap-2"><SourceBadge availability={logs.isError ? "stale" : matchedHistory.availability} /><StatusBadge>{matchedHistory.coverage === "partial" ? "Partial coverage" : "Unavailable coverage"}</StatusBadge></div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">{diagnosticReason(matchedHistory.reason)}</p>
|
||||
<PropertyList className="mt-3">
|
||||
<Property label="Source">Node-agent allowlisted events via bounded Redis history. No historical journal import.</Property>
|
||||
<Property label="Capture protocol">{matchedHistory.capture?.protocol === 1 ? "Protocol 1" : "Not observed or unsupported"}</Property>
|
||||
<Property label="Snapshot queried"><Timestamp value={matchedHistory.observed_at} /></Property>
|
||||
<Property label="Capture observed"><Timestamp value={matchedHistory.capture?.observed_at} /></Property>
|
||||
<Property label="Capture received"><Timestamp value={matchedHistory.capture?.received_at} /></Property>
|
||||
<Property label="Capture run"><span className="font-mono text-xs">{isUUID(matchedHistory.capture?.run_id ?? "") ? matchedHistory.capture?.run_id : "Not observed"}</span></Property>
|
||||
<Property label="Run started"><Timestamp value={matchedHistory.capture?.started_at} /></Property>
|
||||
<Property label="Known drops">{matchedHistory.capture ? count(matchedHistory.capture.dropped) : "Unknown"} (per process run, not total historical loss)</Property>
|
||||
<Property label="Retention">Up to {count(matchedHistory.retention_seconds)} seconds and {count(matchedHistory.max_events)} events. Metadata expires separately.</Property>
|
||||
<Property label="Retained window">{readable ? count(matchedHistory.retained_count) : "Unknown"} events across all filters. <Timestamp value={matchedHistory.oldest_retained_at} /> to <Timestamp value={matchedHistory.newest_retained_at} /></Property>
|
||||
</PropertyList>
|
||||
<p className="mt-3 text-xs text-muted-foreground">Pre-installation events, abrupt crashes and lost acknowledgements may be uncounted. Empty evidence never means zero errors or healthy mailboxes.</p>
|
||||
{matchedHistory.truncated && <p role="status" className="mt-3 text-sm">More matching events are retained than this response contains. Narrow the filters to inspect the window.</p>}
|
||||
{hidden && <p role="status" className="mt-3 text-sm">Unsupported or invalid event records were withheld.</p>}
|
||||
{!readable ? <EmptyState icon={FileText} title="Log evidence unavailable" hint="No observed event count can be inferred. Check capture availability above." className="py-8" /> : events.length === 0 ?
|
||||
<EmptyState icon={FileText} title="No retained events match these filters" hint="No matching evidence is not evidence of health. Retention and capture coverage still apply." className="py-8" /> :
|
||||
<div className="mt-4 overflow-x-auto"><table className="w-full text-left text-xs" aria-label="Redacted node events">
|
||||
<thead><tr className="border-b text-muted-foreground"><th className="p-2">Observed</th><th className="p-2">Level</th><th className="p-2">Evidence</th><th className="p-2">Mailbox</th></tr></thead>
|
||||
<tbody>{events.map((event) => {
|
||||
const mailbox = mailboxes.find((m) => m.id === event.mailbox_id);
|
||||
const mailboxID = isUUID(event.mailbox_id ?? "") ? event.mailbox_id : null;
|
||||
return <tr key={event.id} className="border-b align-top">
|
||||
<td className="whitespace-nowrap p-2"><Timestamp value={event.observed_at} /></td>
|
||||
<td className="p-2"><StatusBadge tone={event.level === "error" ? "danger" : event.level === "warn" ? "warning" : "neutral"}>{event.level}</StatusBadge></td>
|
||||
<td className="p-2"><div className="font-medium">{eventLabels[event.event].title}</div><div className="mt-1 text-muted-foreground">{event.category.replaceAll("_", " ")}
|
||||
{event.event === "sync_control_plane_held" && safeCount(event.http_status) && event.http_status >= 100 && event.http_status <= 599 && ` · HTTP ${event.http_status}`}
|
||||
{safeCount(event.count) && event.count <= 1000000 && ` · count ${event.count}`}</div></td>
|
||||
<td className="p-2 font-mono">{mailboxID ? mailbox && canViewMailboxes ? <Link className="text-[var(--admin-accent-strong)] underline underline-offset-2" to={`/mailboxes?q=${encodeURIComponent(mailbox.email)}&worker=${node.id}`} title="Open assigned mailbox in the mailbox browser">{mailboxID}</Link> : <span title="Mailbox identity is not in the currently loaded assignment page, or mailbox access is not permitted.">{mailboxID}</span> : "Not provided"}</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
</>}
|
||||
</Panel>
|
||||
</Section>
|
||||
<Section title="Broker committed-offset observations" description="Committed lag is not send rate, processing throughput, provider health, confirmation of delivery or evidence of an outage. Command and shared result scopes must never be added together."
|
||||
actions={node.role === "worker" && canViewBroker ? <Button size="xs" variant="ghost" disabled={broker.isFetching} onClick={() => void broker.refetch()}><RefreshCw className="size-3" />{broker.isFetching ? "Refreshing broker…" : "Refresh broker"}</Button> : undefined}>
|
||||
{node.role !== "worker" ? <Panel>Command-group broker observations require a worker node. Consumer nodes use generic log evidence above; no worker lag is inferred for this consumer.</Panel> : !canViewBroker ? <Panel>View analytics permission is also required to read broker observations. Log access is unchanged.</Panel> : <>
|
||||
{broker.isLoading && <Skeleton className="h-48 w-full" />}
|
||||
{broker.isError && <ErrorState error={broker.error} title="Broker observation could not be refreshed" onRetry={() => void broker.refetch()} />}
|
||||
{!broker.isError && broker.data && (broker.data.node_id === node.id ? <>
|
||||
<p className="mb-3 text-xs text-muted-foreground">Snapshot queried: <Timestamp value={broker.data.observed_at} />. Shared results belong to the result-consumer group, not this node's processing or delivery count.</p>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<BrokerScope title="Worker command scope" source={broker.data.commands} group={`worker-${node.id}`} topic={`w.${node.id}`} />
|
||||
<BrokerScope title="Shared result-consumer scope" source={broker.data.results} group="consumer-group" topic="jobs.worker-events" />
|
||||
</div>
|
||||
</> : <p role="alert">Broker observation does not match this node. No metrics are displayed.</p>)}
|
||||
</>}
|
||||
</Section>
|
||||
</>;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Network,
|
||||
Send,
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
TicketPercent,
|
||||
UserCog,
|
||||
Users,
|
||||
@@ -136,6 +137,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Instance",
|
||||
items: [
|
||||
{ label: "CLI sign-in", icon: Terminal, pages: [{ to: "/device", label: "CLI sign-in", title: "Approve CLI sign-in" }] },
|
||||
{
|
||||
label: "Setup and health",
|
||||
icon: HeartPulse,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import useBrowseState from "./useBrowseState";
|
||||
import { ADMIN_BROWSE_PREFIX, clearAdminBrowse, resumeBrowse } from "@/lib/browseState";
|
||||
import { emptyLogFilters, parseLogFilters } from "@/lib/nodeDiagnostics";
|
||||
|
||||
const identity = vi.hoisted(() => ({ id: "admin-a" }));
|
||||
vi.mock("@/hooks/useMe", () => ({ useMe: () => ({ data: { id: identity.id } }) }));
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
let node = "worker-a";
|
||||
function View() {
|
||||
const [value, setValue] = useBrowseState(`node-logs:${node}`, emptyLogFilters, parseLogFilters);
|
||||
return <><span>{value.level || "all"}</span><button onClick={() => setValue({ ...value, level: "warn" })}>Warn</button></>;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); identity.id = "admin-a"; node = "worker-a";
|
||||
sessionStorage.clear(); resumeBrowse(); host = document.createElement("div"); root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); sessionStorage.clear(); vi.unstubAllGlobals(); });
|
||||
const render = () => act(async () => root.render(<View />));
|
||||
const key = (owner: string, worker: string) => `${ADMIN_BROWSE_PREFIX}${owner}:node-logs:${worker}`;
|
||||
|
||||
describe("admin-scoped diagnostic browse state", () => {
|
||||
it("restores validated filters on remount and keeps other users/nodes isolated", async () => {
|
||||
sessionStorage.setItem(key("admin-a", "worker-a"), JSON.stringify({ value: { ...emptyLogFilters, level: "error" } }));
|
||||
await render(); expect(host.textContent).toContain("error");
|
||||
identity.id = "admin-b"; await render(); expect(host.textContent).toContain("all");
|
||||
await act(async () => host.querySelector("button")!.click());
|
||||
expect(JSON.parse(sessionStorage.getItem(key("admin-b", "worker-a"))!).value.level).toBe("warn");
|
||||
node = "consumer-a"; await render(); expect(host.textContent).toContain("all");
|
||||
identity.id = "admin-a"; node = "worker-a"; await render(); expect(host.textContent).toContain("error");
|
||||
});
|
||||
it("rejects malformed stored filters without overwriting another user's state", async () => {
|
||||
sessionStorage.setItem(key("admin-a", "worker-a"), "{broken");
|
||||
sessionStorage.setItem(key("admin-b", "worker-a"), "untouched");
|
||||
await render(); expect(host.textContent).toContain("all");
|
||||
expect(sessionStorage.getItem(key("admin-b", "worker-a"))).toBe("untouched");
|
||||
});
|
||||
it("clears on logout and prevents mounted old-session state from reappearing", async () => {
|
||||
await render(); await act(async () => host.querySelector("button")!.click());
|
||||
expect(sessionStorage.length).toBe(1);
|
||||
clearAdminBrowse(); resumeBrowse();
|
||||
await act(async () => host.querySelector("button")!.click());
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useMe } from "@/hooks/useMe";
|
||||
import { ADMIN_BROWSE_PREFIX, browseGeneration, canPersistBrowse } from "@/lib/browseState";
|
||||
|
||||
export default function useBrowseState<T>(name: string, initial: T, parse: (value: unknown) => T | null): [T, Dispatch<SetStateAction<T>>] {
|
||||
const { data: me } = useMe();
|
||||
const owner = me?.id;
|
||||
const key = owner ? `${ADMIN_BROWSE_PREFIX}${owner}:${name}` : null;
|
||||
const generation = useRef(browseGeneration());
|
||||
const read = () => {
|
||||
if (key) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (raw) return parse(JSON.parse(raw)?.value) ?? initial;
|
||||
} catch { /* Browsing works without storage. */ }
|
||||
}
|
||||
return initial;
|
||||
};
|
||||
const [memory, setMemory] = useState(() => ({ key, value: read() }));
|
||||
let current = memory;
|
||||
if (memory.key !== key) {
|
||||
current = { key, value: read() };
|
||||
setMemory(current);
|
||||
}
|
||||
const setValue = useCallback<Dispatch<SetStateAction<T>>>((next) => {
|
||||
setMemory((previous) => previous.key !== key ? previous : ({
|
||||
key, value: typeof next === "function" ? (next as (value: T) => T)(previous.value) : next,
|
||||
}));
|
||||
}, [key]);
|
||||
useEffect(() => {
|
||||
if (!key || key !== memory.key || !canPersistBrowse(generation.current)) return;
|
||||
try { sessionStorage.setItem(key, JSON.stringify({ value: memory.value })); }
|
||||
catch { /* Browsing works without storage. */ }
|
||||
}, [key, memory]);
|
||||
return [current.value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DeviceDescription } from "@/lib/api/client/admin/device";
|
||||
|
||||
export function instanceIdentity(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function validDeviceDescription(value: DeviceDescription, backend: string): boolean {
|
||||
const request = value?.request;
|
||||
const configured = instanceIdentity(backend);
|
||||
return !!request && request.status === "pending" && !!configured && instanceIdentity(request.instance_url) === configured &&
|
||||
typeof request.client_name === "string" && request.client_name.length > 0 && request.client_name.length <= 64 &&
|
||||
/^[A-Za-z0-9_-]{43}$/.test(request.consent_token) && Number.isFinite(Date.parse(request.expires_at)) &&
|
||||
value.requested_access === "Your live platform administrator permissions through a separate revocable session" &&
|
||||
Number.isSafeInteger(value.admin_permissions) && value.admin_permissions > 0 && value.admin_permissions <= 0xffffffff;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ const REAUTH_PROMPT_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
// promptForReauth asks ReauthDialog (mounted in the app shell) to confirm the
|
||||
// operator, resolving on success and rejecting on cancel.
|
||||
function promptForReauth(): Promise<void> {
|
||||
export function promptForReauth(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = 0;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describeAdminDevice, decideAdminDevice } from "./device";
|
||||
const request = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/client", () => ({ Request: request }));
|
||||
|
||||
describe("device API consent contract", () => {
|
||||
it("uses authorized describe without any automatic grant or poll request", async () => {
|
||||
request.mockResolvedValueOnce({ request: { status: "pending" } });
|
||||
await describeAdminDevice("ABCD-EFGH");
|
||||
expect(request).toHaveBeenLastCalledWith({ method: "POST", url: "/admin/auth/device/describe", authorization: true, data: { user_code: "ABCD-EFGH" } });
|
||||
});
|
||||
it("disables the usual automatic retry after fresh-auth so consent is explicit twice", async () => {
|
||||
await decideAdminDevice("ABCD-EFGH", "fixture-consent", "denied");
|
||||
expect(request).toHaveBeenLastCalledWith({ method: "POST", url: "/admin/auth/device/decide", authorization: true, skipReauthPrompt: true, data: { user_code: "ABCD-EFGH", consent_token: "fixture-consent", decision: "denied" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Request } from "@/lib/api/client";
|
||||
|
||||
export interface DeviceDescription {
|
||||
request: {
|
||||
client_name: string;
|
||||
instance_url: string;
|
||||
expires_at: string;
|
||||
status: "pending";
|
||||
consent_token: string;
|
||||
};
|
||||
requested_access: string;
|
||||
admin_permissions: number;
|
||||
}
|
||||
|
||||
export function describeAdminDevice(userCode: string): Promise<DeviceDescription> {
|
||||
return Request({ method: "POST", url: "/admin/auth/device/describe", authorization: true, data: { user_code: userCode } });
|
||||
}
|
||||
|
||||
export function decideAdminDevice(userCode: string, consentToken: string, decision: "approved" | "denied"): Promise<{ status: "approved" | "denied" }> {
|
||||
return Request({
|
||||
method: "POST", url: "/admin/auth/device/decide", authorization: true, skipReauthPrompt: true,
|
||||
data: { user_code: userCode, consent_token: consentToken, decision },
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
//
|
||||
// Nodes are pull-based: they enrol with the join token, heartbeat, and ask what
|
||||
// version they should be running. Nothing here reaches into a machine, so there
|
||||
// is no install, restart, logs or reboot call to make.
|
||||
// is no install, restart or reboot call to make. Redacted submitted evidence
|
||||
// is read through the generic node diagnostics API.
|
||||
|
||||
import { Request } from "@/lib/api/client";
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Request } from "@/lib/api/client";
|
||||
import type { MonitoringAvailability, MonitoringSource, MonitoringMetric } from "./monitoring";
|
||||
|
||||
export interface NodeLogEvent {
|
||||
id: string;
|
||||
observed_at: string;
|
||||
event: string;
|
||||
level: "info" | "warn" | "error";
|
||||
category: string;
|
||||
mailbox_id?: string;
|
||||
http_status?: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface NodeLogHistory {
|
||||
node_id: string;
|
||||
availability: MonitoringAvailability;
|
||||
coverage: "partial" | "unavailable";
|
||||
reason?: string;
|
||||
observed_at: string;
|
||||
capture?: { protocol: number; run_id: string; started_at: string; observed_at: string; received_at: string; dropped: number };
|
||||
events: NodeLogEvent[];
|
||||
oldest_retained_at?: string;
|
||||
newest_retained_at?: string;
|
||||
retained_count: number;
|
||||
truncated: boolean;
|
||||
retention_seconds: number;
|
||||
max_events: number;
|
||||
}
|
||||
|
||||
export interface NodeLogFilters { level: "" | "info" | "warn" | "error"; after: string; before: string; mailbox: string }
|
||||
|
||||
export interface BrokerPartition { topic: string; partition: number; earliest: number; committed: number; latest: number; committed_lag: number }
|
||||
export interface BrokerDetail { consumer_group: string; topics: string[]; partitions: BrokerPartition[] }
|
||||
export type NodeBrokerMetric = MonitoringMetric & { broker?: BrokerDetail };
|
||||
export type NodeBrokerSource = Omit<MonitoringSource, "metrics"> & { metrics: NodeBrokerMetric[] };
|
||||
export interface NodeBrokerObservation { node_id: string; observed_at: string; commands: NodeBrokerSource; results: NodeBrokerSource; note: string }
|
||||
|
||||
export function getNodeLogs(id: string, filters: NodeLogFilters): Promise<NodeLogHistory> {
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
if (filters.level) params.set("level", filters.level);
|
||||
if (filters.after) params.set("after", new Date(filters.after).toISOString());
|
||||
if (filters.before) params.set("before", new Date(filters.before).toISOString());
|
||||
if (filters.mailbox.trim()) params.set("mailbox_id", filters.mailbox.trim());
|
||||
return Request({ method: "GET", url: `/admin/fleet/nodes/${encodeURIComponent(id)}/logs?${params}`, authorization: true });
|
||||
}
|
||||
|
||||
export function getNodeBroker(id: string): Promise<NodeBrokerObservation> {
|
||||
return Request({ method: "GET", url: `/admin/fleet/nodes/${encodeURIComponent(id)}/broker`, authorization: true, timeout: 12_000 });
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
// intentional — the admin surface should require a fresh, deliberate
|
||||
// auth event, not silently inherit a dashboard session.
|
||||
|
||||
import { clearAdminBrowse, resumeBrowse } from "@/lib/browseState";
|
||||
|
||||
const TOKEN_KEY = "warmbly_admin_token";
|
||||
|
||||
export interface AdminToken {
|
||||
@@ -33,13 +35,16 @@ export function getToken(): AdminToken | null {
|
||||
|
||||
export function setToken(t: AdminToken | null) {
|
||||
if (!t) {
|
||||
clearAdminBrowse();
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(TOKEN_KEY, JSON.stringify(t));
|
||||
resumeBrowse();
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
clearAdminBrowse();
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const ADMIN_BROWSE_PREFIX = "warmbly:admin:browse:v1:";
|
||||
let generation = 0;
|
||||
let active = true;
|
||||
export const browseGeneration = () => generation;
|
||||
export const canPersistBrowse = (value: number) => active && value === generation;
|
||||
export const resumeBrowse = () => { active = true; };
|
||||
|
||||
export function clearAdminBrowse() {
|
||||
active = false;
|
||||
generation++;
|
||||
try {
|
||||
for (let index = sessionStorage.length - 1; index >= 0; index--) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key?.startsWith(ADMIN_BROWSE_PREFIX)) sessionStorage.removeItem(key);
|
||||
}
|
||||
} catch { /* Storage may be disabled. */ }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { NodeBrokerSource, NodeLogEvent } from "@/lib/api/client/admin/nodeDiagnostics";
|
||||
import { emptyLogFilters, logFilterError, parseLogFilters, safeLogEvent, validatedBroker } from "./nodeDiagnostics";
|
||||
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const group = `worker-${id}`;
|
||||
const topic = `w.${id}`;
|
||||
const at = "2026-10-10T08:00:00Z";
|
||||
function source(): NodeBrokerSource {
|
||||
return {
|
||||
id, availability: "fresh", coverage: "complete", observed_at: at, checked_at: at, measured_scopes: 1, expected_scopes: 1,
|
||||
metrics: [{ id: "committed_lag", title: "Broker committed_lag", unit: "messages", scope_id: id, availability: "fresh", condition: "unknown", severity: "info", note: "Observation only", count: 6,
|
||||
observed_at: at, evidence_at: at, latest_evidence_at: at, affected_mailboxes: null, affected_organizations: null, unknown_organization_rows: null, evidence_age_seconds: null, next_eligible_at: null, window_start: null, window_end: null, threshold_seconds: null,
|
||||
broker: { consumer_group: group, topics: [topic], partitions: [{ topic, partition: 0, earliest: 10, committed: 16, latest: 22, committed_lag: 6 }] },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("read-only node broker evidence", () => {
|
||||
it("accepts exact retained committed offsets, not send rates or invented member counts", () => {
|
||||
const result = validatedBroker(source(), group, topic);
|
||||
expect(result?.lag).toBe(6);
|
||||
expect(result?.members).toBeNull();
|
||||
});
|
||||
it("keeps validated zero lag distinct from an unsupported observation", () => {
|
||||
const zero = source();
|
||||
zero.metrics[0].count = 0;
|
||||
zero.metrics[0].broker!.partitions[0].committed = 22;
|
||||
zero.metrics[0].broker!.partitions[0].committed_lag = 0;
|
||||
expect(validatedBroker(zero, group, topic)?.lag).toBe(0);
|
||||
expect(validatedBroker({ ...zero, availability: "unavailable", metrics: [], reason: "unsupported" }, group, topic)).toBeNull();
|
||||
});
|
||||
it.each(["stale", "unavailable"] as const)("does not render %s observations as fresh values", (availability) => {
|
||||
expect(validatedBroker({ ...source(), availability }, group, topic)).toBeNull();
|
||||
});
|
||||
it("does not attribute the shared result group to worker commands", () => {
|
||||
expect(validatedBroker(source(), "consumer-group", "jobs.worker-events")).toBeNull();
|
||||
});
|
||||
it.each(["missing", "duplicate", "negative", "out-of-retention", "future-commit", "mismatch", "wrong-topic", "wrong-group", "overflow", "unsafe-offset", "healthy-condition", "wrong-scope", "partial"])("withholds %s data", (invalid) => {
|
||||
const value = source();
|
||||
const metric = value.metrics[0];
|
||||
const detail = metric.broker!;
|
||||
const partition = detail.partitions[0];
|
||||
switch (invalid) {
|
||||
case "missing": metric.count = null; break;
|
||||
case "duplicate": detail.partitions.push({ ...partition }); break;
|
||||
case "negative": partition.partition = -1; break;
|
||||
case "out-of-retention": partition.committed = 9; break;
|
||||
case "future-commit": partition.committed = 23; break;
|
||||
case "mismatch": metric.count = 7; break;
|
||||
case "wrong-topic": partition.topic = "unexpected"; break;
|
||||
case "wrong-group": detail.consumer_group = "consumer-group"; break;
|
||||
case "overflow": detail.partitions = Array.from({ length: 129 }, (_, i) => ({ ...partition, partition: i })); break;
|
||||
case "unsafe-offset": partition.latest = Number.MAX_SAFE_INTEGER + 1; break;
|
||||
case "healthy-condition": metric.condition = "healthy"; break;
|
||||
case "wrong-scope": metric.scope_id = "other"; break;
|
||||
case "partial": value.coverage = "partial"; break;
|
||||
}
|
||||
expect(validatedBroker(value, group, topic)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redacted node evidence and filters", () => {
|
||||
it("only accepts canonical allowlisted event records", () => {
|
||||
const event: NodeLogEvent = { id, event: "sync_control_plane_held", observed_at: at, level: "warn", category: "control_plane", http_status: 503 };
|
||||
expect(safeLogEvent(event)).toBe(true);
|
||||
expect(safeLogEvent({ ...event, event: "raw_provider_message" })).toBe(false);
|
||||
expect(safeLogEvent({ ...event, category: "provider" })).toBe(false);
|
||||
expect(safeLogEvent({ ...event, observed_at: "invalid" })).toBe(false);
|
||||
});
|
||||
it("strips unrelated stored fields and rejects invalid UUID/time ranges", () => {
|
||||
expect(parseLogFilters({ ...emptyLogFilters, level: "warn", secret: "not-stored" })).toEqual({ ...emptyLogFilters, level: "warn" });
|
||||
expect(parseLogFilters({ ...emptyLogFilters, mailbox: "not-a-uuid" })).toBeNull();
|
||||
expect(parseLogFilters({ ...emptyLogFilters, after: "invalid" })).toBeNull();
|
||||
expect(logFilterError({ ...emptyLogFilters, after: "2026-10-10T10:00", before: "2026-10-10T09:00" })).toMatch(/Start time/);
|
||||
expect(logFilterError({ ...emptyLogFilters, mailbox: id, after: "2026-10-10T08:00" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { NodeBrokerSource, NodeLogFilters, NodeLogEvent, BrokerDetail } from "@/lib/api/client/admin/nodeDiagnostics";
|
||||
|
||||
export const emptyLogFilters: NodeLogFilters = { level: "", after: "", before: "", mailbox: "" };
|
||||
export const isUUID = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) && value !== "00000000-0000-0000-0000-000000000000";
|
||||
export const safeCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
||||
export const validTime = (value: unknown): value is string => typeof value === "string" && Number.isFinite(Date.parse(value));
|
||||
|
||||
export function parseLogFilters(value: unknown): NodeLogFilters | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const v = value as NodeLogFilters;
|
||||
if (!["", "info", "warn", "error"].includes(v.level) || typeof v.after !== "string" || typeof v.before !== "string" || v.after.length > 24 || v.before.length > 24 || typeof v.mailbox !== "string" || v.mailbox.length > 36) return null;
|
||||
if (logFilterError(v)) return null;
|
||||
return { level: v.level, after: v.after, before: v.before, mailbox: v.mailbox };
|
||||
}
|
||||
|
||||
export function logFilterError(value: NodeLogFilters): string | null {
|
||||
if (value.mailbox.trim() && !isUUID(value.mailbox.trim())) return "Enter a valid mailbox UUID.";
|
||||
if ((value.after && !validTime(value.after)) || (value.before && !validTime(value.before))) return "Enter valid start and end times.";
|
||||
if (value.after && value.before && Date.parse(value.after) > Date.parse(value.before)) return "Start time must not be after end time.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export const eventLabels: Record<string, { title: string; level: string; category: string }> = {
|
||||
sync_control_plane_held: { title: "Control-plane sync held", level: "warn", category: "control_plane" },
|
||||
mailbox_provider_unreachable: { title: "Mailbox provider unreachable", level: "warn", category: "provider" },
|
||||
mailbox_provider_auth_failed: { title: "Mailbox provider authentication failed", level: "warn", category: "provider" },
|
||||
mailbox_provider_throttled: { title: "Mailbox provider throttled", level: "warn", category: "provider" },
|
||||
mailbox_provider_sync_completed: { title: "Mailbox provider sync completed", level: "info", category: "provider" },
|
||||
sync_folder_skipped: { title: "Sync folder skipped", level: "info", category: "sync_policy" },
|
||||
sync_skipped_folder_search_failed: { title: "Folder search failed; sync skipped", level: "warn", category: "provider" },
|
||||
sync_message_deferred: { title: "Sync message deferred", level: "warn", category: "control_plane" },
|
||||
sync_message_deferred_by_policy: { title: "Sync message deferred by policy", level: "info", category: "sync_policy" },
|
||||
sync_legacy_arrival: { title: "Legacy sync arrival", level: "warn", category: "compatibility" },
|
||||
worker_error: { title: "Coarse worker runtime error", level: "error", category: "worker_runtime" },
|
||||
};
|
||||
|
||||
export function safeLogEvent(event: NodeLogEvent): boolean {
|
||||
const canonical = eventLabels[event.event];
|
||||
return !!canonical && isUUID(event.id) && validTime(event.observed_at) && canonical.level === event.level && canonical.category === event.category;
|
||||
}
|
||||
|
||||
export const diagnosticReason = (reason?: string) => ({
|
||||
log_credential_unavailable: "Log credential unavailable. Existing or manually enrolled nodes need upgrade and re-enrollment.",
|
||||
capture_not_observed: "No capture has been observed.",
|
||||
cache_unavailable: "Evidence cache could not be read.",
|
||||
no_recent_capture: "No recent capture. Older retained evidence is not current health.",
|
||||
no_recent_evidence: "Recent capture contained no retained events. This does not establish health.",
|
||||
allowlisted_evidence_only: "Only allowlisted, redacted events are captured, not the full journal.",
|
||||
unsupported: "This broker does not support committed-offset observations.",
|
||||
dependency_missing: "Broker diagnostics are not configured.",
|
||||
resource_absent: "The requested group or topic could not be observed.",
|
||||
permission_denied: "The broker denied read access.",
|
||||
timeout: "The broker observation timed out.",
|
||||
collection_failed: "The observation could not be validated or collected. Missing commits, mismatched scopes or offsets outside retention remain unknown.",
|
||||
}[reason ?? ""] ?? "Observation unavailable or incomplete.");
|
||||
|
||||
export function validatedBroker(source: NodeBrokerSource, group: string, topic: string): { lag: number; members: number | null; detail: BrokerDetail } | null {
|
||||
if (source.availability !== "fresh" || source.coverage !== "complete" || source.measured_scopes !== 1 || source.expected_scopes !== 1) return null;
|
||||
const scope = group === "consumer-group" ? "worker_events" : group.replace(/^worker-/, "");
|
||||
if (source.id !== scope || !validTime(source.observed_at) || !validTime(source.checked_at)) return null;
|
||||
const metrics = source.metrics ?? [];
|
||||
const lagMetrics = metrics.filter((m) => m.id === "committed_lag");
|
||||
const metric = lagMetrics[0];
|
||||
if (lagMetrics.length !== 1 || !metric || metric.scope_id !== scope || metric.availability !== "fresh" || metric.condition !== "unknown" || !safeCount(metric.count) || !validTime(metric.observed_at)) return null;
|
||||
const detail = metric.broker;
|
||||
if (!detail || detail.consumer_group !== group || detail.topics?.length !== 1 || detail.topics[0] !== topic || !Array.isArray(detail.partitions) || detail.partitions.length < 1 || detail.partitions.length > 128) return null;
|
||||
let total = 0;
|
||||
const seen = new Set<number>();
|
||||
for (const partition of detail.partitions) {
|
||||
if (partition.topic !== topic || !safeCount(partition.partition) || partition.partition > 0x7fffffff || seen.has(partition.partition) ||
|
||||
!safeCount(partition.earliest) || !safeCount(partition.committed) || !safeCount(partition.latest) || !safeCount(partition.committed_lag) ||
|
||||
partition.earliest > partition.committed || partition.committed > partition.latest || partition.latest - partition.committed !== partition.committed_lag) return null;
|
||||
seen.add(partition.partition);
|
||||
total += partition.committed_lag;
|
||||
if (!safeCount(total)) return null;
|
||||
}
|
||||
if (total !== metric.count) return null;
|
||||
const memberMetrics = metrics.filter((m) => m.id === "members");
|
||||
const member = memberMetrics[0];
|
||||
const members = memberMetrics.length === 1 && member?.scope_id === scope && member.availability === "fresh" && member.condition === "unknown" && validTime(member.observed_at) && safeCount(member.count) ? member.count : null;
|
||||
return { lag: total, members, detail };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { RequireAdmin } from "@/components/layout/RequireAdmin";
|
||||
import { RouteError } from "@/components/layout/RouteError";
|
||||
|
||||
import LoginPage from "@/app/auth/LoginPage";
|
||||
import DeviceApprovalPage from "@/app/auth/DeviceApprovalPage";
|
||||
import OverviewPage from "@/app/dashboard/OverviewPage";
|
||||
import WorkersPage from "@/app/dashboard/WorkersPage";
|
||||
import WorkerDetailPage from "@/app/dashboard/WorkerDetailPage";
|
||||
@@ -135,6 +136,7 @@ const router = createBrowserRouter([
|
||||
errorElement: <RouteError />,
|
||||
children: [
|
||||
{ index: true, element: <OverviewPage /> },
|
||||
{ path: "device", element: <DeviceApprovalPage /> },
|
||||
|
||||
// Operations
|
||||
{ path: "workers", element: gated(AdminPerm.ViewWorkers, <WorkersPage />) },
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from "../lib/proof.ts";
|
||||
import { env } from "../lib/env.ts";
|
||||
|
||||
test.use({ signedIn: false, timezoneId: "UTC" });
|
||||
|
||||
const worker = "11111111-1111-4111-8111-111111111111";
|
||||
const consumer = "22222222-2222-4222-8222-222222222222";
|
||||
const mailbox = "33333333-3333-4333-8333-333333333333";
|
||||
const admin = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
// Parent-run only: the worktree's localhost admin Vite origin with VITE_API_URL=QA_API_URL.
|
||||
test("admin CLI consent and redacted node diagnostics with disclosed local response fixtures", async ({ page, proof }) => {
|
||||
test.skip(process.env.QA_ADMIN_OPERATIONS_FIXTURES !== "1", "Set QA_ADMIN_OPERATIONS_FIXTURES=1 on this worktree's localhost admin origin.");
|
||||
for (const origin of [env.webURL, env.apiURL]) {
|
||||
expect(["localhost", "127.0.0.1", "[::1]"].includes(new URL(origin).hostname)).toBe(true);
|
||||
}
|
||||
const at = new Date().toISOString();
|
||||
const started = new Date(Date.now() - 30000).toISOString();
|
||||
const expires = new Date(Date.now() + 5 * 60000).toISOString();
|
||||
const before = new Date(Date.now() + 60000).toISOString().slice(0, 16);
|
||||
let mfa = false;
|
||||
let confirmed = false;
|
||||
let decisions = 0;
|
||||
let descriptions = 0;
|
||||
let unsupportedResults = false;
|
||||
let consumerBrokerReads = 0;
|
||||
const node = (id: string, role: string) => ({ id, role, name: role === "worker" ? "fixture-operations-worker" : "fixture-operations-consumer", notes: "Synthetic UI fixture", region: "local", address: "", capacity_target: 100, version: "fixture", active: true, last_seen_at: at, enrolled_at: started, usage: {}, mailbox_count: role === "worker" ? 1 : 0, created_at: started, updated_at: at });
|
||||
const metric = (scope: string, id: string, value: number) => ({ id, title: `Broker ${id}`, unit: id === "members" ? "consumers" : "messages", scope_id: scope, availability: "fresh", condition: "unknown", severity: "info", note: "Synthetic committed-offset observation, not delivery evidence", count: value, affected_mailboxes: null, affected_organizations: null, unknown_organization_rows: null, observed_at: at, evidence_at: at, latest_evidence_at: at, evidence_age_seconds: null, next_eligible_at: null, window_start: null, window_end: null, threshold_seconds: null });
|
||||
const source = (scope: string, group: string, topic: string, lag: number) => ({ id: scope, availability: "fresh", coverage: "complete", checked_at: at, observed_at: at, measured_scopes: 1, expected_scopes: 1, metrics: [{ ...metric(scope, "committed_lag", lag), broker: { consumer_group: group, topics: [topic], partitions: [{ topic, partition: 0, earliest: 10, committed: 16, latest: 16 + lag, committed_lag: lag }] } }, metric(scope, "members", 1)] });
|
||||
const held = { id: "55555555-5555-4555-8555-555555555555", observed_at: at, event: "sync_control_plane_held", level: "warn", category: "control_plane", mailbox_id: mailbox, http_status: 503, count: 1 };
|
||||
const runtime = { id: "66666666-6666-4666-8666-666666666666", observed_at: at, event: "worker_error", level: "error", category: "worker_runtime", count: 1 };
|
||||
|
||||
await page.route(`${env.apiURL}/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
const headers = { "Cache-Control": "no-store", "Access-Control-Allow-Origin": new URL(env.webURL).origin, "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Allow-Methods": "GET, POST, OPTIONS" };
|
||||
const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", headers, body: JSON.stringify(body) });
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers });
|
||||
if (path === "/v1/auth/config") return json({ captcha: false });
|
||||
if (path === "/v1/auth/me") return json({ id: admin, email: "fixture-admin@example.test", is_admin: true, admin_permissions: (1 << 0) | (1 << 4) | (1 << 11), session_mfa_verified: mfa });
|
||||
if (path === "/v1/auth/reauth") { confirmed = true; return json({ valid_for_seconds: 300 }); }
|
||||
if (path === "/admin/auth/device/describe") {
|
||||
descriptions++;
|
||||
const body = request.postDataJSON() as { user_code: string };
|
||||
if (body.user_code === "JKLM-2345") return json({ error: "expired", message: "Request expired or already consumed", code: "admin_device_expired", request_id: "fixture-expired" }, 404);
|
||||
return json({ request: { client_name: "Local fixture laptop (unverified name)", instance_url: env.apiURL, expires_at: expires, status: "pending", consent_token: "c".repeat(43) }, requested_access: "Your live platform administrator permissions through a separate revocable session", admin_permissions: (1 << 0) | (1 << 4) | (1 << 11) });
|
||||
}
|
||||
if (path === "/admin/auth/device/decide") {
|
||||
decisions++;
|
||||
const body = request.postDataJSON() as { user_code: string; consent_token: string; decision: string };
|
||||
expect(body.user_code).toBe("ABCD-EFGH"); expect(body.consent_token).toBe("c".repeat(43));
|
||||
if (!confirmed) return json({ error: "forbidden", message: "Fresh authentication required", code: "reauth_required", request_id: "fixture-reauth" }, 403);
|
||||
return json({ status: body.decision });
|
||||
}
|
||||
if (path === "/admin/fleet/nodes") return json({ data: [node(worker, "worker"), node(consumer, "consumer")] });
|
||||
if (path === `/admin/workers/${worker}/stats`) return json({ worker_id: worker, total_emails_sent: 0, emails_sent_today: 0, emails_sent_this_week: 0, average_delivery_time_ms: 0, success_rate: 0, queue_depth: 0 });
|
||||
if (path === `/admin/workers/${worker}/emails`) return json({ data: [{ id: mailbox, email: "fixture-mailbox@example.test", provider: "gmail", user_id: admin }], pagination: { has_more: false, total: 1 } });
|
||||
if (path === `/admin/fleet/nodes/${worker}/logs`) {
|
||||
const events = [held, runtime].filter((event) => (!url.searchParams.get("level") || event.level === url.searchParams.get("level")) && (!url.searchParams.get("mailbox_id") || ("mailbox_id" in event && event.mailbox_id === url.searchParams.get("mailbox_id"))) && (!url.searchParams.get("after") || Date.parse(event.observed_at) >= Date.parse(url.searchParams.get("after")!)) && (!url.searchParams.get("before") || Date.parse(event.observed_at) <= Date.parse(url.searchParams.get("before")!)));
|
||||
return json({ node_id: worker, availability: "fresh", coverage: "partial", reason: "allowlisted_evidence_only", observed_at: at, capture: { protocol: 1, run_id: "77777777-7777-4777-8777-777777777777", started_at: started, observed_at: at, received_at: at, dropped: 3 }, events, oldest_retained_at: at, newest_retained_at: at, retained_count: 2, truncated: false, retention_seconds: 3600, max_events: 1000 });
|
||||
}
|
||||
if (path === `/admin/fleet/nodes/${consumer}/logs`) return json({ node_id: consumer, availability: "unavailable", coverage: "unavailable", reason: "log_credential_unavailable", observed_at: at, events: [], retained_count: 0, truncated: false, retention_seconds: 3600, max_events: 1000 });
|
||||
if (path === `/admin/fleet/nodes/${consumer}/broker`) { consumerBrokerReads++; return json({ code: "bad_request", message: "Worker required" }, 400); }
|
||||
if (path === `/admin/fleet/nodes/${worker}/broker`) return json({ node_id: worker, observed_at: at, commands: source(worker, `worker-${worker}`, `w.${worker}`, 6), results: unsupportedResults ? { id: "worker_events", availability: "unavailable", coverage: "unavailable", reason: "unsupported", checked_at: at, observed_at: at, measured_scopes: null, expected_scopes: null, metrics: [] } : source("worker_events", "consumer-group", "jobs.worker-events", 3), note: "Shared results are not this worker's delivery count; lag is not outage evidence." });
|
||||
if (path === "/admin/instance/health") return json({ checks: [], checked_at: at });
|
||||
if (path === "/admin/instance/monitoring") return json({ version: "fixture", checked_at: at, refresh_after: expires, coverage: "unavailable", sources: [] });
|
||||
return json({ code: "service_unavailable", message: "Not included in the local UI fixture", request_id: "fixture-only" }, 503);
|
||||
});
|
||||
|
||||
await page.goto("/device?user_code=ABCD-EFGH&approve=true");
|
||||
await expect(page).toHaveURL(/\/auth\/login$/);
|
||||
await expect(page.getByRole("heading", { name: "Sign in to Warmbly", exact: true })).toBeVisible();
|
||||
expect(decisions).toBe(0); expect(descriptions).toBe(0);
|
||||
await proof.chapter("Native sign-in boundary", "Local response fixtures only. This flow does not prove actual MFA, a real CLI session, Redis retention or live Kafka observations.");
|
||||
await proof.shot("admin-cli-sign-in-boundary", { caption: "Unauthenticated /device redirects to the unchanged login screen. Synthetic fixtures are used throughout; no production credentials or real device secrets." });
|
||||
|
||||
await page.addInitScript(() => localStorage.setItem("warmbly_admin_token", JSON.stringify({ access_token: "fixture-not-a-credential", refresh_token: "fixture-not-a-credential", access_token_expires_at: "2099-01-01T00:00:00Z", refresh_token_expires_at: "2099-01-01T00:00:00Z" })));
|
||||
await page.goto("/device");
|
||||
await expect(page.getByRole("heading", { name: "Two-factor authentication required", exact: true })).toBeVisible();
|
||||
expect(descriptions).toBe(0);
|
||||
mfa = true;
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Review request", exact: true })).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "User code", exact: true }).fill("ABCD-EFGH");
|
||||
await page.getByRole("button", { name: "Review request", exact: true }).click();
|
||||
await expect(page.getByText("Pending explicit approval", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(env.apiURL, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Local fixture laptop (unverified name)", { exact: true })).toBeVisible();
|
||||
expect(decisions).toBe(0);
|
||||
await proof.chapter("Review is not approval", "Synthetic client/instance/access/expiry response. Opening the link, passing the MFA guard fixture and reviewing do not authorize the CLI.");
|
||||
await proof.shot("admin-cli-pending-consent", { caption: "Disclosed local fixture: explicit pending consent shows instance, requester-supplied client name, administrator capability and expiry. No automatic decision." });
|
||||
await page.getByRole("button", { name: "Approve CLI sign-in", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Confirm identity", exact: true }).click();
|
||||
const confirmation = page.getByRole("alertdialog");
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByPlaceholder("Two-factor or recovery code", { exact: true }).fill("123456");
|
||||
await confirmation.getByRole("button", { name: "Confirm", exact: true }).click();
|
||||
await expect(confirmation).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Approve CLI sign-in", exact: true })).toBeEnabled();
|
||||
expect(decisions).toBe(1);
|
||||
await page.getByRole("button", { name: "Approve CLI sign-in", exact: true }).click();
|
||||
await expect(page.getByText("Request approved", { exact: true })).toBeVisible();
|
||||
expect(decisions).toBe(2);
|
||||
await proof.shot("admin-cli-explicit-approved", { caption: "Synthetic decision and reauth responses, not real MFA or session issuance. Native identity confirmation alone never retries approval; a second explicit click is required." });
|
||||
|
||||
await page.getByRole("button", { name: "Enter another code", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "User code", exact: true }).fill("ABCD-EFGH");
|
||||
await page.getByRole("button", { name: "Review request", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Deny request", exact: true }).click();
|
||||
await expect(page.getByText("Request denied", { exact: true })).toBeVisible();
|
||||
await proof.shot("admin-cli-explicit-denied", { caption: "Local decision fixture: explicit denial resolves the request without returning credentials." });
|
||||
await page.getByRole("button", { name: "Enter another code", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "User code", exact: true }).fill("JKLM-2345");
|
||||
await page.getByRole("button", { name: "Review request", exact: true }).click();
|
||||
await expect(page.getByText("Request expired or unavailable", { exact: true })).toBeVisible();
|
||||
await proof.shot("admin-cli-expired-request", { caption: "Local 404 admin_device_expired fixture keeps unknown/expired/consumed codes fail-closed." });
|
||||
|
||||
await page.goto(`/workers/${worker}`);
|
||||
const logs = page.locator("section").filter({ has: page.getByRole("heading", { name: "Recent redacted node logs", exact: true }) });
|
||||
await logs.getByRole("heading").scrollIntoViewIfNeeded();
|
||||
await expect(logs.getByRole("table", { name: "Redacted node events", exact: true })).toBeVisible();
|
||||
await expect(logs.getByText("Control-plane sync held", { exact: true })).toBeVisible();
|
||||
await expect(logs).toContainText("HTTP 503"); await expect(logs).toContainText("Partial coverage"); await expect(logs).toContainText("per process run");
|
||||
await expect(logs.getByRole("link", { name: mailbox, exact: true })).toHaveAttribute("href", new RegExp("/mailboxes\\?q=fixture-mailbox%40example.test"));
|
||||
await proof.chapter("Redacted operational evidence", "All node capture/log/offset responses are synthetic localhost fixtures. They do not establish production sampling, provider health, delivery or the cause of an arrival 503.");
|
||||
await logs.getByRole("table", { name: "Redacted node events", exact: true }).scrollIntoViewIfNeeded();
|
||||
await proof.shot("admin-node-redacted-evidence", { caption: "Synthetic capture and allowlisted events: timestamps, partial coverage, retention bounds, three known per-run drops, numeric control-plane HTTP 503 and native mailbox link." });
|
||||
await logs.getByRole("combobox", { name: "Log level", exact: true }).click();
|
||||
await page.getByRole("option", { name: "Warn", exact: true }).click();
|
||||
await logs.getByRole("textbox", { name: "Mailbox UUID", exact: true }).fill(mailbox);
|
||||
await logs.getByLabel("Before (local time)", { exact: true }).fill(before);
|
||||
await expect(logs.getByText("Coarse worker runtime error", { exact: true })).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(logs.getByRole("textbox", { name: "Mailbox UUID", exact: true })).toHaveValue(mailbox);
|
||||
await expect(logs.getByLabel("Before (local time)", { exact: true })).toHaveValue(before);
|
||||
await expect(logs.getByRole("combobox", { name: "Log level", exact: true })).toContainText("Warn");
|
||||
await logs.getByRole("heading").scrollIntoViewIfNeeded();
|
||||
await proof.shot("admin-node-persistent-filters", { caption: "Local fixture filters survive refresh under the current admin and node. Window counts cover all retained events, not just the filtered subset." });
|
||||
|
||||
const broker = page.locator("section").filter({ has: page.getByRole("heading", { name: "Broker committed-offset observations", exact: true }) });
|
||||
await broker.getByRole("heading").scrollIntoViewIfNeeded();
|
||||
await expect(broker).toContainText("Worker command scope"); await expect(broker).toContainText("Shared result-consumer scope");
|
||||
await expect(broker).toContainText("6 messages"); await expect(broker).toContainText("3 messages");
|
||||
await expect(broker).toContainText("consumer-group"); await expect(broker).toContainText("jobs.worker-events");
|
||||
await broker.getByText("Validated partition offsets", { exact: true }).first().click();
|
||||
await expect(broker.getByRole("table", { name: "Worker command scope partition offsets", exact: true })).toBeVisible();
|
||||
await proof.shot("admin-node-separate-broker-scopes", { caption: "Synthetic retained offsets earliest 10 / committed 16 / latest 22 yield command lag 6. Shared result lag 3 is separate, never added, attributed to this worker or labeled an outage/send rate." });
|
||||
unsupportedResults = true;
|
||||
await broker.getByRole("button", { name: "Refresh broker", exact: true }).click();
|
||||
await expect(broker).toContainText("This broker does not support"); await expect(broker).toContainText("Unknown / unavailable");
|
||||
await proof.shot("admin-node-unavailable-broker", { caption: "Synthetic unsupported shared-result observation remains unknown/unavailable while independent worker command evidence stays visible. No fabricated zero lag or healthy state." });
|
||||
|
||||
await page.goto(`/workers/${consumer}`);
|
||||
await expect(logs.getByText("Log evidence unavailable", { exact: true })).toBeVisible();
|
||||
await expect(logs).toContainText("upgrade and re-enrollment");
|
||||
await expect(broker).toContainText("Command-group broker observations require a worker node");
|
||||
expect(consumerBrokerReads).toBe(0);
|
||||
await logs.getByRole("heading").scrollIntoViewIfNeeded();
|
||||
await proof.shot("admin-consumer-generic-evidence", { caption: "Local older-enrollment consumer fixture: generic log capture unavailable, retained event count unknown, no worker-command query and no inferred consumer lag." });
|
||||
await page.goto(`/workers/${worker}`);
|
||||
await expect(logs.getByRole("textbox", { name: "Mailbox UUID", exact: true })).toHaveValue(mailbox);
|
||||
await expect(logs.getByRole("combobox", { name: "Log level", exact: true })).toContainText("Warn");
|
||||
});
|
||||
Reference in New Issue
Block a user