Files
agents-marketplace/tests/unit/buyer-ui-smoke.test.tsx
david facf07c35f buyer: single-operator password gate (BUYER_PASSWORD + signed session cookie)
Adds an Express requireAuth middleware on /v1/* + the SPA shell and a
Vite/React login screen so anyone who reaches mp-buyers.* must authenticate
before driving the operator's wallet via /v1/submit-prompt or /v1/accept.

Why: the buyer-app sits on a public HTTP port and signs txs with a
server-held private key. Until now the only gate was network reachability.

Mechanics:
- BUYER_PASSWORD + SESSION_SECRET (>=32 chars) loaded by config.ts and
  surfaced as createApp() deps. Production refuses to start without both.
- buyer/src/auth.ts: HMAC-SHA256 signed buyer_session cookie (HttpOnly,
  SameSite=Lax, Path=/, optional Secure), 7-day rolling expiry,
  timing-safe password compare, in-memory 5/15-min per-IP rate limiter.
- /v1/auth/{login,logout,whoami}. Login is rate-limited; 6th wrong
  attempt returns 429 + Retry-After. Success resets the bucket.
- SPA boot block (__BUYER_BOOT__ injected by server.ts) is gated by the
  same cookie so the buyer's address never reaches an unauthenticated
  browser.
- New <AuthProvider>/<RequireAuth> in buyer/src/ui/state/AuthContext.tsx
  probes /v1/auth/whoami on mount and a scoped window.fetch patch drops
  to the login screen on any mid-session 401 from /v1/* (excluding
  /v1/auth/* so wrong-password 401s stay local to <Login>).
- Tailwind login card at buyer/src/ui/pages/Login.tsx. App.tsx wraps in
  <RequireAuth> and adds a "Sign out" button in the nav.

Tests: tests/unit/buyer-server-auth.test.ts covers sign/verify, rate
limiter, cookie helpers, login flow (400/401/204/429), whoami
(no-cookie/valid/tampered/expired), rolling renewal, logout, gated
/v1/*, /healthz still open, and the no-auth-deps construction shape.
Updated buyer-server-config + buyer-ui-smoke for the new required env
vars and the AuthProvider wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:52:01 +02:00

361 lines
16 KiB
TypeScript

// @vitest-environment happy-dom
/**
* buyer-ui-smoke.test.tsx — RED phase (M1-E)
*
* Category G: UI smoke tests with React Testing Library + happy-dom (~15 tests)
*
* All tests FAIL until M1-E-green because the UI components currently
* render "NOT IMPLEMENTED" stubs and the Marketplace methods throw.
*
* Design contract encoded for Catherine:
* - <App /> must render a <nav> with links to /, /tasks, /wallet
* - <Dashboard /> must call marketplace.discoverSuppliers() on mount
* - <PromptForm /> must call marketplace.submitPrompt() with correct args on submit
* - <TaskHistory /> must call getTaskHistory() and render TaskRow for each entry
* - <Wallet /> must render the configured walletKey.address
* - <SupplierCard /> "Use" button must be disabled when supplier.status === "offline"
* - Submitting an empty prompt must be prevented client-side (no SDK call)
* - A loading state must render while submitPrompt() is pending
* - An error toast/message must render when submitPrompt() rejects
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import App from "../../buyer/src/ui/App.js";
import Dashboard from "../../buyer/src/ui/pages/Dashboard.js";
import TaskHistory from "../../buyer/src/ui/pages/TaskHistory.js";
import Wallet from "../../buyer/src/ui/pages/Wallet.js";
import SupplierCard from "../../buyer/src/ui/components/SupplierCard.js";
import PromptForm from "../../buyer/src/ui/components/PromptForm.js";
import { MarketplaceProvider } from "../../buyer/src/ui/state/MarketplaceContext.js";
import { AuthProvider } from "../../buyer/src/ui/state/AuthContext.js";
import type { SupplierView } from "../../buyer/src/sdk/types.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
import {
ALL_SAMPLE_TASK_RECORDS,
TASK_COMPLETED,
} from "../fixtures/buyer-side/sample-task-records.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
import { MemoryTaskHistoryStore } from "../../buyer/src/sdk/history.js";
// ─── Mock Marketplace factory ──────────────────────────────────────────────
const buyer = buildBuyerWalletKey();
function makeMockMarketplace() {
const store = new MemoryTaskHistoryStore();
const mp = new Marketplace({
chain: new MockChainProvider(),
indexerUrl: "http://indexer.test",
walletKey: buyer,
networkParams: { networkId: 0 },
historyStore: store,
});
return mp;
}
function makeSampleSupplierView(overrides: Partial<SupplierView> = {}): SupplierView {
return {
utxo_ref: "a".repeat(64) + "#0",
supplier_pkh: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01",
capability_id: "llm.text.generate.v1",
model: "qwen2.5:0.5b",
max_output_tokens: 512,
max_processing_ms: 60_000,
price_lovelace: "2000000",
supplier_bond_lovelace: "1000000",
buyer_bond_lovelace: "1000000",
endpoint_url: "https://supplier.example.com",
detail_uri: "ipfs://Qm000",
detail_hash: "a".repeat(64),
advertised_at: 1_745_500_000_000,
status: "free",
advert_status: "Active",
current_escrow_ref: null,
last_seen_iso: "2026-04-24T00:00:00.000Z",
created_slot: 1000,
...overrides,
};
}
function wrap(element: React.ReactElement, mp: Marketplace) {
return (
<MemoryRouter>
<AuthProvider initialStatus="authenticated">
<MarketplaceProvider marketplace={mp}>
{element}
</MarketplaceProvider>
</AuthProvider>
</MemoryRouter>
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("<App /> smoke", () => {
it("renders a nav element with links for Dashboard, Tasks, and Wallet", () => {
const mp = makeMockMarketplace();
render(wrap(<App />, mp));
const nav = document.querySelector("nav");
expect(nav).not.toBeNull();
// Each link should be present
const links = screen.getAllByRole("link");
const hrefs = links.map((l) => (l as HTMLAnchorElement).pathname ?? (l as HTMLAnchorElement).getAttribute("href") ?? "");
expect(hrefs.some(h => h === "/" || h === "")).toBe(true);
expect(hrefs.some(h => h.includes("tasks"))).toBe(true);
expect(hrefs.some(h => h.includes("wallet"))).toBe(true);
});
});
describe("<Dashboard /> smoke", () => {
it("calls marketplace.discoverSuppliers() on mount", async () => {
const mp = makeMockMarketplace();
const spy = vi.spyOn(mp, "discoverSuppliers").mockResolvedValue([]);
render(wrap(<Dashboard />, mp));
await waitFor(() => expect(spy).toHaveBeenCalledOnce());
});
it("renders a SupplierCard for each supplier returned by discoverSuppliers()", async () => {
const mp = makeMockMarketplace();
vi.spyOn(mp, "discoverSuppliers").mockResolvedValue([
makeSampleSupplierView({ supplier_pkh: "a".repeat(56) }),
makeSampleSupplierView({ supplier_pkh: "b".repeat(56) }),
]);
render(wrap(<Dashboard />, mp));
// Wait for async discover to complete and cards to render. Dashboard
// appends a synthetic Piper-TTS demo card to whatever the indexer
// returned, so the count is N+1 until the on-chain TTS supplier ships.
await waitFor(() => {
const cards = document.querySelectorAll("[data-testid='supplier-card']");
expect(cards.length).toBe(3);
});
// Sanity: one of the cards is the demo Piper supplier.
const piperBadges = Array.from(
document.querySelectorAll("[data-testid='supplier-card']"),
).filter((c) => c.textContent?.includes("audio.synthesize.piper.v1"));
expect(piperBadges.length).toBe(1);
});
});
describe("<PromptForm /> smoke", () => {
// PromptForm now POSTs to /v1/submit-prompt server-side instead of calling
// the SDK directly — the browser SPA's chain provider is a stub so all
// chain-touching work moved server-side. These tests stub globalThis.fetch
// to verify the form's request shape and UX states.
function stubFetchOk(receiptObj: unknown, signature: string) {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url);
if (!url.endsWith("/v1/submit-prompt")) {
throw new Error(`unexpected fetch: ${url}`);
}
const body = init?.body ? JSON.parse(init.body as string) : {};
(stubFetchOk as unknown as { lastBody?: unknown }).lastBody = body;
return new Response(JSON.stringify({
choices: [{ index: 0, message: { role: "assistant", content: "4" }, finish_reason: "stop" }],
receipt: receiptObj,
receipt_signature: signature,
escrow_ref: `${"a".repeat(64)}#0`,
}), { status: 200, headers: { "Content-Type": "application/json" } });
});
}
it("POSTs /v1/submit-prompt with correct messages when form is submitted", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
const fetchSpy = stubFetchOk(TASK_COMPLETED.receipt!, TASK_COMPLETED.receipt_signature!);
vi.stubGlobal("fetch", fetchSpy);
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(
<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />,
mp
));
const textarea = screen.getByRole("textbox");
await user.type(textarea, "What is 2+2?");
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
const lastBody = (stubFetchOk as unknown as { lastBody?: { messages: Array<{ content: string }> } }).lastBody;
expect(lastBody?.messages.some((m) => m.content.includes("What is 2+2?"))).toBe(true);
vi.unstubAllGlobals();
});
it("does NOT POST when prompt textarea is empty (client-side validation)", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
const fetchSpy = stubFetchOk(TASK_COMPLETED.receipt!, TASK_COMPLETED.receipt_signature!);
vi.stubGlobal("fetch", fetchSpy);
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(
<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />,
mp
));
// Do NOT type anything; just click submit
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await new Promise(r => setTimeout(r, 50));
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("renders a loading indicator while /v1/submit-prompt is pending", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
let resolveFn!: (value: Response) => void;
const pending = new Promise<Response>(r => { resolveFn = r; });
vi.stubGlobal("fetch", vi.fn(() => pending));
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />, mp));
const textarea = screen.getByRole("textbox");
await user.type(textarea, "What is 2+2?");
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await waitFor(() => {
const loading = screen.queryByTestId("loading-indicator") ??
screen.queryByRole("progressbar") ??
screen.queryByText(/loading|pending|…|\.\.\.$/i);
expect(loading).not.toBeNull();
});
resolveFn(new Response("{}", { status: 502 }));
vi.unstubAllGlobals();
});
it("renders an error message when /v1/submit-prompt rejects", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
vi.stubGlobal("fetch", vi.fn(async () =>
new Response(
JSON.stringify({ error: "submit_prompt_failed", message: "supplier offline" }),
{ status: 502, headers: { "Content-Type": "application/json" } }
)
));
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />, mp));
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello?");
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await waitFor(() => {
const errEl = screen.queryByRole("alert") ?? screen.queryByText(/error|failed|offline/i);
expect(errEl).not.toBeNull();
});
vi.unstubAllGlobals();
});
});
describe("<TaskHistory /> smoke", () => {
// TaskHistory now fetches /v1/indexer/escrows?buyer=<pkh> via the buyer-app
// server proxy and groups rows by posted_at. Each lifecycle (Open → Claimed
// → Submitted → terminal) shares one posted_at value, so we mock the
// indexer with a couple of fully-populated rows representing two distinct
// lifecycles in different states and assert one task-row per lifecycle.
it("fetches /v1/indexer/escrows and renders one row per distinct lifecycle (posted_at)", async () => {
const mp = makeMockMarketplace();
const buyerPkh = mp.getWalletKey().pubKeyHash;
const baseRow = (overrides: Record<string, unknown>) => ({
utxo_ref: "a".repeat(64) + "#0",
buyer_pkh: buyerPkh,
supplier_pkh: "b".repeat(56),
advert_ref: "c".repeat(64) + "#0",
capability_id: "llm.text.generate.v1",
prompt_hash: "d".repeat(64),
payment_lovelace: "2000000",
buyer_bond_lovelace: "1000000",
supplier_bond_lovelace: "1000000",
posted_at: 1700000000000,
submitted_at: null,
result_receipt_hash: null,
state: "Submitted",
created_slot: 1000,
...overrides,
});
// Lifecycle A: posted_at=1700000000000 — Open + Submitted rows (two
// chain rows in same lifecycle).
// Lifecycle B: posted_at=1700000099999 — Open + Accepted (different lifecycle).
const rows = [
baseRow({ utxo_ref: "1".repeat(64) + "#0", state: "Open", created_slot: 1000 }),
baseRow({ utxo_ref: "2".repeat(64) + "#0", state: "Submitted", created_slot: 1010 }),
baseRow({ utxo_ref: "3".repeat(64) + "#0", state: "Open", created_slot: 2000, posted_at: 1700000099999 }),
baseRow({ utxo_ref: "4".repeat(64) + "#0", state: "Accepted", created_slot: 2020, posted_at: 1700000099999 }),
];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url);
// TaskHistory now also fetches /v1/responses (the buyer-app's archive
// index) and joins by escrow_ref. Empty list is a valid response that
// means "archive disabled / no records yet" and exercises the same
// "no archive payload to render inline" code path as production.
if (url.includes("/v1/responses")) {
return new Response(JSON.stringify({ responses: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (!url.includes(`/v1/indexer/escrows?buyer=${buyerPkh}`)) {
throw new Error(`unexpected fetch: ${url}`);
}
return new Response(JSON.stringify(rows), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}));
render(wrap(<TaskHistory />, mp));
await waitFor(() => {
const taskRows = document.querySelectorAll("[data-testid='task-row']");
expect(taskRows.length).toBe(2); // 2 distinct lifecycles
});
vi.unstubAllGlobals();
});
it("renders an empty-state when the indexer returns no rows", async () => {
const mp = makeMockMarketplace();
vi.stubGlobal("fetch", vi.fn(async () =>
new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } })
));
render(wrap(<TaskHistory />, mp));
await waitFor(() => {
expect(screen.getByTestId("task-history-empty")).toBeTruthy();
});
vi.unstubAllGlobals();
});
});
describe("<Wallet /> smoke", () => {
it("renders the configured walletKey.address", () => {
const mp = makeMockMarketplace();
render(wrap(<Wallet />, mp));
// The buyer's bech32 address must appear somewhere in the rendered output
expect(screen.getByText(new RegExp(buyer.address, "i"))).toBeTruthy();
});
});
describe("<SupplierCard /> smoke", () => {
it("renders the supplier model and price", () => {
const supplier = makeSampleSupplierView();
render(<SupplierCard supplier={supplier} />);
// Should display model name
expect(screen.getByText(/qwen2\.5:0\.5b/i)).toBeTruthy();
});
it("'Use' button is disabled when supplier.status is 'offline'", () => {
const offlineSupplier = makeSampleSupplierView({ status: "offline" });
render(<SupplierCard supplier={offlineSupplier} />);
const useBtn = screen.queryByRole("button", { name: /use/i });
if (useBtn) {
expect(useBtn).toBeDisabled();
} else {
// If there's a "Use" button but it's hidden/replaced, verify it cannot be clicked
const btn = document.querySelector("button");
expect(btn === null || btn.disabled).toBe(true);
}
});
it("'Use' button is enabled when supplier.status is 'free'", () => {
const freeSupplier = makeSampleSupplierView({ status: "free" });
render(<SupplierCard supplier={freeSupplier} />);
const useBtn = screen.getByRole("button", { name: /use/i });
expect(useBtn).not.toBeDisabled();
});
});