diff --git a/.env.example b/.env.example index 1c279619..ffdca1c1 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,14 @@ EMAIL_FROM=Livepeer Waitlist EMAIL_REPLY_TO=help@example.com INTERNAL_OUTBOX_SECRET=replace-with-at-least-32-random-bytes NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# Required in deployed environments. Generate a high-entropy secret and keep it +# stable so one-hour first-party media links remain valid across instances. +ASSET_URL_SIGNING_SECRET=replace-with-at-least-32-random-bytes +# Exact hosts or wildcard subdomains, comma-separated. Redirects are rechecked. +ASSET_PROXY_ALLOWED_HOSTS=fal.media,*.fal.media +# Preview-only, branch-scoped verification records. Never enable in production. +CONSOLE_PREVIEW_FIXTURES=0 +NEXT_PUBLIC_CONSOLE_PREVIEW_FIXTURES=0 # Publishable token; analytics safely no-ops when unset. NEXT_PUBLIC_POSTHOG_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d71c301e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Drizzle Kit writes a full schema dump per migration, not a delta. +# Keep the files for generate/migrate; collapse them in GitHub review. +drizzle-baseline/meta/*_snapshot.json linguist-generated=true +drizzle/meta/*_snapshot.json linguist-generated=true diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 0612d65b..8d859bec 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,5 +1,4 @@ import { redirect } from "next/navigation"; -import { identitySyncPath } from "@/lib/identity/sync-return"; export const dynamic = "force-dynamic"; @@ -11,5 +10,5 @@ export default async function RootPage({ const params = await searchParams; if (params.ref?.trim()) redirect(`/waitlist?ref=${encodeURIComponent(params.ref.trim())}`); - redirect(identitySyncPath("/home")); + redirect("/home"); } diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 5eb98f86..c671c992 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { getAuthenticatedIdentity } from "@/lib/authentication/session"; import { authLoginHref, safeReturnTo } from "@/lib/console/auth-login"; import LoginPage from "@/components/console/LoginPage"; -import { identitySyncPath } from "@/lib/identity/sync-return"; +import { signedInLandingPath } from "@/lib/identity/signed-in-landing"; import type { Metadata } from "next"; @@ -26,7 +26,9 @@ export default async function LoginRoute({ const identity = await getAuthenticatedIdentity(); if (identity) - redirect(identitySyncPath(mcpOauth ? MCP_CALLBACK_PATH : returnTo)); + redirect( + await signedInLandingPath(mcpOauth ? MCP_CALLBACK_PATH : returnTo) + ); // MCP flow must go directly to Auth0 — no interactive UI step. if (mcpOauth) { diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 27c41176..6c3790fa 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import { getAuthenticatedIdentity } from "@/lib/authentication/session"; import { safeReturnTo } from "@/lib/console/auth-login"; import LoginPage from "@/components/console/LoginPage"; -import { identitySyncPath } from "@/lib/identity/sync-return"; +import { signedInLandingPath } from "@/lib/identity/signed-in-landing"; export const metadata: Metadata = { title: "Sign up — Livepeer Early Access", @@ -17,6 +17,6 @@ export default async function SignupRoute({ const params = await searchParams; const returnTo = safeReturnTo(params.returnTo); const identity = await getAuthenticatedIdentity(); - if (identity) redirect(identitySyncPath(returnTo)); + if (identity) redirect(await signedInLandingPath(returnTo)); return ; } diff --git a/app/api/admin/runs/[id]/route.ts b/app/api/admin/runs/[id]/route.ts index c2fa3724..2f1acd3d 100644 --- a/app/api/admin/runs/[id]/route.ts +++ b/app/api/admin/runs/[id]/route.ts @@ -1,6 +1,7 @@ import { getAdminPrincipal } from "@/lib/admin/auth"; import { getAdminRun } from "@/lib/runs/store"; import { runError, RUN_HEADERS } from "@/lib/runs/http"; +import { publicRunDetail } from "@/lib/assets/public"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET( @@ -17,7 +18,7 @@ export async function GET( const { id } = await context.params; const result = await getAdminRun(actor, id); if (!result) throw new Error("run_not_found"); - return Response.json(result, { headers: RUN_HEADERS }); + return Response.json(publicRunDetail(result), { headers: RUN_HEADERS }); } catch (error) { return runError(error); } diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts new file mode 100644 index 00000000..0da51601 --- /dev/null +++ b/app/api/assets/[id]/route.ts @@ -0,0 +1,262 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { lookup } from "node:dns/promises"; +import { fetchPinnedAsset } from "@/lib/assets/transport"; +import { isIP } from "node:net"; +import { assetSignature, ASSET_URL_TTL_SECONDS } from "@/lib/assets/public"; +import { getAssetSource } from "@/lib/mcp/store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const FORWARDED_HEADERS = [ + "accept-ranges", + "content-length", + "content-range", + "etag", + "last-modified", +] as const; + +const MEDIA_CONTENT_TYPE = /^(?:image|video|audio)\/[a-z0-9.+-]+$/i; + +function isPlayableMediaType(type: string): boolean { + return MEDIA_CONTENT_TYPE.test(type) && !/^image\/svg\b/i.test(type); +} + +/** Provider media types pass through. HTML/JS/SVG never do; Chrome's player cannot sandbox a video document. */ +function mediaContentType( + upstream: string | null, + stored: string | null +): string | null { + const offered = (upstream ?? "").split(";")[0]!.trim().toLowerCase(); + if (isPlayableMediaType(offered)) return offered; + if (offered && offered !== "application/octet-stream") return null; + const kind = (stored ?? "").trim().toLowerCase(); + if (isPlayableMediaType(kind)) return kind; + if (kind === "video") return "video/mp4"; + if (kind === "image") return "image/jpeg"; + if (kind === "audio") return "audio/mpeg"; + return null; +} + +function notFound(): Response { + return new Response("Not found", { + status: 404, + headers: { "cache-control": "private, no-store" }, + }); +} + +function isPrivateIp(address: string): boolean { + let normalized = address.toLowerCase(); + if (normalized.startsWith("::ffff:")) { + normalized = normalized.slice(7); + if (!normalized.includes(".")) { + const words = normalized.split(":").map((word) => parseInt(word, 16)); + if (words.length !== 2 || words.some((word) => !Number.isFinite(word))) + return true; + normalized = [ + words[0]! >> 8, + words[0]! & 255, + words[1]! >> 8, + words[1]! & 255, + ].join("."); + } + } + if (isIP(normalized) === 4) { + const [a, b] = normalized.split(".").map(Number); + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 198 && (b === 18 || b === 19)) || + (a === 192 && b === 0) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a >= 224 + ); + } + return ( + isIP(normalized) !== 6 || + !/^[23]/.test(normalized) || + normalized.startsWith("2001:db8:") || + normalized.startsWith("2001:0:") || + normalized.startsWith("2001::") || + normalized.startsWith("2002:") || + normalized === "::" || + normalized === "::1" || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + /^fe[89ab]/.test(normalized) + ); +} + +function allowedHosts(): string[] { + const configured = process.env.ASSET_PROXY_ALLOWED_HOSTS?.trim(); + if (configured) + return configured + .split(",") + .map((host) => host.trim().toLowerCase()) + .filter(Boolean); + if (process.env.NODE_ENV !== "production") + return ["fal.media", "*.fal.media", "media.example.test"]; + throw new Error("ASSET_PROXY_ALLOWED_HOSTS is required"); +} + +function isAllowedHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return allowedHosts().some((rule) => + rule.startsWith("*.") + ? host.endsWith(rule.slice(1)) && host !== rule.slice(2) + : host === rule + ); +} + +async function assertPublicHttps(raw: string) { + const url = new URL(raw); + if ( + url.protocol !== "https:" || + url.port || + url.username || + url.password || + !isAllowedHost(url.hostname) + ) + throw new Error("unsafe_asset_origin"); + const addresses = await lookup(url.hostname, { all: true, verbatim: true }); + if ( + !addresses.length || + addresses.some(({ address }) => isPrivateIp(address)) + ) + throw new Error("unsafe_asset_origin"); + return { url, addresses }; +} + +function validSignature( + id: string, + principalId: string, + exp: number, + supplied: string +): boolean { + const expected = Buffer.from(assetSignature(id, principalId, exp)); + const actual = Buffer.from(supplied); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +async function proxy(request: Request, id: string): Promise { + if (!/^[A-Za-z0-9_-]{1,160}$/.test(id)) return notFound(); + const requestUrl = new URL(request.url); + const expText = requestUrl.searchParams.get("exp") ?? ""; + const sig = requestUrl.searchParams.get("sig") ?? ""; + const exp = Number(expText); + const nowSeconds = Math.floor(Date.now() / 1000); + if ( + !/^\d{10,}$/.test(expText) || + !Number.isSafeInteger(exp) || + exp <= nowSeconds || + exp > nowSeconds + ASSET_URL_TTL_SECONDS + ) + return notFound(); + + const asset = await getAssetSource(id); + if ( + !asset || + asset.unavailableAt || + !validSignature(id, asset.principalId, exp, sig) || + (asset.expiresAt && asset.expiresAt.getTime() <= Date.now()) + ) + return notFound(); + + // Synthetic fixtures are bundled public images, never arbitrary proxy origins. + if ( + process.env.VERCEL_ENV === "preview" && + process.env.CONSOLE_PREVIEW_FIXTURES === "1" + ) { + const suffix = createHash("sha256") + .update(asset.principalId) + .digest("hex") + .slice(0, 12); + const fixturePaths: Record = { + [`asset_preview_v2_${suffix}_portrait`]: + "/images/console/explore/flux-schnell.webp", + [`asset_preview_v2_${suffix}_variation`]: + "/images/console/explore/img2img-sdxl.webp", + }; + const location = fixturePaths[id]; + if (location) + return new Response(null, { + status: 307, + headers: { location, "cache-control": "private, no-store" }, + }); + } + + try { + let target = await assertPublicHttps(asset.url); + let upstream: Response | undefined; + for (let redirects = 0; redirects <= 3; redirects += 1) { + upstream = await fetchPinnedAsset(target.url, target.addresses, { + method: request.method, + signal: AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]), + headers: { + Accept: request.headers.get("accept") ?? "*/*", + "Accept-Encoding": "identity", + ...(request.headers.get("range") + ? { Range: request.headers.get("range")! } + : {}), + }, + }); + if (![301, 302, 303, 307, 308].includes(upstream.status)) break; + const location = upstream.headers.get("location"); + await upstream.body?.cancel(); + if (!location || redirects === 3) throw new Error("asset_redirect"); + target = await assertPublicHttps(new URL(location, target.url).href); + } + if (!upstream) throw new Error("asset_unavailable"); + const providerSeconds = asset.expiresAt + ? Math.max(0, Math.floor((asset.expiresAt.getTime() - Date.now()) / 1000)) + : Number.POSITIVE_INFINITY; + const maxAge = Math.max( + 0, + Math.min(60, exp - Math.floor(Date.now() / 1000), providerSeconds) + ); + const type = mediaContentType( + upstream.headers.get("content-type"), + asset.mediaType + ); + const headers = new Headers({ + "cache-control": upstream.ok + ? `private, max-age=${maxAge}` + : "private, no-store", + "x-content-type-options": "nosniff", + ...(type + ? { "content-type": type } + : { "content-security-policy": "default-src 'none'; sandbox" }), + }); + for (const name of FORWARDED_HEADERS) { + const value = upstream.headers.get(name); + if (value) headers.set(name, value); + } + return new Response(request.method === "HEAD" ? null : upstream.body, { + status: upstream.status, + headers, + }); + } catch { + return new Response("Asset unavailable", { + status: 502, + headers: { "cache-control": "private, no-store" }, + }); + } +} + +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> } +) { + return proxy(request, (await context.params).id); +} + +export async function HEAD( + request: Request, + context: { params: Promise<{ id: string }> } +) { + return proxy(request, (await context.params).id); +} diff --git a/app/api/console/runs/[id]/route.ts b/app/api/console/runs/[id]/route.ts index 10b55901..cf58217d 100644 --- a/app/api/console/runs/[id]/route.ts +++ b/app/api/console/runs/[id]/route.ts @@ -1,5 +1,6 @@ import { getOwnRun } from "@/lib/runs/store"; import { requireRunOwner, runError, RUN_HEADERS } from "@/lib/runs/http"; +import { publicRunDetail } from "@/lib/assets/public"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET( @@ -11,7 +12,7 @@ export async function GET( const { id } = await context.params; const result = await getOwnRun(owner, id); if (!result) throw new Error("run_not_found"); - return Response.json(result, { headers: RUN_HEADERS }); + return Response.json(publicRunDetail(result), { headers: RUN_HEADERS }); } catch (error) { return runError(error); } diff --git a/app/api/console/runs/[id]/schema/route.ts b/app/api/console/runs/[id]/schema/route.ts new file mode 100644 index 00000000..3f236035 --- /dev/null +++ b/app/api/console/runs/[id]/schema/route.ts @@ -0,0 +1,27 @@ +import { getOwnRun } from "@/lib/runs/store"; +import { requireRunOwner, runError, RUN_HEADERS } from "@/lib/runs/http"; +import { + loadFalInputSchema, + resolveFalCatalogEntry, +} from "@/lib/mcp/fal-input-schema"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> } +) { + try { + const owner = await requireRunOwner(); + const result = await getOwnRun(owner, (await context.params).id); + if (!result) throw new Error("run_not_found"); + const catalog = resolveFalCatalogEntry(result); + return Response.json( + { inputSchema: catalog ? await loadFalInputSchema(catalog) : null }, + { headers: RUN_HEADERS } + ); + } catch (error) { + return runError(error); + } +} diff --git a/app/api/console/runs/billing-sync/route.ts b/app/api/console/runs/billing-sync/route.ts new file mode 100644 index 00000000..ee7ddf83 --- /dev/null +++ b/app/api/console/runs/billing-sync/route.ts @@ -0,0 +1,39 @@ +import { requireConsoleSession } from "@/lib/console/session-user"; +import { refreshOwnedRunBilling } from "@/lib/runs/manifest-billing"; +import { resolveRunOwner } from "@/lib/runs/store"; +import { runError, RUN_HEADERS } from "@/lib/runs/http"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { runIds?: unknown }; + if ( + !Array.isArray(body.runIds) || + body.runIds.length > 50 || + body.runIds.some((id) => typeof id !== "string" || !id || id.length > 160) + ) + throw new Error("invalid_run_query"); + const session = await requireConsoleSession(); + const owner = await resolveRunOwner(session.externalUserId); + if (owner.userId !== session.canonicalUserId) + throw new Error("run_owner_mismatch"); + const result = await refreshOwnedRunBilling({ + owner, + externalUserId: session.externalUserId, + email: session.email, + runIds: body.runIds, + }); + return Response.json( + { + changedRunIds: result.changedRunIds, + changedCount: result.changedRunIds.length, + pending: result.pending, + }, + { headers: RUN_HEADERS } + ); + } catch (error) { + return runError(error); + } +} diff --git a/app/api/console/runs/preview-fixtures/route.ts b/app/api/console/runs/preview-fixtures/route.ts new file mode 100644 index 00000000..2aff4615 --- /dev/null +++ b/app/api/console/runs/preview-fixtures/route.ts @@ -0,0 +1,22 @@ +import { requireRunOwner, runError, RUN_HEADERS } from "@/lib/runs/http"; +import { + ensurePreviewRunFixtures, + previewFixturesEnabled, +} from "@/lib/runs/preview-fixtures"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + if (!previewFixturesEnabled()) + return new Response("Not found", { status: 404 }); + const owner = await requireRunOwner(); + return Response.json( + await ensurePreviewRunFixtures(owner, new URL(request.url).origin), + { headers: RUN_HEADERS } + ); + } catch (error) { + return runError(error); + } +} diff --git a/app/api/console/runs/route.ts b/app/api/console/runs/route.ts index 9c5181ef..74d362f6 100644 --- a/app/api/console/runs/route.ts +++ b/app/api/console/runs/route.ts @@ -1,4 +1,8 @@ import { listOwnRuns } from "@/lib/runs/store"; +import { + ensurePreviewRunFixtures, + previewFixturesEnabled, +} from "@/lib/runs/preview-fixtures"; import { parseRunQuery, requireRunOwner, @@ -10,7 +14,13 @@ export const dynamic = "force-dynamic"; export async function GET(request: Request) { try { const owner = await requireRunOwner(); - return Response.json(await listOwnRuns(owner, parseRunQuery(request.url)), { + const preview = previewFixturesEnabled(); + if (preview) + await ensurePreviewRunFixtures(owner, new URL(request.url).origin); + const page = await listOwnRuns(owner, parseRunQuery(request.url), { + excludeLegacyPreview: preview, + }); + return Response.json(page, { headers: RUN_HEADERS, }); } catch (error) { diff --git a/app/api/identity/sync/route.ts b/app/api/identity/sync/route.ts deleted file mode 100644 index af0514ac..00000000 --- a/app/api/identity/sync/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuthenticatedIdentity } from "@/lib/authentication/session"; -import { resolveProviderIdentity } from "@/lib/identity/provider-user"; -import { enrollAuthenticatedUser } from "@/lib/access/enrollment"; -import { - safeIdentityReturnTo, - isProtocolReturnPath, -} from "@/lib/identity/sync-return"; -import { getAccessDecision } from "@/lib/access/service"; -import { getAdminPrincipalForUser } from "@/lib/admin/permissions"; -import { waitlistReturnPath } from "@/lib/waitlist/return-path"; -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -export async function GET(request: NextRequest) { - // In-flight transactions from the retired Auth0 waitlist CTA return to the - // email form. Auth0 remains Console's authentication authority. - if (request.nextUrl.searchParams.get("from") === "waitlist") - return NextResponse.redirect( - new URL(waitlistReturnPath(request.nextUrl.searchParams), request.url) - ); - const returnTo = safeIdentityReturnTo( - request.nextUrl.searchParams.get("returnTo") - ); - const identity = await getAuthenticatedIdentity(); - if (!identity) { - const login = new URL("/login", request.url); - login.searchParams.set("returnTo", returnTo); - return NextResponse.redirect(login); - } - let destination = "/access-pending"; - try { - const canonical = await resolveProviderIdentity(identity); - await enrollAuthenticatedUser(identity, canonical); - const decision = await getAccessDecision(canonical.userId); - if (decision.state === "approved") - destination = (await getAdminPrincipalForUser(canonical.userId)) - ? "/admin" - : "/home"; - if (returnTo === "/waitlist") destination = "/waitlist"; - } catch (error) { - console.error("identity_sync_failed", { - errorType: error instanceof Error ? error.name : "unknown", - }); - } - return NextResponse.redirect( - new URL( - isProtocolReturnPath(returnTo) ? returnTo : destination, - request.url - ) - ); -} diff --git a/app/api/pymthouse/account-requests/route.ts b/app/api/pymthouse/account-requests/route.ts index f79d2150..c55fd49b 100644 --- a/app/api/pymthouse/account-requests/route.ts +++ b/app/api/pymthouse/account-requests/route.ts @@ -4,12 +4,12 @@ import { fetchAccountRequestsForExternalUser } from "@/lib/console/pymthouse-bff import { requireConsoleSession } from "@/lib/console/session-user"; import { AccessError } from "@/lib/access/service"; import { configuredPymthouseScope } from "@/lib/external-accounts/service"; +import { sanitizeBillingReceipt } from "@/lib/console/billing-receipts"; import { existingRunGatewayIds, recordRunUsage, resolveRunOwner, } from "@/lib/runs/store"; -import type { JsonValue } from "@/lib/runs/types"; import { PYMTHOUSE_NO_STORE_HEADERS, pymthouseErrorResponse, @@ -53,6 +53,7 @@ export async function GET(request: NextRequest) { email: session.email, cursor: next, limit, + ...(includeCorrelated ? { recentWindow: true } : {}), }); if ( payload.externalUserId !== session.externalUserId || @@ -66,42 +67,14 @@ export async function GET(request: NextRequest) { ); await recordRunUsage( owner, - scoped - .map((item) => { - const metadata: Record = {}; - for (const key of [ - "networkFeeUsdMicros", - "feeWei", - "ethUsdPrice", - "pixels", - ] as const) { - const value = item[key]; - if ( - typeof value === "string" && - value.length <= 128 && - /^\d+(?:\.\d+)?$/.test(value) - ) - metadata[key] = value; - } - return { - eventId: item.eventId, - gatewayRequestId: item.gatewayRequestId, - metadata, - }; - }) - .filter( - (ticket) => - typeof ticket.eventId === "string" && - ticket.eventId.length > 0 && - ticket.eventId.length <= 512 && - typeof ticket.gatewayRequestId === "string" && - ticket.gatewayRequestId.length > 0 && - ticket.gatewayRequestId.length <= 512 - ) + scoped.map(sanitizeBillingReceipt).filter((ticket) => ticket !== null) ); if (includeCorrelated) { return NextResponse.json( - { ...payload, items: scoped }, + { + ...payload, + items: await attachOutputsToTickets(session.externalUserId, scoped), + }, { headers: PYMTHOUSE_NO_STORE_HEADERS } ); } diff --git a/app/api/pymthouse/account-usage/route.ts b/app/api/pymthouse/account-usage/route.ts index fb6938df..eb10447c 100644 --- a/app/api/pymthouse/account-usage/route.ts +++ b/app/api/pymthouse/account-usage/route.ts @@ -5,6 +5,10 @@ import { PYMTHOUSE_NO_STORE_HEADERS, pymthouseErrorResponse, } from "@/app/api/pymthouse/route-helpers"; +import { + previewAccountUsage, + previewFixturesEnabled, +} from "@/lib/runs/preview-fixtures"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -37,6 +41,17 @@ export async function GET(request: NextRequest) { try { const session = await requireConsoleSession(); + if (previewFixturesEnabled()) { + return NextResponse.json( + previewAccountUsage({ + externalUserId: session.externalUserId, + periodDays, + window, + includePrior, + }), + { headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } const payload = await fetchAccountUsageForExternalUser({ externalUserId: session.externalUserId, periodDays, diff --git a/app/layout.tsx b/app/layout.tsx index cd0eed2c..d613f9b9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import type { CSSProperties } from "react"; import { Toaster } from "sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; const SITE_TITLE = "Livepeer Early Access"; @@ -62,7 +63,7 @@ export default function RootLayout({ ", { + headers: { "content-type": "image/svg+xml" }, + }) + ); + const fromUpstream = await GET(signedRequest(), context); + expect(fromUpstream.headers.get("content-type")).not.toBe("image/svg+xml"); + expect(fromUpstream.headers.get("content-security-policy")).toContain( + "sandbox" + ); + + vi.mocked(getAssetSource).mockResolvedValue({ + ...source, + mediaType: "image/svg+xml", + }); + vi.mocked(fetchPinnedAsset).mockImplementation( + async () => + new Response("", { + headers: { "content-type": "application/octet-stream" }, + }) + ); + const fromStored = await GET(signedRequest(), context); + expect(fromStored.headers.get("content-type")).not.toBe("image/svg+xml"); + expect(fromStored.headers.get("content-security-policy")).toContain( + "sandbox" + ); +}); + +it("serves only owner-bound synthetic fixtures without widening the proxy host allowlist", async () => { + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("CONSOLE_PREVIEW_FIXTURES", "1"); + try { + const suffix = createHash("sha256") + .update("eu_test") + .digest("hex") + .slice(0, 12); + const id = `asset_preview_v2_${suffix}_portrait`; + vi.mocked(getAssetSource).mockResolvedValue({ + ...source, + url: "https://old-preview.example/images/console/explore/flux-schnell.webp", + }); + vi.mocked(lookup).mockClear(); + vi.mocked(fetchPinnedAsset).mockClear(); + const response = await GET(new Request(publicAssetUrl(id, "eu_test")), { + params: Promise.resolve({ id }), + }); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "/images/console/explore/flux-schnell.webp" + ); + expect(lookup).not.toHaveBeenCalled(); + expect(fetchPinnedAsset).not.toHaveBeenCalled(); + vi.mocked(getAssetSource).mockResolvedValue({ + ...source, + unavailableAt: new Date(), + }); + expect( + ( + await GET(new Request(publicAssetUrl(id, "eu_test")), { + params: Promise.resolve({ id }), + }) + ).status + ).toBe(404); + vi.stubEnv("VERCEL_ENV", "production"); + vi.mocked(getAssetSource).mockResolvedValue({ + ...source, + url: "https://old-preview.example/file", + }); + expect( + ( + await GET(new Request(publicAssetUrl(id, "eu_test")), { + params: Promise.resolve({ id }), + }) + ).status + ).toBe(502); + } finally { + vi.unstubAllEnvs(); + } +}); diff --git a/tests/contracts/asset-transport.test.ts b/tests/contracts/asset-transport.test.ts new file mode 100644 index 00000000..21b3e1e0 --- /dev/null +++ b/tests/contracts/asset-transport.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import type { TLSSocket } from "node:tls"; +import { expect, it, vi } from "vitest"; + +const harness = vi.hoisted(() => ({ ca: "" })); +vi.mock("node:https", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + request: vi.fn((url, options, callback) => + original.request(url, { ...options, ca: harness.ca }, callback) + ), + }; +}); +import { createServer, request } from "node:https"; +import { fetchPinnedAsset } from "@/lib/assets/transport"; + +it("pins the connection while preserving TLS hostname, range, HEAD, and stream cancellation", async () => { + const dir = mkdtempSync(join(tmpdir(), "asset-transport-")); + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + join(dir, "key.pem"), + "-out", + join(dir, "cert.pem"), + "-days", + "1", + "-subj", + "/CN=media.example.test", + "-addext", + "subjectAltName=DNS:media.example.test", + ], + { stdio: "ignore" } + ); + harness.ca = readFileSync(join(dir, "cert.pem"), "utf8"); + let host = "", + servername = "", + range = ""; + const server = createServer( + { key: readFileSync(join(dir, "key.pem")), cert: harness.ca }, + (req, res) => { + host = req.headers.host!; + servername = (req.socket as TLSSocket).servername || ""; + range = req.headers.range || ""; + if (req.url === "/slow") { + res.writeHead(200); + res.write("start"); + return; + } + res.writeHead(req.headers.range ? 206 : 200, { + "content-type": "video/mp4", + }); + res.end("bytes"); + } + ); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const url = new URL(`https://media.example.test:${port}/video`); + const options = { + method: "GET", + headers: { Range: "bytes=0-4" }, + signal: AbortSignal.timeout(5000), + }; + const response = await fetchPinnedAsset( + url, + [{ address: "127.0.0.1", family: 4 }], + options + ); + expect(response.status).toBe(206); + expect(await response.text()).toBe("bytes"); + expect(host).toBe(`media.example.test:${port}`); + expect(servername).toBe("media.example.test"); + expect(range).toBe("bytes=0-4"); + expect(vi.mocked(request).mock.calls[0]?.[1]).toMatchObject({ + agent: false, + lookup: expect.any(Function), + }); + const head = await fetchPinnedAsset( + url, + [{ address: "127.0.0.1", family: 4 }], + { ...options, method: "HEAD" } + ); + expect(head.body).toBeNull(); + const controller = new AbortController(); + const slow = await fetchPinnedAsset( + new URL("/slow", url), + [{ address: "127.0.0.1", family: 4 }], + { ...options, signal: controller.signal } + ); + const reader = slow.body!.getReader(); + await reader.read(); + controller.abort(); + await expect(reader.read()).rejects.toThrow(); + await expect( + fetchPinnedAsset( + new URL(`https://wrong.example.test:${port}/`), + [{ address: "127.0.0.1", family: 4 }], + options + ) + ).rejects.toThrow(); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +}, 15000); diff --git a/tests/contracts/auth0-entry-pages.test.ts b/tests/contracts/auth0-entry-pages.test.ts index fdc8ac38..b206e9e1 100644 --- a/tests/contracts/auth0-entry-pages.test.ts +++ b/tests/contracts/auth0-entry-pages.test.ts @@ -1,10 +1,16 @@ import { beforeEach, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ identity: vi.fn() })); +const mocks = vi.hoisted(() => ({ + identity: vi.fn(), + landing: vi.fn(async (returnTo: string) => returnTo), +})); vi.mock("server-only", () => ({})); vi.mock("@/lib/authentication/session", () => ({ getAuthenticatedIdentity: mocks.identity, })); +vi.mock("@/lib/identity/signed-in-landing", () => ({ + signedInLandingPath: mocks.landing, +})); vi.mock("@/components/console/LoginPage", () => ({ default: () => null })); vi.mock("next/navigation", () => ({ redirect: (url: string) => { @@ -15,30 +21,29 @@ import LoginRoute from "@/app/(auth)/login/page"; import SignupRoute from "@/app/(auth)/signup/page"; import RootPage from "@/app/(app)/page"; -beforeEach(() => mocks.identity.mockResolvedValue({ subject: "synthetic" })); -it("signed-in login and signup delegate landing to canonical admission", async () => { +beforeEach(() => { + mocks.identity.mockResolvedValue({ subject: "synthetic" }); + mocks.landing.mockImplementation(async (returnTo: string) => returnTo); +}); +it("signed-in login and signup land through the page, not a sync route", async () => { for (const page of [LoginRoute, SignupRoute]) await expect(page({ searchParams: Promise.resolve({}) })).rejects.toThrow( - "redirect:/api/identity/sync?returnTo=%2Fhome" + "redirect:/home" ); }); -it("preserves MCP and explicit device return paths through synchronization", async () => { +it("preserves MCP and explicit device return paths through login", async () => { await expect( LoginRoute({ searchParams: Promise.resolve({ mcp_oauth: "1" }) }) - ).rejects.toThrow( - "redirect:/api/identity/sync?returnTo=%2Fapi%2Fmcp%2Foauth%2Fcallback" - ); + ).rejects.toThrow("redirect:/api/mcp/oauth/callback"); await expect( LoginRoute({ searchParams: Promise.resolve({ returnTo: "/device?code=fixture" }), }) - ).rejects.toThrow( - "redirect:/api/identity/sync?returnTo=%2Fdevice%3Fcode%3Dfixture" - ); + ).rejects.toThrow("redirect:/device?code=fixture"); }); -it("root delegates landing and preserves legacy referral links", async () => { +it("root goes home and preserves legacy referral links", async () => { await expect(RootPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( - "redirect:/api/identity/sync?returnTo=%2Fhome" + "redirect:/home" ); await expect( RootPage({ searchParams: Promise.resolve({ ref: "abc_123" }) }) diff --git a/tests/contracts/billing-sync-route.test.ts b/tests/contracts/billing-sync-route.test.ts new file mode 100644 index 00000000..2971a6d7 --- /dev/null +++ b/tests/contracts/billing-sync-route.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("server-only", () => ({})); +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + fetchUsage: vi.fn(), + manifests: vi.fn(), + record: vi.fn(), + owner: vi.fn(), + runs: vi.fn(), +})); +vi.mock("@/lib/console/session-user", () => ({ + requireConsoleSession: mocks.session, +})); +vi.mock("@/lib/console/manifest-usage", () => ({ + fetchManifestUsage: mocks.fetchUsage, +})); +vi.mock("@/lib/runs/store", () => ({ + ownedPaymentManifests: mocks.manifests, + recordManifestUsage: mocks.record, + resolveRunOwner: mocks.owner, + ownedRunsByIds: mocks.runs, +})); +vi.mock("@/lib/runs/http", () => ({ + RUN_HEADERS: {}, + runError: (e: Error) => + Response.json( + {}, + { status: e.message === "invalid_run_query" ? 400 : 503 } + ), +})); +import { POST } from "@/app/api/console/runs/billing-sync/route"; +const post = (ids: string[]) => + POST( + new Request("https://test/api", { + method: "POST", + body: JSON.stringify({ runIds: ids }), + }) + ); +beforeEach(() => { + vi.resetAllMocks(); + mocks.session.mockResolvedValue({ + externalUserId: "eu", + canonicalUserId: "user", + }); + mocks.owner.mockResolvedValue({ + userId: "user", + externalAccountId: "account", + }); + mocks.manifests.mockResolvedValue([ + { + manifest: { + manifestId: "mid", + accepted: true, + createdAt: new Date("2026-08-31"), + observedAt: null, + }, + status: "succeeded", + updatedAt: new Date("2026-01-01"), + }, + ]); + mocks.fetchUsage.mockResolvedValue([ + { manifestId: "mid", networkFeeUsdMicros: "2982", feeWei: "123" }, + { manifestId: "unrelated", networkFeeUsdMicros: "999", feeWei: null }, + ]); + mocks.record.mockResolvedValue(["run"]); + mocks.runs.mockResolvedValue([]); +}); +describe("manifest billing sync", () => { + it("queries one lifetime aggregate interval and persists only exact owned manifests", async () => { + const r = await post(["run"]); + expect(await r.json()).toEqual({ + changedRunIds: ["run"], + changedCount: 1, + pending: false, + }); + expect(mocks.fetchUsage).toHaveBeenCalledTimes(1); + expect(mocks.fetchUsage).toHaveBeenCalledWith( + expect.objectContaining({ + externalUserId: "eu", + startDate: "2026-08-01T00:00:00.000Z", + }) + ); + expect(mocks.record).toHaveBeenCalledWith( + expect.anything(), + ["run"], + [{ manifestId: "mid", networkFeeUsdMicros: "2982", feeWei: "123" }], + expect.any(Date) + ); + }); + it("does not infer manifests for historical runs", async () => { + mocks.manifests.mockResolvedValue([]); + expect((await post(["old"])).status).toBe(200); + expect(mocks.fetchUsage).not.toHaveBeenCalled(); + }); + it("retries when a running run has not yet reached payment", async () => { + mocks.manifests.mockResolvedValue([]); + mocks.runs.mockResolvedValue([{ status: "running" }]); + expect((await (await post(["run"])).json()).pending).toBe(true); + }); + it("keeps missing accepted usage pending without fabricating zero receipts", async () => { + mocks.fetchUsage.mockResolvedValue([]); + expect((await (await post(["run"])).json()).pending).toBe(true); + expect(mocks.record.mock.calls[0][2]).toEqual([]); + }); + it("does not repeatedly fetch a fresh terminal snapshot", async () => { + mocks.manifests.mockResolvedValue([ + { + manifest: { observedAt: new Date() }, + status: "succeeded", + updatedAt: new Date("2026-01-01"), + }, + ]); + await post(["run"]); + expect(mocks.fetchUsage).not.toHaveBeenCalled(); + }); + it("refreshes a recently completed run while final usage may still be arriving", async () => { + mocks.runs.mockResolvedValue([ + { status: "succeeded", updatedAt: new Date() }, + ]); + mocks.manifests.mockResolvedValue([ + { + manifest: { observedAt: new Date() }, + status: "succeeded", + updatedAt: new Date(), + }, + ]); + expect((await (await post(["run"])).json()).pending).toBe(true); + }); + it("rejects oversized requests and mismatched owners before upstream calls", async () => { + expect( + (await post(Array.from({ length: 51 }, (_, i) => String(i)))).status + ).toBe(400); + mocks.owner.mockResolvedValue({ userId: "foreign" }); + expect((await post(["run"])).status).toBe(503); + expect(mocks.fetchUsage).not.toHaveBeenCalled(); + }); +}); + +it.each([false, true])( + "keeps a manifest-free active run pending beside a settled run (cached=%s)", + async (cached) => { + mocks.runs.mockResolvedValue([ + { id: "settled", status: "succeeded", updatedAt: new Date("2026-01-01") }, + { id: "waiting", status: "running", updatedAt: new Date() }, + ]); + mocks.manifests.mockResolvedValue([ + { + status: "succeeded", + updatedAt: new Date("2026-01-01"), + manifest: { + manifestId: "mid", + accepted: true, + networkFeeUsdMicros: "2982", + createdAt: new Date("2026-08-01"), + observedAt: cached ? new Date() : null, + }, + }, + ]); + expect((await (await post(["settled", "waiting"])).json()).pending).toBe( + true + ); + if (cached) expect(mocks.fetchUsage).not.toHaveBeenCalled(); + } +); +it("does not treat a recent observation without a fee as settled", async () => { + mocks.manifests.mockResolvedValue([ + { + manifest: { + accepted: true, + networkFeeUsdMicros: null, + observedAt: new Date(), + }, + }, + ]); + expect((await (await post(["run"])).json()).pending).toBe(true); +}); diff --git a/tests/contracts/call-detail-media.test.tsx b/tests/contracts/call-detail-media.test.tsx new file mode 100644 index 00000000..9cf04851 --- /dev/null +++ b/tests/contracts/call-detail-media.test.tsx @@ -0,0 +1,491 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import CallsTable from "@/components/console/CallsTable"; +import CallDetailDrawer from "@/components/console/CallDetailDrawer"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { AccountActivityRow } from "@/lib/console/types"; +import type { RunDetail } from "@/lib/runs/types"; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver = ResizeObserverMock; + +const row: AccountActivityRow = { + id: "run-media", + recordKind: "run", + gatewayRequestId: "job-media", + environmentId: "console", + timestamp: "2026-09-09T12:00:00.000Z", + model: "Whisper Transcribe", + pipeline: "audio-to-text", + modality: "asr", + status: "success", + kind: "batch", + latencyMs: 1_000, + durationMs: null, + signer: "paymthouse", + signerLabel: "MCP", + tokenId: "", + tokenName: "", + costDisplay: "$0.01", + providerRequestId: "provider-media", +}; + +function detail(assets: RunDetail["assets"]): RunDetail { + return { + id: row.id, + principalId: "eu_test", + userId: "user-test", + externalAccountId: "account-test", + gatewayRequestId: row.gatewayRequestId!, + providerRequestId: row.providerRequestId ?? null, + provider: "fal", + source: "mcp", + capability: "livepeer-example/fal-whisper-transcribe", + modelId: "livepeer-example/fal-whisper-transcribe", + endpoint: null, + status: "succeeded", + billing: { networkFeeUsdMicros: "10000", receiptCount: 1 }, + submittedArguments: { + inputs: { + audio_url: "https://earlyaccess.livepeer.org/api/assets/asset-audio", + diarize: true, + }, + }, + result: { + value: { + text: "A transcript must not occupy the media stage.", + inference_time_ms: 1_000, + }, + }, + captureVersion: 1, + captureRedactedPaths: [], + errorCode: null, + errorMessage: null, + version: 1, + createdAt: "2026-09-09T12:00:00.000Z", + updatedAt: "2026-09-09T12:00:01.000Z", + startedAt: "2026-09-09T12:00:00.000Z", + completedAt: "2026-09-09T12:00:01.000Z", + email: "test@example.invalid", + assets, + events: [], + }; +} + +afterEach(cleanup); + +function renderWithTooltips(element: ReactElement) { + return render({element}); +} + +it("never renders JSON or text results in the user media stage", async () => { + renderWithTooltips( + {}} + detail={detail([])} + variant="user" + /> + ); + + expect(screen.getByText("Media unavailable")).toBeTruthy(); + expect(screen.queryByText(/transcript must not occupy/i)).toBeNull(); + expect(screen.getByText("asr").className).toContain("h-[18px]"); + expect(screen.queryByText("Speech to text")).toBeNull(); + expect(screen.queryByText("Modality")).toBeNull(); + expect(screen.getByText("Render status")).toBeTruthy(); + fireEvent.focus(screen.getByRole("button", { name: "About Render status" })); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toBe( + "Amount of time it took the model to generate your request." + ); + expect( + tooltip.closest('[data-slot="tooltip-positioner"]')?.className + ).toContain("z-[130]"); +}); + +it("uses asset media type and shows its expiry countdown", async () => { + const expiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + renderWithTooltips( + {}} + detail={detail([ + { + id: "asset-audio", + url: "https://earlyaccess.livepeer.org/api/assets/asset-audio", + mediaType: "audio/mpeg", + providerRequestId: row.providerRequestId ?? null, + availableUntil: null, + expiresAt, + unavailableAt: null, + hiddenAt: null, + createdAt: "2026-09-09T12:00:01.000Z", + }, + ])} + variant="user" + /> + ); + + expect(document.querySelector("audio")).toBeTruthy(); + expect( + screen.getByText(/Expires in 00:01:59:5\d|Expires in 00:02:00:00/) + ).toBeTruthy(); + expect(screen.getByText("Audio")).toBeTruthy(); + fireEvent.focus(screen.getByRole("button", { name: "About Audio" })); + expect((await screen.findByRole("tooltip")).textContent).toContain( + "upload or an earlier platform result" + ); + expect(screen.getByRole("link", { name: "asset-audio" })).toHaveProperty( + "target", + "_blank" + ); + expect(screen.getByRole("link", { name: "asset-audio" }).className).toContain( + "text-xs" + ); + expect(screen.queryByText(/earlyaccess\.livepeer\.org/)).toBeNull(); +}); + +it("keeps every asset in an input array as a compact URL-backed link", () => { + const run = detail([ + { + id: "asset-first", + displayName: "First reference.png", + url: "https://earlyaccess.livepeer.org/api/assets/asset-first", + mediaType: "image/png", + providerRequestId: null, + availableUntil: null, + expiresAt: null, + unavailableAt: null, + hiddenAt: null, + createdAt: "2026-09-09T12:00:01.000Z", + }, + { + id: "asset-second", + displayName: "Second reference.png", + url: "https://earlyaccess.livepeer.org/api/assets/asset-second", + mediaType: "image/png", + providerRequestId: null, + availableUntil: null, + expiresAt: null, + unavailableAt: null, + hiddenAt: null, + createdAt: "2026-09-09T12:00:01.000Z", + }, + ]); + run.submittedArguments = { + inputs: { + image_urls: [ + "https://earlyaccess.livepeer.org/api/assets/asset-first", + "https://earlyaccess.livepeer.org/api/assets/asset-second", + ], + }, + }; + + renderWithTooltips( + {}} + detail={run} + variant="user" + /> + ); + + for (const name of ["First reference.png", "Second reference.png"]) { + const link = screen.getByRole("link", { name }); + expect(link).toHaveProperty("target", "_blank"); + expect(link.className).toContain("text-xs"); + expect(link.getAttribute("href")).toContain("/api/assets/asset-"); + } +}); + +it("groups each keyframe timestamp with its linked asset", async () => { + const run = detail([ + { + id: "asset-frame", + displayName: "Train entering station.webp", + url: "https://earlyaccess.livepeer.org/api/assets/asset-frame", + mediaType: "image/webp", + providerRequestId: row.providerRequestId ?? null, + availableUntil: null, + expiresAt: null, + unavailableAt: null, + hiddenAt: null, + createdAt: "2026-09-09T12:00:01.000Z", + }, + ]); + run.submittedArguments = { + inputs: { + keyframes: [ + { + timestamp_seconds: 3.5, + image_url: "https://earlyaccess.livepeer.org/api/assets/asset-frame", + }, + ], + loop: false, + }, + }; + + renderWithTooltips( + {}} + detail={run} + variant="user" + /> + ); + + expect(screen.getByText("Keyframe 1")).toBeTruthy(); + const timestamp = screen.getByText("00:03"); + const assetLink = screen.getByRole("link", { + name: "Train entering station.webp", + }); + expect(assetLink).toHaveProperty("target", "_blank"); + expect(assetLink).toHaveProperty("title", "Train entering station.webp"); + expect(assetLink.className).toContain("text-xs"); + expect(assetLink.querySelector("span")?.className).toContain("truncate"); + expect(timestamp.parentElement).toBe(assetLink.parentElement); + expect( + timestamp.compareDocumentPosition(assetLink) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(screen.queryByText("Timestamp Seconds")).toBeNull(); + expect(screen.getAllByText("false")[0]?.getAttribute("data-slot")).toBe( + "badge" + ); + fireEvent.focus(screen.getByRole("button", { name: "About Loop" })); + const loopTooltip = await screen.findByRole("tooltip"); + expect(loopTooltip.textContent).toContain( + "Makes the video repeat continuously. When set to true, the final frame transitions back to the first" + ); + expect(loopTooltip.className).toContain("p-0"); + const loopTooltipBody = loopTooltip.querySelector( + '[data-slot="tooltip-body"]' + ); + expect(loopTooltipBody?.className).toContain("items-start"); + expect(loopTooltipBody?.className).toContain("pb-4"); + const loopOptions = loopTooltip.querySelector('[aria-label="Options"]'); + expect(loopOptions).toBeTruthy(); + expect(loopOptions?.className).toContain("self-start"); + expect(loopOptions?.className).toContain("flex-col"); + expect(loopOptions?.className).toContain("w-[7.5rem]"); + expect(screen.getAllByText("true").at(-1)?.getAttribute("data-slot")).toBe( + "badge" + ); + expect(screen.getAllByText("false").at(-1)?.getAttribute("data-slot")).toBe( + "badge" + ); +}); + +it("shows Flux Schnell image-size alternatives", async () => { + const run = detail([]); + run.submittedArguments = { + inputs: { image_size: "landscape_16_9" }, + }; + run.inputSchema = { + endpointId: "fal-ai/flux/schnell", + schemaSha256: "fixture", + fields: [ + { + path: "image_size", + title: "Image Size", + description: "The size of the generated image.", + required: false, + types: ["object", "string"], + options: [ + "square_hd", + "square", + "portrait_4_3", + "portrait_16_9", + "landscape_4_3", + "landscape_16_9", + ], + defaultValue: "landscape_4_3", + }, + { + path: "image_size.width", + title: "Width", + description: "Custom output width.", + required: false, + types: ["integer"], + options: [], + }, + { + path: "image_size.height", + title: "Height", + description: "Custom output height.", + required: false, + types: ["integer"], + options: [], + }, + ], + }; + + renderWithTooltips( + {}} + detail={run} + variant="user" + /> + ); + + fireEvent.focus(screen.getByRole("button", { name: "About Image Size" })); + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toContain("portrait_16_9"); + expect(tooltip.textContent).toContain("landscape_16_9"); + expect( + Array.from(tooltip.querySelectorAll('[data-slot="badge"]')).map( + (badge) => badge.textContent + ) + ).toEqual([ + "square_hd", + "square", + "portrait_4_3", + "portrait_16_9", + "landscape_4_3", + "landscape_16_9", + ]); + expect((await screen.findByRole("tooltip")).textContent).toContain( + "custom width and height" + ); +}); + +it("exposes exact table cost by keyboard without activating or nesting the row button", async () => { + const select = vi.fn(); + renderWithTooltips( + + ); + const cost = screen.getByRole("button", { name: "Exact cost $0.01000025" }); + expect(cost.parentElement?.closest("button")).toBeNull(); + fireEvent.focus(cost); + expect((await screen.findByRole("tooltip")).textContent).toBe("$0.01000025"); + fireEvent.click(cost); + expect(select).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: `Inspect ${row.id}` })); + expect(select).toHaveBeenCalledOnce(); +}); +it("retains unavailable media records and cost", () => { + const d = detail([ + { + id: "asset-audio", + url: "https://preview.example/api/assets/asset-audio", + mediaType: "audio/mpeg", + unavailableAt: "2026-09-10T00:00:00Z", + hiddenAt: null, + createdAt: "2026-09-09T00:00:00Z", + providerRequestId: null, + role: "input", + }, + ]); + renderWithTooltips( + {}} + detail={d} + variant="user" + /> + ); + expect(screen.getByText("Media unavailable")).toBeTruthy(); + expect(document.querySelector("audio")).toBeNull(); + expect(screen.getByText("Cost")).toBeTruthy(); +}); +it("labels mixed lineage and lets keyboard users inspect admin exact costs", async () => { + const d = detail([ + { + id: "input", + url: "https://preview.example/api/assets/input", + mediaType: "image", + role: "input", + unavailableAt: null, + hiddenAt: null, + createdAt: row.timestamp, + providerRequestId: null, + }, + { + id: "output", + url: "https://preview.example/api/assets/output", + mediaType: "image", + role: "output", + unavailableAt: null, + hiddenAt: null, + createdAt: row.timestamp, + providerRequestId: null, + }, + ]); + renderWithTooltips( + {}} + detail={d} + variant="admin" + /> + ); + expect(screen.getByText("Input 1")).toBeTruthy(); + expect(screen.getByText("Output 1")).toBeTruthy(); + const cost = screen.getByRole("button", { name: "$0.01" }); + fireEvent.focus(cost); + expect((await screen.findByRole("tooltip")).textContent).toBe("$0.01000025"); +}); + +it("prefers a renderable 3D preview over the model download", () => { + const d = detail([ + { + id: "model", + url: "https://preview.example/api/assets/model", + mediaType: "model", + role: "output", + createdAt: row.timestamp, + providerRequestId: null, + unavailableAt: null, + hiddenAt: null, + }, + { + id: "preview", + url: "https://preview.example/api/assets/preview", + mediaType: "image", + role: "output", + createdAt: row.timestamp, + providerRequestId: null, + unavailableAt: null, + hiddenAt: null, + }, + ]); + renderWithTooltips( + {}} + detail={d} + variant="user" + /> + ); + expect(document.querySelector("img")?.getAttribute("src")).toBe( + "https://preview.example/api/assets/preview" + ); +}); diff --git a/tests/contracts/dev-mock-billing.test.ts b/tests/contracts/dev-mock-billing.test.ts new file mode 100644 index 00000000..a7b22829 --- /dev/null +++ b/tests/contracts/dev-mock-billing.test.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { devMockResponse } from "@/lib/console/dev-mock"; +import type { RunSummary, RunDetail } from "@/lib/runs/types"; +it("shares mock costs between summaries, details and events without billing network access", async () => { + const response = devMockResponse( + "/api/console/runs", + new URLSearchParams(), + "http://localhost:3000" + ); + const { items } = (await response!.json()) as { items: RunSummary[] }; + for (const run of items) { + const detail = (await devMockResponse( + `/api/console/runs/${run.id}`, + new URLSearchParams(), + "http://localhost:3000" + )!.json()) as RunDetail; + expect(detail.billing).toEqual(run.billing); + if (run.status === "succeeded") + expect( + detail.events.find((e) => e.metadata.kind === "billing_usage")?.metadata + .networkFeeUsdMicros + ).toBe(run.billing?.networkFeeUsdMicros); + } + expect( + await devMockResponse( + "/api/console/runs/billing-sync", + new URLSearchParams(), + "http://localhost:3000" + )!.json() + ).toMatchObject({ pending: false, changedCount: 0 }); +}); + +it("serves every mock asset locally and never falls through for unknown IDs", async () => { + const { items } = (await devMockResponse( + "/api/console/runs", + new URLSearchParams(), + "http://localhost:3000" + )!.json()) as { items: RunSummary[] }; + for (const run of items) { + const detail = (await devMockResponse( + `/api/console/runs/${run.id}`, + new URLSearchParams(), + "http://localhost:3000" + )!.json()) as RunDetail; + for (const asset of detail.assets) { + const reply = devMockResponse( + new URL(asset.url).pathname, + new URLSearchParams(), + "http://localhost:3000" + )!; + expect(reply.status).toBe(307); + expect(new URL(reply.headers.get("location")!).origin).toBe( + "http://localhost:3000" + ); + expect( + existsSync( + join( + process.cwd(), + "public", + new URL(reply.headers.get("location")!).pathname + ) + ) + ).toBe(true); + } + } + expect( + devMockResponse( + "/api/assets/unknown", + new URLSearchParams(), + "http://localhost:3000" + )!.status + ).toBe(404); +}); + +it("bundles valid audio and model fixture containers", () => { + expect( + readFileSync("public/fixtures/history/sample-tone.wav") + .subarray(0, 4) + .toString() + ).toBe("RIFF"); + const glb = readFileSync("public/fixtures/history/octahedron.glb"); + expect(glb.subarray(0, 4).toString()).toBe("glTF"); + expect(glb.readUInt32LE(8)).toBe(glb.length); +}); diff --git a/tests/contracts/end-user-token-cache.test.ts b/tests/contracts/end-user-token-cache.test.ts new file mode 100644 index 00000000..83f7f5b1 --- /dev/null +++ b/tests/contracts/end-user-token-cache.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, it } from "vitest"; +import { + cachedEndUserAccessToken, + endUserTokenCacheKey, + resetEndUserTokenCache, + type CachedEndUserToken, +} from "@/lib/console/end-user-token-cache"; + +function token(access_token: string, expires_in = 300): CachedEndUserToken { + return { + access_token, + refresh_token: "refresh", + token_type: "Bearer", + expires_in, + scope: "sign:job", + }; +} + +afterEach(() => { + resetEndUserTokenCache(); +}); + +it("reuses a live token and single-flights concurrent mints", async () => { + let mints = 0; + let release!: (value: CachedEndUserToken) => void; + const first = new Promise((resolve) => { + release = resolve; + }); + const mint = () => { + mints += 1; + return mints === 1 ? first : Promise.resolve(token("late")); + }; + const key = endUserTokenCacheKey("app", "eu"); + const a = cachedEndUserAccessToken(key, mint); + const b = cachedEndUserAccessToken(key, mint); + release(token("minted")); + expect(await a).toMatchObject({ access_token: "minted" }); + expect(await b).toMatchObject({ access_token: "minted" }); + expect(mints).toBe(1); + expect( + (await cachedEndUserAccessToken(key, mint, Date.now() + 60_000)) + .access_token + ).toBe("minted"); + expect(mints).toBe(1); +}); + +it("force mint replaces a live token", async () => { + const key = endUserTokenCacheKey("app", "eu"); + let mints = 0; + const mint = async () => token(`t${++mints}`); + expect( + (await cachedEndUserAccessToken(key, mint, 1_000_000)).access_token + ).toBe("t1"); + expect( + (await cachedEndUserAccessToken(key, mint, 1_000_000, { force: true })) + .access_token + ).toBe("t2"); + expect(mints).toBe(2); +}); + +it("remints after expiry skew and keeps a still-valid token if refresh fails", async () => { + const key = endUserTokenCacheKey("app", "eu"); + let mints = 0; + const mint = async () => { + mints += 1; + if (mints >= 2) throw new Error("mint_failed"); + return token(`t${mints}`, 40); + }; + const now = 1_000_000; + expect((await cachedEndUserAccessToken(key, mint, now)).access_token).toBe( + "t1" + ); + expect( + (await cachedEndUserAccessToken(key, mint, now + 15_000)).access_token + ).toBe("t1"); + expect(mints).toBe(2); + await expect( + cachedEndUserAccessToken(key, mint, now + 41_000) + ).rejects.toThrow("mint_failed"); +}); diff --git a/tests/contracts/gateway-payment-manifest.test.ts b/tests/contracts/gateway-payment-manifest.test.ts new file mode 100644 index 00000000..574a3901 --- /dev/null +++ b/tests/contracts/gateway-payment-manifest.test.ts @@ -0,0 +1,158 @@ +import { createServer } from "node:http"; +import { once } from "node:events"; +import { createRequire } from "node:module"; +import { expect, it } from "vitest"; +import * as esm from "@pymthouse/gateway-web"; +const cjs = createRequire(import.meta.url)( + "@pymthouse/gateway-web" +) as typeof esm; + +async function fixture(mode: "single-shot" | "persistent", failFirst = false) { + const order: string[] = []; + let payments = 0; + let origin = ""; + const server = createServer(async (req, res) => { + const parts: Buffer[] = []; + for await (const part of req) parts.push(Buffer.from(part)); + const body = parts.length + ? JSON.parse(Buffer.concat(parts).toString()) + : {}; + const path = new URL(req.url!, origin).pathname.replace("/app/", "/"); + const json = (status: number, value: unknown) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(value)); + }; + if (path === "/discover-orchestrators") + return json( + 200, + (failFirst ? ["first", "second"] : ["first"]).map((id) => ({ + address: origin + "/orch-" + id, + runners: [ + { + app: "test-app", + url: origin + "/" + id + "/", + mode, + runner_id: id, + price_info: { price: 1, currency: "wei", unit: "fixed" }, + }, + ], + })) + ); + if (path === "/sign-orchestrator-info") + return json(200, { address: "0xabc", signature: "0xsig" }); + if (path === "/generate-live-payment") { + payments++; + order.push("paid:" + body.ManifestID); + return json(200, { payment: "PAY", segCreds: "SEG", state: {} }); + } + if (path === "/first/" || path === "/second/") { + const id = path.slice(1, -1); + if (!req.headers["livepeer-payment"]) + return json(402, { + payment_params: "p", + manifest_id: "manifest-" + id, + payment_url: origin + "/pay", + }); + order.push("provider:" + id); + if (failFirst && id === "first") + return json(503, { error: "provider_failed" }); + return json( + 200, + mode === "persistent" + ? { + session_id: "provider-session", + app_url: origin + "/session-app", + control_url: origin + "/control", + } + : { text: "ok", request_id: "provider" } + ); + } + if (path === "/session-app/hello") return json(200, { text: "ok" }); + if (path === "/control/stop") return json(200, {}); + json(404, {}); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + return { + order, + payments: () => payments, + origin, + close: async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} +for (const [name, sdk] of [ + ["ESM", esm], + ["CJS", cjs], +] as const) { + it.each(["single-shot", "persistent"] as const)( + `${name} records the payment manifest before charging and before provider completion (%s)`, + async (mode) => { + const f = await fixture(mode); + try { + await sdk + .createGateway({ signerUrl: f.origin, timeoutMs: 5000 }) + .runInference({ + capability: "test-app", + ...(mode === "persistent" ? { endpoint: "/hello" } : {}), + onPayment: async ({ manifestId, phase }) => { + f.order.push(phase + ":" + manifestId); + }, + }); + expect(f.order).toEqual([ + "prepared:manifest-first", + "paid:manifest-first", + "accepted:manifest-first", + "provider:first", + ]); + } finally { + await f.close(); + } + } + ); + it(`${name} preserves manifests from paid failed attempts and failover`, async () => { + const f = await fixture("single-shot", true); + try { + await sdk + .createGateway({ signerUrl: f.origin, timeoutMs: 5000 }) + .runInference({ + capability: "test-app", + onPayment: async ({ manifestId, phase }) => { + f.order.push(phase + ":" + manifestId); + }, + }); + expect(f.order.filter((s) => s.startsWith("accepted:"))).toEqual([ + "accepted:manifest-first", + "accepted:manifest-second", + ]); + expect(f.payments()).toBe(2); + } finally { + await f.close(); + } + }); + it.each(["prepared", "accepted"])( + `${name} stops without paid failover if persistence fails at %s`, + async (phase) => { + const f = await fixture("single-shot", true); + try { + await expect( + sdk + .createGateway({ signerUrl: f.origin, timeoutMs: 5000 }) + .runInference({ + capability: "test-app", + onPayment: async (p) => { + if (p.phase === phase) throw new Error("db unavailable"); + }, + }) + ).rejects.toThrow("payment_manifest_persistence_failed"); + expect(f.payments()).toBe(phase === "prepared" ? 0 : 1); + expect(f.order.some((s) => s.startsWith("provider:"))).toBe(false); + } finally { + await f.close(); + } + } + ); +} diff --git a/tests/contracts/home-history-surface.test.tsx b/tests/contracts/home-history-surface.test.tsx index 2733e4a7..07363df7 100644 --- a/tests/contracts/home-history-surface.test.tsx +++ b/tests/contracts/home-history-surface.test.tsx @@ -2,6 +2,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import CallsSection from "@/components/console/CallsSection"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { RunSummary } from "@/lib/runs/types"; const navigation = vi.hoisted(() => ({ search: "" })); @@ -22,6 +23,13 @@ vi.mock("@/components/console/CallDetailDrawer", () => ({ })); const records: RunSummary[] = []; const fetcher = vi.fn(); +function renderCallsSection(query = "") { + return render( + + + + ); +} beforeEach(() => { navigation.search = ""; records.length = 0; @@ -53,7 +61,7 @@ afterEach(() => { }); it("renders exactly one History and one empty state, without the billing feed", async () => { - render(); + renderCallsSection(); await screen.findByText("No history yet."); expect(screen.getAllByRole("heading", { name: "History" })).toHaveLength(1); expect( @@ -88,6 +96,7 @@ it("shows Postgres rows even when billing is unavailable", async () => { startedAt: "2026-09-01T12:00:00Z", completedAt: "2026-09-01T12:00:01Z", email: null, + billing: null, }); navigation.search = "request=saved-run"; fetcher.mockImplementation(async (input: string) => { @@ -113,17 +122,82 @@ it("shows Postgres rows even when billing is unavailable", async () => { ? Response.json({ items: records, nextCursor: null }) : Response.json({ error: "billing unavailable" }, { status: 503 }); }); - render(); + renderCallsSection(); await screen.findByRole("button", { name: "Inspect saved-run" }); expect(screen.queryByText("No history yet.")).toBeNull(); expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByText("Usage-only history")).toBeNull(); await waitFor(() => - expect(screen.getByTestId("detail-cost").textContent).toBe("$0.0025") + expect(screen.getByTestId("detail-cost").textContent).toBe("—") ); }); -it("joins a correlated ticket fee onto the saved run, without adding a billing row", async () => { +it("does not infer a cost from an unmatched orchestrator ticket", async () => { + const run = { + id: "28d04c8a-7edd-487e-a0c4-5f95ed637a4b", + principalId: "external", + userId: "user", + externalAccountId: "account", + gatewayRequestId: "job_fe3e40004a49442c", + providerRequestId: null, + provider: null, + source: "mcp", + capability: "livepeer-example/fal-ideogram-v4", + modelId: "livepeer-example/fal-ideogram-v4", + endpoint: null, + status: "succeeded" as const, + captureVersion: 1, + errorCode: null, + errorMessage: null, + version: 2, + createdAt: "2026-09-09T21:42:18.000Z", + updatedAt: "2026-09-09T21:42:19.000Z", + startedAt: "2026-09-09T21:42:18.000Z", + completedAt: "2026-09-09T21:42:19.000Z", + email: null, + billing: null, + }; + records.push(run); + navigation.search = "request=28d04c8a-7edd-487e-a0c4-5f95ed637a4b"; + fetcher.mockImplementation(async (input: string) => { + if (input === "/api/console/runs/28d04c8a-7edd-487e-a0c4-5f95ed637a4b") { + return Response.json({ + ...run, + submittedArguments: null, + result: null, + captureRedactedPaths: [], + assets: [], + events: [], + }); + } + if (input === "/api/console/runs/billing-sync") + return Response.json({ changedRunIds: [], changedCount: 0 }); + if (String(input).startsWith("/api/console/runs")) + return Response.json({ items: records, nextCursor: null }); + return Response.json({ + items: [ + { + eventId: "c9a1fae7", + gatewayRequestId: "c9a1fae7", + time: "2026-09-09T21:42:19.000Z", + clientId: "app_test", + externalUserId: "eu_test", + pipeline: "text-to-image", + modelId: "livepeer-example/fal-ideogram-v4", + networkFeeUsdMicros: "9984.675492933755", + feeWei: "4048746912830", + }, + ], + nextCursor: null, + openMeterConfigured: true, + }); + }); + render(); + await waitFor(() => expect(fetcher).toHaveBeenCalled()); + expect(screen.getByTestId("detail-cost").textContent).toBe("—"); +}); + +it("reloads the Neon cost after an exact billing sync", async () => { records.push({ id: "saved-run", principalId: "external", @@ -146,50 +220,219 @@ it("joins a correlated ticket fee onto the saved run, without adding a billing r startedAt: "2026-09-01T12:00:00Z", completedAt: "2026-09-01T12:00:01Z", email: null, + billing: null, }); + let synced = false; fetcher.mockImplementation(async (input: string) => { + if (input === "/api/console/runs/billing-sync") { + synced = true; + records[0]!.billing = { + networkFeeUsdMicros: "1000", + receiptCount: 1, + }; + return Response.json({ changedRunIds: ["saved-run"], changedCount: 1 }); + } if (input.startsWith("/api/console/runs")) return Response.json({ items: records, nextCursor: null }); - return Response.json({ - items: [ - { - eventId: "evt-saved", - gatewayRequestId: "job_saved", - time: "2026-09-01T12:00:00Z", - clientId: "app_test", - externalUserId: "eu_test", - pipeline: "text-generation", - modelId: "saved-model", - networkFeeUsdMicros: "1000", - }, - { - eventId: "legacy-event", - gatewayRequestId: "legacy-job", - time: "2025-01-01T00:00:00Z", - clientId: "app_test", - externalUserId: "eu_test", - pipeline: "text-generation", - modelId: "legacy-only-model", - networkFeeUsdMicros: "100", - }, - ], - nextCursor: null, - openMeterConfigured: true, - }); + return Response.json({ error: "not found" }, { status: 404 }); }); - render(); + renderCallsSection(); await screen.findByRole("button", { name: "Inspect saved-run" }); - expect(screen.getByText("$0.0010")).toBeTruthy(); + await waitFor(() => expect(screen.getByText("$0.0010")).toBeTruthy()); + expect(synced).toBe(true); + expect(screen.queryByText("legacy-only-model")).toBeNull(); +}); + +it("includes a deep-linked run in billing-sync even when it is off the first page", async () => { + records.push({ + id: "page-run", + principalId: "external", + userId: "user", + externalAccountId: "account", + gatewayRequestId: "job_page", + providerRequestId: null, + provider: null, + source: "mcp", + capability: "text-generation", + modelId: "page-model", + endpoint: null, + status: "succeeded", + captureVersion: 1, + errorCode: null, + errorMessage: null, + version: 2, + createdAt: "2026-09-01T12:00:00Z", + updatedAt: "2026-09-01T12:00:01Z", + startedAt: "2026-09-01T12:00:00Z", + completedAt: "2026-09-01T12:00:01Z", + email: null, + billing: null, + }); + navigation.search = "request=deep-run"; + fetcher.mockImplementation(async (input: string) => { + if (input === "/api/console/runs/billing-sync") + return Response.json({ changedRunIds: [], changedCount: 0 }); + if (input === "/api/console/runs/deep-run") { + return Response.json({ + id: "deep-run", + principalId: "external", + userId: "user", + externalAccountId: "account", + gatewayRequestId: "job_deep", + providerRequestId: null, + provider: null, + source: "mcp", + capability: "text-generation", + modelId: "deep-model", + endpoint: null, + status: "succeeded", + captureVersion: 1, + errorCode: null, + errorMessage: null, + version: 2, + createdAt: "2026-08-01T12:00:00Z", + updatedAt: "2026-08-01T12:00:01Z", + startedAt: "2026-08-01T12:00:00Z", + completedAt: "2026-08-01T12:00:01Z", + email: null, + billing: null, + submittedArguments: null, + result: null, + captureRedactedPaths: [], + assets: [], + events: [], + }); + } + if (String(input).startsWith("/api/console/runs")) + return Response.json({ items: records, nextCursor: null }); + return Response.json({ error: "not found" }, { status: 404 }); + }); + renderCallsSection(); + await waitFor(() => + expect( + fetcher.mock.calls.some(([url, init]) => { + if (url !== "/api/console/runs/billing-sync") return false; + const body = JSON.parse( + String((init as RequestInit | undefined)?.body) + ); + return ( + Array.isArray(body.runIds) && + body.runIds[0] === "deep-run" && + body.runIds.includes("page-run") + ); + }) + ).toBe(true) + ); +}); + +it("does not keep polling billing-sync when usage is still pending", async () => { + records.push({ + id: "saved-run", + principalId: "external", + userId: "user", + externalAccountId: "account", + gatewayRequestId: "job_saved", + providerRequestId: null, + provider: null, + source: "mcp", + capability: "text-generation", + modelId: "saved-model", + endpoint: null, + status: "succeeded", + captureVersion: 1, + errorCode: null, + errorMessage: null, + version: 2, + createdAt: "2026-09-01T12:00:00Z", + updatedAt: "2026-09-01T12:00:01Z", + startedAt: "2026-09-01T12:00:00Z", + completedAt: "2026-09-01T12:00:01Z", + email: null, + billing: null, + }); + fetcher.mockImplementation(async (input: string) => { + if (input === "/api/console/runs/billing-sync") + return Response.json({ + changedRunIds: [], + changedCount: 0, + pending: true, + }); + if (String(input).startsWith("/api/console/runs")) + return Response.json({ items: records, nextCursor: null }); + return Response.json({ error: "not found" }, { status: 404 }); + }); + renderCallsSection(); + await waitFor(() => + expect( + fetcher.mock.calls.filter( + ([url]) => url === "/api/console/runs/billing-sync" + ) + ).toHaveLength(1) + ); + await new Promise((resolve) => setTimeout(resolve, 25)); expect( - fetcher.mock.calls.some(([url]) => - String(url).includes("includeCorrelated=1") + fetcher.mock.calls.filter( + ([url]) => url === "/api/console/runs/billing-sync" ) - ).toBe(true); - expect(screen.queryByText("legacy-only-model")).toBeNull(); + ).toHaveLength(1); +}); + +it("does not restart billing sync when run detail arrives", async () => { + records.push({ + id: "saved-run", + principalId: "external", + userId: "user", + externalAccountId: "account", + gatewayRequestId: "job_saved", + providerRequestId: null, + provider: null, + source: "mcp", + capability: "text-generation", + modelId: "saved-model", + endpoint: null, + status: "succeeded", + captureVersion: 1, + errorCode: null, + errorMessage: null, + version: 2, + createdAt: "2026-09-01T12:00:00Z", + updatedAt: "2026-09-01T12:00:01Z", + startedAt: "2026-09-01T12:00:00Z", + completedAt: "2026-09-01T12:00:01Z", + email: null, + billing: null, + }); + navigation.search = "request=saved-run"; + fetcher.mockImplementation(async (input: string) => { + if (input === "/api/console/runs/billing-sync") + return Response.json({ changedRunIds: [], changedCount: 0 }); + if (input === "/api/console/runs/saved-run") { + return Response.json({ + ...records[0], + submittedArguments: null, + result: null, + captureRedactedPaths: [], + assets: [], + events: [], + }); + } + if (String(input).startsWith("/api/console/runs")) + return Response.json({ items: records, nextCursor: null }); + return Response.json({ error: "not found" }, { status: 404 }); + }); + renderCallsSection(); + await waitFor(() => expect(screen.getByTestId("detail-cost")).toBeTruthy()); + await waitFor(() => + expect( + fetcher.mock.calls.filter( + ([url]) => url === "/api/console/runs/billing-sync" + ) + ).toHaveLength(1) + ); }); it("searches the same Postgres history rather than a separate loaded billing list", async () => { - render(); + renderCallsSection("flux"); await screen.findByText("No history matches this search."); await waitFor(() => expect(fetcher).toHaveBeenCalledWith( diff --git a/tests/contracts/job-billing.test.ts b/tests/contracts/job-billing.test.ts new file mode 100644 index 00000000..b57575c1 --- /dev/null +++ b/tests/contracts/job-billing.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + fetchRange: vi.fn(), + manifests: vi.fn(), + record: vi.fn(), +})); +vi.mock("server-only", () => ({})); +vi.mock("@/lib/console/manifest-usage", () => ({ + fetchManifestUsage: mocks.fetchRange, +})); +vi.mock("@/lib/runs/store", () => ({ + ownedPaymentManifests: mocks.manifests, + recordManifestUsage: mocks.record, + ownedRunsByIds: vi.fn(), +})); +import { refreshOwnedRunBillingByJob } from "@/lib/runs/manifest-billing"; + +const owner = { + principalId: "eu", + userId: "user", + externalAccountId: "account", +}; +const input = { + owner, + runId: "run_test", + externalUserId: "eu", + now: new Date("2026-09-11T12:00:00.000Z"), + retryDelayMs: 0, +}; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.manifests.mockResolvedValue([ + { + manifest: { + manifestId: "mid", + accepted: true, + createdAt: new Date("2026-09-11T11:55:00.000Z"), + networkFeeUsdMicros: null, + }, + }, + ]); + mocks.fetchRange.mockResolvedValue([ + { manifestId: "mid", networkFeeUsdMicros: "2982", feeWei: "123" }, + { manifestId: "other", networkFeeUsdMicros: "119005", feeWei: null }, + ]); + mocks.record.mockResolvedValue(["run_test"]); +}); + +it("persists the exact owned manifest and ignores other jobs in the window", async () => { + expect(await refreshOwnedRunBillingByJob(input)).toEqual({ + changedRunIds: ["run_test"], + pending: false, + }); + expect(mocks.fetchRange).toHaveBeenCalledTimes(1); + expect(mocks.fetchRange).toHaveBeenCalledWith({ + externalUserId: "eu", + startDate: "2026-09-11T11:54:00.000Z", + endDate: "2026-09-11T12:00:00.000Z", + }); + expect(mocks.record).toHaveBeenCalledWith( + owner, + ["run_test"], + [{ manifestId: "mid", networkFeeUsdMicros: "2982", feeWei: "123" }], + input.now + ); +}); + +it("does not call PymtHouse when the run has no accepted manifest", async () => { + mocks.manifests.mockResolvedValue([]); + expect(await refreshOwnedRunBillingByJob(input)).toEqual({ + changedRunIds: [], + pending: false, + }); + expect(mocks.fetchRange).not.toHaveBeenCalled(); +}); + +it("stamps each accepted manifest from the same run without using a job total", async () => { + mocks.manifests.mockResolvedValue([ + { + manifest: { + manifestId: "a", + accepted: true, + createdAt: new Date("2026-09-11T11:55:00.000Z"), + }, + }, + { + manifest: { + manifestId: "b", + accepted: true, + createdAt: new Date("2026-09-11T11:55:01.000Z"), + }, + }, + ]); + mocks.fetchRange.mockResolvedValue([ + { manifestId: "a", networkFeeUsdMicros: "1000", feeWei: null }, + { manifestId: "b", networkFeeUsdMicros: "2000", feeWei: null }, + ]); + expect(await refreshOwnedRunBillingByJob(input)).toEqual({ + changedRunIds: ["run_test"], + pending: false, + }); + expect(mocks.record).toHaveBeenCalledWith( + owner, + ["run_test"], + [ + { manifestId: "a", networkFeeUsdMicros: "1000", feeWei: null }, + { manifestId: "b", networkFeeUsdMicros: "2000", feeWei: null }, + ], + input.now + ); +}); + +it("retries until the exact manifest appears instead of requiring History", async () => { + mocks.fetchRange + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { manifestId: "mid", networkFeeUsdMicros: "2982", feeWei: "123" }, + ]); + expect(await refreshOwnedRunBillingByJob(input)).toEqual({ + changedRunIds: ["run_test"], + pending: false, + }); + expect(mocks.fetchRange).toHaveBeenCalledTimes(2); +}); + +it("stays pending when the exact manifest is still missing", async () => { + mocks.fetchRange.mockResolvedValue([]); + expect(await refreshOwnedRunBillingByJob(input)).toEqual({ + changedRunIds: [], + pending: true, + }); + expect(mocks.fetchRange).toHaveBeenCalledTimes(3); + expect(mocks.record).not.toHaveBeenCalled(); +}); diff --git a/tests/contracts/manifest-usage.test.ts b/tests/contracts/manifest-usage.test.ts new file mode 100644 index 00000000..6251ac9e --- /dev/null +++ b/tests/contracts/manifest-usage.test.ts @@ -0,0 +1,57 @@ +import { afterEach, expect, it, vi } from "vitest"; +vi.mock("server-only", () => ({})); +vi.mock("@/lib/console/pymthouse-bff", () => ({ + getEndUserAccessToken: vi.fn(async () => ({ access_token: "test-token" })), +})); +vi.mock("@/lib/console/pymthouse-http", () => ({ + issuerOriginFromConfig: () => "https://pymthouse.test", +})); +import { fetchManifestUsage } from "@/lib/console/manifest-usage"; +const input = { + externalUserId: "eu", + startDate: "2026-08-01", + endDate: "2026-09-12", +}; +afterEach(() => vi.unstubAllGlobals()); +it("uses Bearer scope and documented manifest grouping without receipt cursors or subject overrides", async () => { + const f = vi.fn(async () => + Response.json({ + byManifest: [ + { + manifestId: "mid", + networkFeeUsdMicros: "2982", + feeWei: "1190385777842", + }, + ], + }) + ); + vi.stubGlobal("fetch", f); + expect(await fetchManifestUsage(input)).toEqual([ + { manifestId: "mid", networkFeeUsdMicros: "2982", feeWei: "1190385777842" }, + ]); + const [url, init] = (f.mock.calls as unknown as [URL, RequestInit][])[0]; + expect(url.pathname).toBe("/api/v1/user/usage"); + expect([...url.searchParams.keys()]).toEqual([ + "groupBy", + "startDate", + "endDate", + ]); + expect(url.searchParams.get("groupBy")).toBe("manifest"); + expect(init.headers).toEqual({ + Authorization: "Bearer test-token", + Accept: "application/json", + }); +}); +it.each([ + {}, + { byManifest: [{ manifestId: "mid", networkFeeUsdMicros: -1 }] }, + { byManifest: [{ manifestId: "mid", networkFeeUsdMicros: "NaN" }] }, + { + byManifest: Array(2).fill({ manifestId: "mid", networkFeeUsdMicros: "1" }), + }, +])("rejects malformed or duplicate aggregate rows", async (body) => { + vi.stubGlobal("fetch", async () => Response.json(body)); + await expect(fetchManifestUsage(input)).rejects.toThrow( + "invalid_manifest_usage" + ); +}); diff --git a/tests/contracts/preview-run-fixtures-route.test.ts b/tests/contracts/preview-run-fixtures-route.test.ts new file mode 100644 index 00000000..b44aae0a --- /dev/null +++ b/tests/contracts/preview-run-fixtures-route.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const mocks = vi.hoisted(() => ({ + owner: vi.fn(), + ownedRuns: vi.fn(), + createRun: vi.fn(), + transitionRun: vi.fn(), + recordUsage: vi.fn(), +})); + +vi.mock("@/lib/runs/http", () => ({ + requireRunOwner: mocks.owner, + RUN_HEADERS: { "cache-control": "no-store" }, + runError: () => Response.json({ error: "failed" }, { status: 500 }), +})); +vi.mock("@/lib/runs/store", () => ({ + withPreviewRunFixtures: async ( + _owner: unknown, + work: (store: unknown) => unknown + ) => + work({ + completedRunIds: async () => [], + ownedRunsByIds: mocks.ownedRuns, + createRun: mocks.createRun, + transitionRun: mocks.transitionRun, + recordRunUsage: mocks.recordUsage, + }), + ownedRunsByIds: mocks.ownedRuns, + createRun: mocks.createRun, + transitionRun: mocks.transitionRun, + recordRunUsage: mocks.recordUsage, +})); + +import { POST } from "@/app/api/console/runs/preview-fixtures/route"; + +describe("POST /api/console/runs/preview-fixtures", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("CONSOLE_PREVIEW_FIXTURES", "1"); + mocks.owner.mockResolvedValue({ + principalId: "eu_preview", + userId: "user_preview", + externalAccountId: "account_preview", + }); + mocks.ownedRuns.mockResolvedValue([]); + mocks.createRun.mockImplementation(async (_owner, input) => ({ + id: input.id, + })); + mocks.transitionRun.mockResolvedValue({}); + mocks.recordUsage.mockResolvedValue([]); + }); + + it("is unavailable outside an explicitly enabled Vercel preview", async () => { + vi.stubEnv("VERCEL_ENV", "production"); + const response = await POST( + new Request("https://console.example/api/console/runs/preview-fixtures", { + method: "POST", + }) + ); + expect(response.status).toBe(404); + expect(mocks.owner).not.toHaveBeenCalled(); + }); + + it("creates owner-scoped Neon fixtures with exact receipt joins and lineage", async () => { + const response = await POST( + new Request("https://preview.example/api/console/runs/preview-fixtures", { + method: "POST", + }) + ); + expect(response.status).toBe(200); + expect((await response.json()).createdCount).toBe(4); + expect(mocks.createRun).toHaveBeenCalledTimes(4); + expect(mocks.transitionRun).toHaveBeenCalledTimes(4); + expect(mocks.recordUsage).toHaveBeenCalledTimes(3); + expect(mocks.recordUsage.mock.calls.flatMap((call) => call[1])).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + gatewayRequestId: expect.stringContaining("job_preview_"), + metadata: expect.objectContaining({ networkFeeUsdMicros: "1000.25" }), + }), + expect.objectContaining({ + metadata: expect.objectContaining({ networkFeeUsdMicros: "2000.75" }), + }), + ]) + ); + expect(JSON.stringify(mocks.createRun.mock.calls)).toContain( + "/api/assets/asset_preview_" + ); + }); +}); diff --git a/tests/contracts/public-media.test.ts b/tests/contracts/public-media.test.ts new file mode 100644 index 00000000..c6d10002 --- /dev/null +++ b/tests/contracts/public-media.test.ts @@ -0,0 +1,291 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { sanitizePublicMedia, publicRunDetail } from "@/lib/assets/public"; +import { extractRunOutputs } from "@/lib/runs/outputs"; +import type { RunDetail } from "@/lib/runs/types"; +afterEach(() => vi.unstubAllEnvs()); +it("removes input and redacted provider media while preserving prompts and unrelated links", () => { + const url = "https://provider.example/a?token=[REDACTED]"; + const clean = sanitizePublicMedia( + { + inputs: { + reference_image: url, + mask_url: url, + image_prompt: url, + prompt: `paint ${url}`, + website: "https://example.com", + keyframes: [{ image_url: url }], + images: [{ url, width: 20 }], + }, + result: { video: { url } }, + }, + [], + "eu_test" + ); + expect(clean).toEqual({ + inputs: { + image_prompt: url, + prompt: `paint ${url}`, + website: "https://example.com", + keyframes: [{}], + images: [{ width: 20 }], + }, + result: { video: {} }, + }); +}); +it("rewrites owned media but removes unmatched media even with partial persistence", () => { + vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://preview.example"); + const assets = [{ id: "owned", url: "https://provider.example/a" }]; + const clean = sanitizePublicMedia( + { + images: [{ url: assets[0]!.url }, { url: "https://provider.example/b" }], + reference_image: "https://preview.example/api/assets/owned?sig=old", + mask_url: "https://evil.example/api/assets/owned", + video_url: "https://preview.example/api/assets/foreign", + }, + assets, + "eu_test" + ); + expect(JSON.stringify(clean)).not.toMatch(/provider|evil|foreign|sig=old/); + expect(JSON.stringify(clean)).toContain( + "https://preview.example/api/assets/owned?exp=" + ); +}); +it("strips provider URLs from public error messages and drops empty leftovers", () => { + const queue = "https://queue.fal.run/fal-ai/flux/requests/req-1/status"; + const signed = "https://v3b.fal.media/files/output.mp4?token=secret"; + const clean = publicRunDetail({ + principalId: "eu_test", + assets: [], + errorMessage: `upstream failed ${queue} then ${signed}`, + result: { value: { text: "ok" } }, + } as unknown as RunDetail); + expect(clean.errorMessage).toBe("upstream failed then"); + expect(JSON.stringify(clean)).not.toMatch(/queue\.fal\.run|fal\.media/); + expect( + publicRunDetail({ + principalId: "eu_test", + assets: [], + errorMessage: queue, + } as unknown as RunDetail).errorMessage + ).toBeNull(); +}); +it("strips provider queue URLs from public event keys and metadata", () => { + const queue = "https://queue.fal.run/fal-ai/flux/requests/req-1/status"; + const relative = "//queue.fal.run/fal-ai/flux/requests/req-1/status"; + const bare = "queue.fal.run/fal-ai/flux/requests/req-1/status"; + const event = ( + id: string, + eventKey: string, + metadata: Record = { providerStatus: "IN_QUEUE" } + ) => ({ + id, + eventKey, + status: "running" as const, + createdAt: "2026-09-11T00:00:00.000Z", + metadata, + }); + const clean = publicRunDetail({ + principalId: "eu_test", + billing: { networkFeeUsdMicros: "2982", manifestCount: 1 }, + assets: [], + submittedArguments: { prompt: "portrait" }, + result: { value: { text: "ok" } }, + events: [ + { + ...event("evt_progress", `progress:IN_QUEUE:req-1:${queue}`), + metadata: { + providerStatus: "IN_QUEUE", + queue: { + statusUrl: queue, + resultUrl: queue.replace(/\/status$/, ""), + }, + recoveryHandle: queue, + }, + runId: "run_hidden", + }, + event("evt_empty_id", `progress:IN_QUEUE::${queue}`), + event("evt_relative", `progress:IN_QUEUE:req-1:${relative}`), + event("evt_bare", `progress:IN_QUEUE:req-1:${bare}`), + { + id: "evt_usage", + eventKey: "usage:receipt", + status: "succeeded", + createdAt: "2026-09-11T00:00:01.000Z", + metadata: { + kind: "billing_usage", + networkFeeUsdMicros: "2982", + feeWei: "1", + pipeline: "fal-ai/flux/schnell", + modelId: "fal-ai/flux/schnell", + reason: queue, + providerStatus: `IN_QUEUE ${relative}`, + }, + }, + ], + } as unknown as RunDetail); + expect(JSON.stringify(clean)).not.toMatch( + /queue\.fal\.run|statusUrl|run_hidden/ + ); + expect(clean.events).toEqual([ + { + id: "evt_progress", + eventKey: "progress:IN_QUEUE:req-1", + status: "running", + createdAt: "2026-09-11T00:00:00.000Z", + metadata: { providerStatus: "IN_QUEUE" }, + }, + { + id: "evt_empty_id", + eventKey: "progress:IN_QUEUE:", + status: "running", + createdAt: "2026-09-11T00:00:00.000Z", + metadata: { providerStatus: "IN_QUEUE" }, + }, + { + id: "evt_relative", + eventKey: "progress:IN_QUEUE:req-1", + status: "running", + createdAt: "2026-09-11T00:00:00.000Z", + metadata: { providerStatus: "IN_QUEUE" }, + }, + { + id: "evt_bare", + eventKey: "progress:IN_QUEUE:req-1", + status: "running", + createdAt: "2026-09-11T00:00:00.000Z", + metadata: { providerStatus: "IN_QUEUE" }, + }, + { + id: "evt_usage", + eventKey: "usage:receipt", + status: "succeeded", + createdAt: "2026-09-11T00:00:01.000Z", + metadata: { + kind: "billing_usage", + networkFeeUsdMicros: "2982", + feeWei: "1", + pipeline: "fal-ai/flux/schnell", + modelId: "fal-ai/flux/schnell", + providerStatus: "IN_QUEUE", + }, + }, + ]); +}); +it("keeps unavailable asset lineage and billing in public history", () => { + const unavailableAt = "2026-09-11T00:00:00Z"; + const detail = { + principalId: "eu_test", + billing: { networkFeeUsdMicros: "2982", manifestCount: 1 }, + assets: [ + { + id: "owned", + url: "https://provider.example/a", + unavailableAt, + role: "output", + }, + ], + submittedArguments: { prompt: "portrait" }, + result: { value: { image_url: "https://provider.example/a" } }, + } as unknown as RunDetail; + const clean = publicRunDetail(detail); + expect(clean.assets[0]).toMatchObject({ + id: "owned", + unavailableAt, + role: "output", + }); + expect(clean.billing).toEqual(detail.billing); + expect(clean.submittedArguments).toEqual(detail.submittedArguments); +}); +it("captures distinct explicit expiries, keeps availability guarantees separate, and leaves unknown expiry unknown", () => { + const a = "2026-09-12T00:00:00.000Z", + b = "2026-09-13T00:00:00.000Z"; + expect( + extractRunOutputs({ + images: [ + { url: "https://p.example/a", expiresAt: a }, + { url: "https://p.example/b", available_until: b }, + { url: "https://p.example/c", expiresAt: "invalid" }, + ], + }) + ).toEqual([ + { url: "https://p.example/a", mediaKind: "image", expiresAt: a }, + { + url: "https://p.example/b", + mediaKind: "image", + availableUntil: b, + }, + { url: "https://p.example/c", mediaKind: "image" }, + ]); + expect( + extractRunOutputs({ imageUrl: "https://p.example/a", expiresAt: a })[0] + ?.expiresAt + ).toBe(a); +}); + +it("strips provider URLs under generic output/data/result wrappers", () => { + const url = "https://provider.example/file?token=secret"; + expect( + sanitizePublicMedia( + { + output: url, + data: url, + result: url, + nested: { output: { data: url } }, + prompt: `see ${url}`, + text: url, + website: "https://example.com", + }, + [], + "eu_test" + ) + ).toEqual({ + nested: { output: {} }, + prompt: `see ${url}`, + text: url, + website: "https://example.com", + }); +}); + +it("strips compound provider URL keys including credentialed values", () => { + const token = "https://v3.fal.media/files/x?token=secret"; + const queue = "https://queue.fal.run/fal-ai/flux/requests/id/status"; + expect( + sanitizePublicMedia( + { + output_url: token, + outputUrl: token, + outputURL: token, + preview_url: token, + previewUrl: token, + download_url: token, + status_url: queue, + statusUrl: queue, + response_url: "https://queue.fal.run/fal-ai/flux/requests/id", + responseURI: queue, + asset2Url: token, + prompt: `see ${token}`, + website: "https://example.com", + }, + [], + "eu_test" + ) + ).toEqual({ prompt: `see ${token}`, website: "https://example.com" }); +}); + +it("drops unsupported 3D media URLs without removing ordinary model identifiers", () => { + expect( + sanitizePublicMedia( + { + model: "fal-ai/model", + model_mesh: "ftp://provider.example/a.glb", + textures: [ + "ftp://p.example/a.png", + { url: "https://p.example/b?token=secret" }, + ], + preview_image: { url: "https://p.example/c?token=secret" }, + }, + [], + "eu_test" + ) + ).toEqual({ model: "fal-ai/model", textures: [{}], preview_image: {} }); +}); diff --git a/tests/contracts/receipt-precision.test.ts b/tests/contracts/receipt-precision.test.ts new file mode 100644 index 00000000..36c24786 --- /dev/null +++ b/tests/contracts/receipt-precision.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import { sanitizeBillingReceipt } from "@/lib/console/billing-receipts"; +import type { SignedTicketRequestRow } from "@/lib/console/account-usage"; +const row = (fields: Record) => + ({ + eventId: "evt", + gatewayRequestId: "job", + ...fields, + }) as SignedTicketRequestRow; +it("accepts exact column boundaries", () => { + expect( + sanitizeBillingReceipt( + row({ + networkFeeUsdMicros: "9".repeat(60) + "." + "9".repeat(18), + feeWei: "9".repeat(78), + }) + ) + ).not.toBeNull(); +}); +it.each([ + { networkFeeUsdMicros: "9".repeat(61) }, + { pixels: "0." + "1".repeat(19) }, + { feeWei: "1.1" }, + { feeWei: "1.0" }, + { feeWei: "9".repeat(79) }, +])("rejects out-of-range receipts before a batch write", (fields) => { + const batch = [row(fields), row({ feeWei: "42" })] + .map(sanitizeBillingReceipt) + .filter(Boolean); + expect(batch).toHaveLength(1); +}); diff --git a/tests/contracts/run-execution.test.ts b/tests/contracts/run-execution.test.ts index cc614d12..01d24cac 100644 --- a/tests/contracts/run-execution.test.ts +++ b/tests/contracts/run-execution.test.ts @@ -22,6 +22,7 @@ function fixture() { const run = { ...owner, id: "run_test" } as RunDetail; const deps: ExecutionDependencies = { store: { + recordRunPaymentManifest: vi.fn().mockResolvedValue(undefined), resolveRunOwner: vi.fn().mockResolvedValue(owner), createRun: vi.fn().mockResolvedValue(run), transitionRun: vi.fn().mockResolvedValue(run), @@ -142,6 +143,60 @@ describe("durable MCP execution", () => { ); expect(reply.payload.billable_units).toBe(2.5); }); + it("returns asset ids and first-party URLs without leaking provider media URLs", async () => { + const deps = fixture(); + const providerUrl = "https://v3b.fal.media/files/output.mp4"; + vi.mocked(deps.infer).mockResolvedValue({ + gatewayRequestId: "job_test", + data: { video_urls: [providerUrl] }, + status: "COMPLETED", + videoUrl: providerUrl, + } as never); + vi.mocked(deps.store.transitionRun).mockImplementation( + async (_owner, _id, transition) => + ({ + ...owner, + id: "run_test", + assets: + transition.eventKey === "dispatch-returned" + ? [ + { + id: "asset_123", + url: providerUrl, + mediaType: "video", + providerRequestId: null, + availableUntil: null, + expiresAt: null, + unavailableAt: null, + hiddenAt: null, + createdAt: "2026-09-09T12:00:00.000Z", + }, + ] + : [], + }) as RunDetail + ); + + const response = await executeDurableRun( + principal, + { capability: "video" }, + deps + ); + expect(response.payload.url).toMatch( + /^http:\/\/localhost:3000\/api\/assets\/asset_123\?exp=\d+&sig=[A-Za-z0-9_-]+$/ + ); + expect(response.payload.assets).toEqual([ + { + id: "asset_123", + url: expect.stringMatching( + /^http:\/\/localhost:3000\/api\/assets\/asset_123\?exp=\d+&sig=[A-Za-z0-9_-]+$/ + ), + media_type: "video", + }, + ]); + expect(JSON.stringify(response.payload)).not.toContain("fal.media"); + expect(response.payload).not.toHaveProperty("status_url"); + expect(response.payload).not.toHaveProperty("response_url"); + }); it("persists interrupted execution as unknown, not failed", async () => { const deps = fixture(); vi.mocked(deps.infer).mockRejectedValue(new Error("timeout")); @@ -214,3 +269,185 @@ describe("durable MCP execution", () => { } ); }); + +it("retries accepted payment persist on a transient store failure", async () => { + const deps = fixture(); + let acceptedAttempts = 0; + vi.mocked(deps.store.recordRunPaymentManifest).mockImplementation( + async (_owner, _id, payment) => { + if (payment.phase === "accepted" && ++acceptedAttempts === 1) + throw new Error("db unavailable"); + } + ); + vi.mocked(deps.infer).mockImplementation(async ({ onPayment }) => { + await onPayment({ manifestId: "manifest-1", phase: "prepared" }); + await onPayment({ manifestId: "manifest-1", phase: "accepted" }); + return { + gatewayRequestId: "job_test", + data: { text: "ok" }, + status: "succeeded", + url: null, + billableUnits: null, + } as never; + }); + const reply = await executeDurableRun( + principal, + { capability: "test" }, + deps + ); + expect(reply.isError).toBe(false); + expect(acceptedAttempts).toBe(2); + expect(deps.store.recordRunPaymentManifest).toHaveBeenCalledTimes(3); +}); + +it("aborts after payment persist retries are exhausted", async () => { + const deps = fixture(); + vi.mocked(deps.store.recordRunPaymentManifest).mockRejectedValue( + new Error("db unavailable") + ); + vi.mocked(deps.infer).mockImplementation(async ({ onPayment }) => { + await onPayment({ manifestId: "manifest-1", phase: "accepted" }); + return { + gatewayRequestId: "job_test", + data: { text: "ok" }, + status: "succeeded", + url: null, + billableUnits: null, + } as never; + }); + const reply = await executeDurableRun( + principal, + { capability: "test" }, + deps + ); + expect(reply.isError).toBe(true); + expect(deps.store.recordRunPaymentManifest).toHaveBeenCalledTimes(3); + expect(deps.store.transitionRun).toHaveBeenCalledWith( + owner, + "run_test", + expect.objectContaining({ + status: "unknown", + errorCode: "execution_outcome_unknown", + }) + ); +}); + +it("records every payment phase against the run even when inference fails afterward", async () => { + const deps = fixture(); + vi.mocked(deps.infer).mockImplementation(async ({ onPayment }) => { + for (const manifestId of ["failed-attempt", "successful-attempt"]) + for (const phase of ["prepared", "accepted"] as const) + await onPayment({ manifestId, phase }); + throw new Error("provider unavailable"); + }); + await executeDurableRun(principal, { capability: "test" }, deps); + expect(deps.store.recordRunPaymentManifest).toHaveBeenCalledTimes(4); + expect(deps.store.recordRunPaymentManifest).toHaveBeenLastCalledWith( + owner, + "run_test", + { manifestId: "successful-attempt", phase: "accepted" } + ); +}); + +it("persists explicit expiry and sanitizes all returned media with partial capture", async () => { + const deps = fixture(); + const expiresAt = "2026-10-01T00:00:00.000Z"; + vi.mocked(deps.infer).mockResolvedValue({ + gatewayRequestId: "job_test", + status: "succeeded", + url: null, + billableUnits: null, + data: { + images: [ + { url: "https://provider.example/owned", expiresAt }, + { url: "https://provider.example/missing" }, + { url: "https://provider.example/signed?token=private" }, + ], + output: "https://provider.example/download?token=private", + data: "https://provider.example/download?token=private", + result: { output: "https://provider.example/download?token=private" }, + output_url: "https://provider.example/download?token=private", + outputUrl: "https://provider.example/download?token=private", + outputURL: "https://provider.example/download?token=private", + preview_url: "https://provider.example/preview?token=private", + previewUrl: "https://provider.example/preview?token=private", + status_url: "https://queue.fal.run/fal-ai/flux/requests/id/status", + statusUrl: "https://queue.fal.run/fal-ai/flux/requests/id/status", + responseURI: "https://queue.fal.run/fal-ai/flux/requests/id", + asset2Url: "https://provider.example/signed?token=private", + }, + } as unknown as Awaited>); + vi.mocked(deps.store.transitionRun).mockResolvedValue({ + ...owner, + id: "run_test", + assets: [ + { id: "owned", url: "https://provider.example/owned", role: "output" }, + ], + } as RunDetail); + const result = await executeDurableRun( + principal, + { capability: "image" }, + deps + ); + expect(deps.store.transitionRun).toHaveBeenCalledWith( + owner, + "run_test", + expect.objectContaining({ + assets: expect.arrayContaining([ + expect.objectContaining({ + url: "https://provider.example/owned", + expiresAt, + }), + ]), + }) + ); + expect(JSON.stringify(result.payload)).not.toMatch( + /provider.example|private|REDACTED|queue\.fal\.run/ + ); + expect(JSON.stringify(result.payload)).toContain("/api/assets/owned"); +}); + +it("writes usage after inference and does not fail the job when billing refresh throws", async () => { + const deps = fixture(); + deps.refreshBilling = vi.fn().mockRejectedValue(new Error("usage lag")); + const reply = await executeDurableRun( + principal, + { capability: "test" }, + deps + ); + expect(reply.isError).toBe(false); + expect(reply.payload.run_id).toBe("run_test"); + expect(deps.refreshBilling).toHaveBeenCalledWith({ + owner, + runId: "run_test", + gatewayRequestId: vi.mocked(deps.infer).mock.calls[0][0].gatewayRequestId, + }); +}); + +it("does not refresh billing when execution was never dispatched", async () => { + const deps = fixture(); + deps.refreshBilling = vi.fn(); + vi.mocked(deps.store.createRun).mockRejectedValue(new Error("db")); + await executeDurableRun(principal, { capability: "test" }, deps); + expect(deps.refreshBilling).not.toHaveBeenCalled(); +}); + +it("refreshes billing after an interrupted paid attempt", async () => { + const deps = fixture(); + deps.refreshBilling = vi.fn().mockResolvedValue(undefined); + vi.mocked(deps.infer).mockImplementation(async ({ onPayment }) => { + await onPayment({ manifestId: "manifest-1", phase: "accepted" }); + throw new Error("provider unavailable"); + }); + const reply = await executeDurableRun( + principal, + { capability: "test" }, + deps + ); + expect(reply.isError).toBe(true); + expect(deps.refreshBilling).toHaveBeenCalledWith({ + owner, + runId: "run_test", + gatewayRequestId: vi.mocked(deps.infer).mock.calls[0][0].gatewayRequestId, + }); +}); diff --git a/tests/contracts/run-history-session.test.tsx b/tests/contracts/run-history-session.test.tsx index 445ad10a..be34fceb 100644 --- a/tests/contracts/run-history-session.test.tsx +++ b/tests/contracts/run-history-session.test.tsx @@ -29,6 +29,32 @@ function deferred() { return { promise, resolve }; } +it("keeps the current run list visible while reload fetches a newer page", async () => { + const next = deferred(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(page("run-a")) + .mockReturnValueOnce(next.promise) + ); + const hook = renderHook(() => useRunHistory("/api/console/runs", true, {})); + await waitFor(() => + expect(hook.result.current.page?.items[0].id).toBe("run-a") + ); + act(() => { + hook.result.current.reload(); + }); + expect(hook.result.current.page?.items[0].id).toBe("run-a"); + expect(hook.result.current.loading).toBe(false); + await act(async () => { + next.resolve(page("run-b")); + }); + await waitFor(() => + expect(hook.result.current.page?.items[0].id).toBe("run-b") + ); +}); + it("invalidates run list data when the enabled account changes", async () => { const next = deferred(); vi.stubGlobal( @@ -82,6 +108,51 @@ it("ignores a late run page from an old account and hides data when disabled", a expect(hook.result.current.loading).toBe(false); }); +it("aborts pagination when reloading so a later loadMore is not blocked", async () => { + const more = deferred(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(page("run-a", "cursor-1")) + .mockReturnValueOnce(more.promise) + .mockResolvedValueOnce(Response.json({}, { status: 500 })) + .mockResolvedValueOnce(page("run-older")) + ); + const hook = renderHook(() => useRunHistory("/api/console/runs", true, {})); + await waitFor(() => + expect(hook.result.current.page?.nextCursor).toBe("cursor-1") + ); + let firstMore!: Promise; + act(() => { + firstMore = hook.result.current.loadMore(); + }); + expect(hook.result.current.loadingMore).toBe(true); + act(() => { + hook.result.current.reload(); + }); + expect(hook.result.current.loadingMore).toBe(false); + await waitFor(() => expect(hook.result.current.error).toBeTruthy()); + expect(hook.result.current.loadingMore).toBe(false); + await act(async () => { + await hook.result.current.loadMore(); + }); + await waitFor(() => + expect(hook.result.current.page?.items.map((row) => row.id)).toEqual([ + "run-a", + "run-older", + ]) + ); + await act(async () => { + more.resolve(page("run-stale")); + await firstMore; + }); + expect(hook.result.current.page?.items.map((row) => row.id)).toEqual([ + "run-a", + "run-older", + ]); +}); + it("does not append another account's delayed continuation to the current run list", async () => { const more = deferred(); vi.stubGlobal( diff --git a/tests/contracts/run-http.test.ts b/tests/contracts/run-http.test.ts index ec1e83c0..c1c1e2e8 100644 --- a/tests/contracts/run-http.test.ts +++ b/tests/contracts/run-http.test.ts @@ -8,6 +8,9 @@ const mocks = vi.hoisted(() => ({ admin: vi.fn(), adminList: vi.fn(), adminDetail: vi.fn(), + seedPreview: vi.fn(), + previewEnabled: vi.fn(() => false), + hideLegacy: vi.fn((page) => page), })); vi.mock("@/lib/console/session-user", () => ({ requireConsoleSession: mocks.session, @@ -20,6 +23,11 @@ vi.mock("@/lib/runs/store", () => ({ listAdminRuns: mocks.adminList, getAdminRun: mocks.adminDetail, })); +vi.mock("@/lib/runs/preview-fixtures", () => ({ + ensurePreviewRunFixtures: mocks.seedPreview, + previewFixturesEnabled: mocks.previewEnabled, + withoutLegacyPreviewFixtures: mocks.hideLegacy, +})); import { GET as list } from "@/app/api/console/runs/route"; import { GET as detail } from "@/app/api/console/runs/[id]/route"; import { GET as adminList } from "@/app/api/admin/runs/route"; @@ -47,11 +55,30 @@ it("derives ownership from session and ignores submitted identity", async () => expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); expect(mocks.owner).toHaveBeenCalledWith("external"); - expect(mocks.list).toHaveBeenCalledWith(owner, { - limit: 2, - status: "unknown", - cursor: undefined, - search: undefined, + expect(mocks.list).toHaveBeenCalledWith( + owner, + { + limit: 2, + status: "unknown", + cursor: undefined, + search: undefined, + }, + { excludeLegacyPreview: false } + ); +}); +it("automatically seeds owner-scoped preview history before listing", async () => { + mocks.previewEnabled.mockReturnValue(true); + const response = await list( + new Request("https://preview.example/api/console/runs?limit=10") + ); + expect(response.status).toBe(200); + expect(mocks.seedPreview).toHaveBeenCalledWith( + owner, + "https://preview.example" + ); + expect(mocks.list).toHaveBeenCalledAfter(mocks.seedPreview); + expect(mocks.list).toHaveBeenCalledWith(owner, expect.anything(), { + excludeLegacyPreview: true, }); }); it("fails closed for mismatched canonical identity and invalid filters", async () => { @@ -70,6 +97,72 @@ it("fails closed for mismatched canonical identity and invalid filters", async ( ).toBe(400); expect(mocks.list).not.toHaveBeenCalled(); }); +it("strips provider queue URLs from user and admin run detail", async () => { + const queue = "https://queue.fal.run/fal-ai/flux/requests/req-1/status"; + const leaky = { + principalId: owner.principalId, + userId: owner.userId, + externalAccountId: owner.externalAccountId, + id: "run_1", + gatewayRequestId: "job_1", + providerRequestId: null, + provider: "fal", + source: "mcp", + capability: "fal-ai/flux", + modelId: null, + endpoint: null, + status: "running", + submittedArguments: { prompt: "portrait" }, + result: null, + captureVersion: 1, + captureRedactedPaths: [], + errorCode: null, + errorMessage: null, + version: 1, + createdAt: "2026-09-11T00:00:00.000Z", + updatedAt: "2026-09-11T00:00:00.000Z", + startedAt: "2026-09-11T00:00:00.000Z", + completedAt: null, + email: null, + billing: null, + assets: [], + events: [ + { + id: "evt_progress", + eventKey: `progress:IN_QUEUE:req-1:${queue}`, + status: "running", + createdAt: "2026-09-11T00:00:00.000Z", + metadata: { + providerStatus: "IN_QUEUE", + queue: { statusUrl: queue }, + }, + }, + ], + }; + mocks.detail.mockResolvedValue(leaky); + const user = await detail(new Request("https://console.invalid"), { + params: Promise.resolve({ id: "run_1" }), + }); + const actor = { userId: "admin", adminGrantId: "grant", signupId: "signup" }; + mocks.admin.mockResolvedValue(actor); + mocks.adminDetail.mockResolvedValue(leaky); + const admin = await adminDetail(new Request("https://console.invalid"), { + params: Promise.resolve({ id: "run_1" }), + }); + expect(user.status).toBe(200); + expect(admin.status).toBe(200); + for (const response of [user, admin]) { + const body = await response.json(); + expect(JSON.stringify(body)).not.toContain("queue.fal.run"); + expect(body.events[0]).toEqual({ + id: "evt_progress", + eventKey: "progress:IN_QUEUE:req-1", + status: "running", + createdAt: "2026-09-11T00:00:00.000Z", + metadata: { providerStatus: "IN_QUEUE" }, + }); + } +}); it("does not expose foreign or missing runs and masks driver failures", async () => { mocks.detail.mockResolvedValue(null); expect( diff --git a/tests/contracts/run-outputs.test.ts b/tests/contracts/run-outputs.test.ts index 514c99df..ab88136a 100644 --- a/tests/contracts/run-outputs.test.ts +++ b/tests/contracts/run-outputs.test.ts @@ -15,3 +15,23 @@ it("recognizes multiple output objects in returned arrays without treating promp { url: "https://media.example.com/b.mp4", mediaKind: "video" }, ]); }); + +it("captures 3D model, texture and preview assets without confusing model identifiers or prompts", () => { + const result = { + model: { url: "https://provider.example/model.glb" }, + textures: [{ url: "https://provider.example/texture.png" }], + preview_image: { url: "https://provider.example/preview.png" }, + prompt: "https://example.com/prompt", + }; + expect(extractRunOutputs(result)).toEqual([ + { url: result.model.url, mediaKind: "model" }, + { url: result.preview_image.url, mediaKind: "image" }, + { url: result.textures[0]!.url, mediaKind: "image" }, + ]); + expect( + extractRunOutputs({ + model: "fal-ai/model", + model_id: "https://example.com/not-output", + }) + ).toEqual([]); +}); diff --git a/tests/contracts/run-recovery.test.ts b/tests/contracts/run-recovery.test.ts index 90ca65e9..9e7c0340 100644 --- a/tests/contracts/run-recovery.test.ts +++ b/tests/contracts/run-recovery.test.ts @@ -32,7 +32,10 @@ describe("public queue recovery", () => { .mockResolvedValueOnce( Response.json({ images: [ - { url: "https://media.fal.media/a.png" }, + { + url: "https://media.fal.media/a.png", + expiresAt: "2026-10-01T00:00:00.000Z", + }, { url: "https://media.fal.media/b.png" }, ], }) @@ -53,7 +56,11 @@ describe("public queue recovery", () => { expect.objectContaining({ status: "succeeded", assets: [ - { url: "https://media.fal.media/a.png", mediaType: "image" }, + { + url: "https://media.fal.media/a.png", + mediaType: "image", + expiresAt: "2026-10-01T00:00:00.000Z", + }, { url: "https://media.fal.media/b.png", mediaType: "image" }, ], }) diff --git a/tests/contracts/run-security.test.ts b/tests/contracts/run-security.test.ts index 24fcd3a1..dd4ef47d 100644 --- a/tests/contracts/run-security.test.ts +++ b/tests/contracts/run-security.test.ts @@ -129,6 +129,10 @@ describe("run capture privacy contract", () => { ], image: { url: "https://v3.fal.media/files/a.png" }, video: { url: "https://v3.fal.media/files/c.mp4" }, + video_urls: [ + "https://v3.fal.media/files/d.mp4", + "https://v3.fal.media/files/e.mp4", + ], status_url: "https://queue.fal.run/fal-ai/flux/requests/request-1/status", prompt: "https://example.invalid/not-an-output.png", }); @@ -136,6 +140,8 @@ describe("run capture privacy contract", () => { "https://v3.fal.media/files/a.png", "https://v3.fal.media/files/c.mp4", "https://v3.fal.media/files/b.png", + "https://v3.fal.media/files/d.mp4", + "https://v3.fal.media/files/e.mp4", ]); expect( extractRunOutputs({ @@ -186,6 +192,7 @@ it("keeps a late queue receipt despite repeated provider status and broken clien }; const record = { ...owner, id: "run-test" } as RunDetail; const store = { + recordRunPaymentManifest: vi.fn().mockResolvedValue(undefined), resolveRunOwner: vi.fn().mockResolvedValue(owner), createRun: vi.fn().mockResolvedValue(record), transitionRun: vi.fn().mockResolvedValue(record), diff --git a/tests/contracts/session-allowance.test.tsx b/tests/contracts/session-allowance.test.tsx index 9f3b72a2..88b27d3c 100644 --- a/tests/contracts/session-allowance.test.tsx +++ b/tests/contracts/session-allowance.test.tsx @@ -33,6 +33,14 @@ vi.mock("@/lib/console/session-user", () => ({ vi.mock("@/lib/console/pymthouse-bff", () => ({ fetchAccountUsageForExternalUser: vi.fn(), })); +const preview = vi.hoisted(() => ({ + enabled: vi.fn(() => false), + usage: vi.fn(), +})); +vi.mock("@/lib/runs/preview-fixtures", () => ({ + previewFixturesEnabled: preview.enabled, + previewAccountUsage: preview.usage, +})); import SidebarUsageCard from "@/components/console/SidebarUsageCard"; import { GET } from "@/app/api/pymthouse/account-usage/route"; @@ -45,6 +53,29 @@ import { fetchAccountUsageForExternalUser } from "@/lib/console/pymthouse-bff"; beforeEach(() => { fixture.connected = true; vi.resetAllMocks(); + preview.enabled.mockReturnValue(false); +}); +it("serves a preview balance without contacting PymtHouse", async () => { + vi.mocked(requireConsoleSession).mockResolvedValue({ + externalUserId: "session-user", + } as never); + preview.enabled.mockReturnValue(true); + preview.usage.mockReturnValue({ + balance: { externalUserId: "session-user", balanceUsdMicros: "4986174" }, + }); + const response = await GET( + new NextRequest( + "http://localhost/api/pymthouse/account-usage?days=7&window=rolling&includePrior=0" + ) + ); + expect(response.status).toBe(200); + expect(preview.usage).toHaveBeenCalledWith({ + externalUserId: "session-user", + periodDays: 7, + window: "rolling", + includePrior: false, + }); + expect(fetchAccountUsageForExternalUser).not.toHaveBeenCalled(); }); afterEach(cleanup); diff --git a/tests/contracts/ui-primitive-dependencies.test.ts b/tests/contracts/ui-primitive-dependencies.test.ts new file mode 100644 index 00000000..9546320c --- /dev/null +++ b/tests/contracts/ui-primitive-dependencies.test.ts @@ -0,0 +1,18 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("registry UI primitive policy", () => { + it("does not introduce Radix packages or imports", async () => { + const files = [ + "package.json", + "components/ui/dialog.tsx", + "components/ui/tooltip.tsx", + "components/ui/badge.tsx", + "components/ui/button.tsx", + ]; + const contents = await Promise.all( + files.map((file) => readFile(new URL(`../../${file}`, import.meta.url), "utf8")) + ); + expect(contents.join("\n")).not.toMatch(/(?:@radix-ui\/|["']radix-ui["'])/); + }); +}); diff --git a/tests/integration/mcp-assets.test.ts b/tests/integration/mcp-assets.test.ts index 4fe51454..133a6e75 100644 --- a/tests/integration/mcp-assets.test.ts +++ b/tests/integration/mcp-assets.test.ts @@ -32,9 +32,11 @@ describe("mcp asset store helpers", () => { createdAt: "2026-09-05T01:00:00.000Z", }); expect(asset.gatewayRequestId).toBe("job_abc"); - expect(serializeAsset(asset)).toEqual({ + expect(serializeAsset(asset, "eu_test")).toEqual({ id: "asset_1", - url: "https://v3b.fal.media/files/x.jpg", + url: expect.stringMatching( + /^http:\/\/localhost:3000\/api\/assets\/asset_1\?exp=\d+&sig=[A-Za-z0-9_-]+$/ + ), capability: "livepeer-example/fal-flux-schnell", created_at: "2026-09-05T01:00:00.000Z", gateway_request_id: "job_abc", diff --git a/tests/integration/preview-run-repair.test.ts b/tests/integration/preview-run-repair.test.ts new file mode 100644 index 00000000..8f352a55 --- /dev/null +++ b/tests/integration/preview-run-repair.test.ts @@ -0,0 +1,175 @@ +import { randomUUID, createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { sql, eq } from "drizzle-orm"; +import { expect, it, vi } from "vitest"; +import { openIntegrationDatabase } from "@/tests/support/isolated-db"; +import * as schema from "@/lib/db/schema"; +vi.mock("server-only", () => ({})); +vi.mock("@/lib/db", () => ({ getDb: vi.fn() })); +vi.mock("@/lib/external-accounts/service", () => ({ + configuredPymthouseScope: () => ({ + service: "pymthouse", + issuer: "https://issuer.invalid", + appId: "run-tests", + }), + findExternalAccountOwner: vi.fn(), +})); +import { getDb } from "@/lib/db"; +import { ensurePreviewRunFixtures } from "@/lib/runs/preview-fixtures"; +import { + createRun, + transitionRun, + withPreviewRunFixtures, +} from "@/lib/runs/store"; + +it.skipIf(!process.env.TEST_DATABASE_URL)( + "serializes fixture seeding, rolls back failures, and repairs partial owner-scoped fixtures", + async () => { + const { client } = await openIntegrationDatabase(process.env); + const db = drizzle(client, { schema }); + const namespace = `fixture_${randomUUID().replaceAll("-", "")}`; + const transaction: typeof db.transaction = (work, config) => + db.transaction(async (tx) => { + await tx.execute( + sql.raw(`SET LOCAL search_path TO "${namespace}", public`) + ); + return work(tx); + }, config); + try { + await db.execute(sql.raw(`CREATE SCHEMA "${namespace}"`)); + await transaction(async (tx) => { + const journal = JSON.parse( + readFileSync("drizzle-baseline/meta/_journal.json", "utf8") + ); + for (const { tag } of journal.entries) + for (const statement of readFileSync( + `drizzle-baseline/${tag}.sql`, + "utf8" + ).split("--> statement-breakpoint")) + if (statement.trim()) + await tx.execute( + sql.raw(statement.replaceAll('"public".', `"${namespace}".`)) + ); + }); + vi.mocked(getDb).mockReturnValue({ ...db, transaction } as typeof db); + vi.stubEnv("VERCEL_ENV", "preview"); + vi.stubEnv("CONSOLE_PREVIEW_FIXTURES", "1"); + vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://preview.example"); + const makeOwner = async (principalId: string) => + transaction(async (tx) => { + const [user] = await tx.insert(schema.users).values({}).returning(); + const [account] = await tx + .insert(schema.externalAccounts) + .values({ + userId: user!.id, + externalUserId: principalId, + service: "pymthouse", + issuer: "https://issuer.invalid", + appId: "run-tests", + source: "test", + }) + .returning(); + return { + principalId, + userId: user!.id, + externalAccountId: account!.id, + }; + }); + const owner = await makeOwner("eu_fixture_one"), + other = await makeOwner("eu_fixture_two"); + const suffix = createHash("sha256") + .update(owner.principalId) + .digest("hex") + .slice(0, 12); + const id = `run_preview_v2_${suffix}_portrait`; + const input = { + id, + gatewayRequestId: `job_preview_v2_${suffix}_portrait`, + capability: "fal-ai/flux/schnell", + submittedArguments: { inputs: { prompt: "preserved" } }, + }; + await expect( + withPreviewRunFixtures(owner, async (store) => { + await store.createRun(owner, input); + throw Error("injected_failure"); + }) + ).rejects.toThrow("injected_failure"); + expect( + await transaction((tx) => tx.select().from(schema.runs)) + ).toHaveLength(0); + await createRun(owner, input); + await transitionRun(owner, id, { + eventKey: "dispatch-returned", + status: "succeeded", + result: { value: { text: "preserved result" } }, + }); + const results = await Promise.all([ + ensurePreviewRunFixtures(owner, "https://preview.example"), + ensurePreviewRunFixtures(owner, "https://preview.example"), + ]); + expect(results.map((r) => r.createdCount).sort()).toEqual([0, 3]); + expect( + (await ensurePreviewRunFixtures(owner, "https://preview.example")) + .createdCount + ).toBe(0); + await ensurePreviewRunFixtures(other, "https://preview.example"); + await transaction(async (tx) => { + expect(await tx.select().from(schema.runs)).toHaveLength(8); + const receipts = await tx.select().from(schema.runUsageReceipts); + expect(receipts).toHaveLength(8); + const assets = await tx.select().from(schema.mcpAssets); + expect(assets).toHaveLength(4); + const links = await tx.select().from(schema.runAssetLinks); + expect(links).toHaveLength(6); + const [original] = await tx + .select() + .from(schema.runs) + .where(eq(schema.runs.id, id)); + expect(original!.submittedArguments).toEqual(input.submittedArguments); + expect(original!.result).toEqual({ + value: { text: "preserved result" }, + }); + expect( + assets.filter((a) => a.principalId === owner.principalId) + ).toHaveLength(2); + }); + const expiry = "2026-12-01T00:00:00.000Z"; + const captured = { + id: `asset_preview_v2_${suffix}_portrait`, + url: "https://preview.example/images/console/explore/flux-schnell.webp", + mediaType: "image/webp", + }; + await withPreviewRunFixtures(owner, (store) => + store.transitionRun(owner, id, { + eventKey: "dispatch-returned", + status: "succeeded", + assets: [{ ...captured, expiresAt: expiry }], + }) + ); + await withPreviewRunFixtures(owner, (store) => + store.transitionRun(owner, id, { + eventKey: "dispatch-returned", + status: "succeeded", + assets: [captured], + }) + ); + await transaction(async (tx) => { + const [asset] = await tx + .select() + .from(schema.mcpAssets) + .where(eq(schema.mcpAssets.id, captured.id)); + expect(asset!.expiresAt?.toISOString()).toBe(expiry); + }); + vi.stubEnv("VERCEL_ENV", "production"); + await expect( + ensurePreviewRunFixtures(owner, "https://preview.example") + ).rejects.toThrow("preview_fixtures_disabled"); + } finally { + vi.unstubAllEnvs(); + await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`)); + await client.end(); + } + }, + 90000 +); diff --git a/tests/integration/run-records.test.ts b/tests/integration/run-records.test.ts index 8b0bad36..5c0d4063 100644 --- a/tests/integration/run-records.test.ts +++ b/tests/integration/run-records.test.ts @@ -15,6 +15,10 @@ vi.mock("@/lib/external-accounts/service", () => ({ }), findExternalAccountOwner: vi.fn(), })); +import { + recordRunPaymentManifest, + recordManifestUsage, +} from "@/lib/runs/store"; import { getDb } from "@/lib/db"; import { claimReconciliationJobs, @@ -31,6 +35,7 @@ import { forgetAssets, listAssets } from "@/lib/mcp/store"; it.skipIf(!process.env.TEST_DATABASE_URL)( "stores complete run lifecycle and hidden assets with transactional idempotency and owner isolation", async () => { + vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://preview.example"); const { client } = await openIntegrationDatabase(process.env); const rollback = new Error("rollback_run_store"); try { @@ -111,6 +116,44 @@ it.skipIf(!process.env.TEST_DATABASE_URL)( ], }); expect(succeeded.assets).toHaveLength(2); + expect( + await tx + .select() + .from(schema.runAssetLinks) + .where(eq(schema.runAssetLinks.runId, created.id)) + ).toHaveLength(2); + const reusedAsset = succeeded.assets[0]!; + const consumer = await createRun(owner, { + id: "run-consumer", + gatewayRequestId: "job-consumer", + capability: "image-to-image", + submittedArguments: { + inputs: { + reference_image: `https://preview.example/api/assets/${reusedAsset.id}?exp=9999999999&sig=fixture`, + }, + }, + }); + expect(consumer.assets).toEqual([ + expect.objectContaining({ id: reusedAsset.id, role: "input" }), + ]); + expect( + await tx + .select() + .from(schema.runAssetLinks) + .where(eq(schema.runAssetLinks.runId, consumer.id)) + ).toEqual([ + expect.objectContaining({ + assetId: reusedAsset.id, + direction: "input", + role: "reference_image", + parameterPath: "inputs.reference_image", + ordinal: 0, + }), + ]); + await tx + .update(schema.runs) + .set({ createdAt: new Date("2023-01-01"), updatedAt: new Date() }) + .where(eq(schema.runs.id, consumer.id)); expect(succeeded.result?.value).toEqual(["a", { image: "b" }]); expect( ( @@ -165,7 +208,7 @@ it.skipIf(!process.env.TEST_DATABASE_URL)( const first = await listOwnRuns(owner, { limit: 1 }); expect(first.items.map((row) => row.id)).toEqual(["run-1"]); expect(first.items[0]).not.toHaveProperty("submittedArguments"); - expect(first.counts.total).toBe(2); + expect(first.counts.total).toBe(3); const next = await listOwnRuns(owner, { limit: 1, cursor: first.nextCursor!, @@ -203,7 +246,12 @@ it.skipIf(!process.env.TEST_DATABASE_URL)( { eventId: "event-1", gatewayRequestId: "job-1", - metadata: { fee: "0.01" }, + metadata: { networkFeeUsdMicros: "0.932" }, + }, + { + eventId: "event-2", + gatewayRequestId: "job-1", + metadata: { networkFeeUsdMicros: "2.068" }, }, ]; await recordRunUsage(owner, usage); @@ -216,9 +264,118 @@ it.skipIf(!process.env.TEST_DATABASE_URL)( afterUsage?.events.filter((event) => event.eventKey.startsWith("usage:") ) - ).toHaveLength(1); + ).toHaveLength(2); + expect(afterUsage?.billing).toEqual({ + networkFeeUsdMicros: "3", + receiptCount: 2, + }); + expect( + await tx + .select() + .from(schema.runUsageReceipts) + .where(eq(schema.runUsageReceipts.runId, created.id)) + ).toHaveLength(2); + expect( + (await listOwnRuns(owner, { limit: 10 })).items.find( + (item) => item.id === created.id + )?.billing + ).toEqual({ networkFeeUsdMicros: "3", receiptCount: 2 }); expect(afterUsage?.version).toBe(beforeUsage?.version); expect(afterUsage?.status).toBe("succeeded"); + // Snapshot refresh replaces amounts and must never add receipt totals again. + await recordRunPaymentManifest(owner, created.id, { + manifestId: "paid-1", + phase: "prepared", + }); + await recordRunPaymentManifest(owner, created.id, { + manifestId: "paid-1", + phase: "accepted", + }); + await recordRunPaymentManifest(owner, created.id, { + manifestId: "paid-1", + phase: "prepared", + }); + await recordRunPaymentManifest(owner, created.id, { + manifestId: "paid-2", + phase: "accepted", + }); + await expect( + recordRunPaymentManifest( + { ...owner, userId: randomUUID() }, + created.id, + { manifestId: "foreign", phase: "prepared" } + ) + ).rejects.toThrow("run_owner_mismatch"); + await expect( + recordRunPaymentManifest(owner, "run-2", { + manifestId: "paid-1", + phase: "prepared", + }) + ).rejects.toThrow("payment_manifest_already_linked"); + const snapshots = [ + { + manifestId: "paid-1", + networkFeeUsdMicros: "2982", + feeWei: "123", + }, + { manifestId: "paid-2", networkFeeUsdMicros: "10", feeWei: "1" }, + ]; + expect( + await recordManifestUsage( + owner, + [created.id], + snapshots.slice(0, 1), + new Date("2026-09-11T00:00:00Z") + ) + ).toEqual([created.id]); + expect((await getOwnRun(owner, created.id))?.billing).toBeNull(); + await recordManifestUsage( + owner, + [created.id], + snapshots, + new Date("2026-09-11T00:01:00Z") + ); + expect( + await recordManifestUsage( + owner, + [created.id], + snapshots, + new Date("2026-09-11T00:02:00Z") + ) + ).toEqual([]); + await recordManifestUsage( + owner, + [created.id], + [{ ...snapshots[0], networkFeeUsdMicros: "1" }], + new Date("2026-09-10") + ); + expect((await getOwnRun(owner, created.id))?.billing).toEqual({ + networkFeeUsdMicros: "2992", + manifestCount: 2, + }); + expect( + (await listOwnRuns(owner, { limit: 100 })).items.find( + (r) => r.id === created.id + )?.billing + ).toEqual({ networkFeeUsdMicros: "2992", manifestCount: 2 }); + await recordManifestUsage( + owner, + [created.id], + [{ ...snapshots[0], networkFeeUsdMicros: "3000" }], + new Date("2026-09-11T00:03:00Z") + ); + expect( + (await getOwnRun(owner, created.id))?.billing?.networkFeeUsdMicros + ).toBe("3010"); + await recordManifestUsage( + { ...owner, userId: randomUUID() }, + [created.id], + snapshots, + new Date("2026-09-12") + ); + expect( + (await getOwnRun(owner, created.id))?.billing?.networkFeeUsdMicros + ).toBe("3010"); const stale = await createRun(owner, { id: "stale", gatewayRequestId: "job-stale", @@ -368,10 +525,55 @@ it.skipIf(!process.env.TEST_DATABASE_URL)( (await tx.select().from(schema.runReconciliationJobs))[0] .completedAt ).not.toBeNull(); + // Filter obsolete preview records before both pagination and totals. + for (const id of [ + "run_preview_v2_page_1", + "run_preview_v2_page_2", + "run_preview_v2_page_3", + "run_preview_legacy_1", + "run_preview_legacy_2", + ]) { + await createRun(owner, { + id, + gatewayRequestId: `job_${id}`, + capability: "pagination-regression", + submittedArguments: {}, + }); + } + const firstPage = await listOwnRuns( + owner, + { limit: 2, search: "pagination-regression" }, + { excludeLegacyPreview: true } + ); + expect(firstPage.items).toHaveLength(2); + expect(firstPage.counts.total).toBe(3); + expect(firstPage.nextCursor).not.toBeNull(); + const lastPage = await listOwnRuns( + owner, + { + limit: 2, + search: "pagination-regression", + cursor: firstPage.nextCursor!, + }, + { excludeLegacyPreview: true } + ); + expect(lastPage.items).toHaveLength(1); + expect(lastPage.counts.total).toBe(3); + expect(lastPage.nextCursor).toBeNull(); + expect( + new Set( + [...firstPage.items, ...lastPage.items].map((item) => item.id) + ).size + ).toBe(3); + expect( + (await listOwnRuns(owner, { search: "pagination-regression" })) + .counts.total + ).toBe(5); throw rollback; }) ).rejects.toBe(rollback); } finally { + vi.unstubAllEnvs(); await client.end(); } },