← back to Kickbacks Ai Upstream

test/loopback.test.ts

241 lines

import { describe, it, expect, afterEach } from "vitest";
import { Loopback } from "../src/loopback";

let lb: Loopback | null = null;
afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });

describe("Loopback", () => {
  it("token-gated routes fire callbacks; bad token 404s", async () => {
    const events: string[] = [];
    let clicked = "";
    lb = new Loopback({
      onEvent: (k) => events.push(k),
      onClick: (ct) => { clicked = ct; },
      getActivity: () => ({ tool: "Bash", ts: 42 }),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;

    expect((await fetch(`${base}/impression_rendered`, { method: "POST" })).status).toBe(204);
    expect((await fetch(`${base}/click?ct=abc`, { method: "POST" })).status).toBe(204);
    const act = await (await fetch(`${base}/activity`)).json();
    expect(act).toEqual({ tool: "Bash", ts: 42 });
    expect(events).toContain("impression_rendered");
    expect(clicked).toBe("abc");

    const bad = await fetch(`http://127.0.0.1:${port}/vibe-ads/WRONG/activity`);
    expect(bad.status).toBe(404);
  });

  it("/click accepts the corr query param without regressing ct parsing", async () => {
    // Task 6 added `&corr=<id>` to the click ping; the route now also reads
    // url.searchParams.get("corr"). Guard that the extra param does not break
    // ct extraction / the 204 contract (the corr→dlog hop is covered by
    // log.test.ts + the live C-check).
    let clicked: string | null = null;
    lb = new Loopback({
      onEvent: () => {},
      onClick: (ct) => { clicked = ct; },
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
    const r = await fetch(`${base}/click?ct=ck&corr=debug.973525`,
      { method: "POST" });
    expect(r.status).toBe(204);
    expect(clicked).toBe("ck");
    // corr-only (no ct) must still 204 and fire onClick with "" — never throw
    const r2 = await fetch(`${base}/click?corr=ad1.zz`, { method: "POST" });
    expect(r2.status).toBe(204);
    expect(clicked).toBe("");
  });

  it("/click relays event_uuid for end-to-end ledger tracing", async () => {
    let saw: { ct: string; eventUuid?: string } | null = null;
    lb = new Loopback({
      onEvent: () => {},
      onClick: (ct, _surface, _visibleMs, eventUuid) => {
        saw = { ct, eventUuid };
      },
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
    const eventUuid = "123e4567-e89b-42d3-a456-426614174000";

    const r = await fetch(`${base}/click?ct=ck&event_uuid=${eventUuid}`,
      { method: "POST" });

    expect(r.status).toBe(204);
    expect(saw).toEqual({ ct: "ck", eventUuid });
  });

  it("/click extracts surface for all four product surfaces", async () => {
    // Surface attribution must reach the onClick callback verbatim across
    // every product line. A regression here (e.g. accepting only "overlay"
    // and silently dropping codex_overlay / statusline / banner) would
    // bucket all clicks under the CC default and break per-product
    // revenue reporting. Whitelist is in loopback.ts; this is its boundary
    // contract.
    const captured: Array<{ ct: string; surface?: string }> = [];
    lb = new Loopback({
      onEvent: () => {},
      onClick: (ct, surface) => { captured.push({ ct, surface }); },
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
    for (const s of ["overlay", "banner", "codex_overlay", "statusline"]) {
      const r = await fetch(`${base}/click?ct=ck&corr=cx&surface=${s}`,
        { method: "POST" });
      expect(r.status).toBe(204);
    }
    expect(captured.map((c) => c.surface)).toEqual(
      ["overlay", "banner", "codex_overlay", "statusline"]);
    expect(captured.every((c) => c.ct === "ck")).toBe(true);
  });

  it("/click ignores an UNKNOWN surface value (defensive whitelist; never "
    + "blocks the click)", async () => {
    // The whitelist is server-authoritative — a client sending a typo or
    // an attacker-crafted surface must NOT inject a bogus attribution
    // label into the ledger. The click still bills (defensive: never lose
    // revenue over a label), but `surface` is undefined downstream.
    let saw: { ct: string; surface?: string } | null = null;
    lb = new Loopback({
      onEvent: () => {},
      onClick: (ct, surface) => { saw = { ct, surface }; },
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
    const r = await fetch(`${base}/click?ct=ck&surface=javascript:alert(1)`,
      { method: "POST" });
    expect(r.status).toBe(204);
    expect(saw).not.toBeNull();
    expect((saw as unknown as { ct: string }).ct).toBe("ck");
    expect((saw as unknown as { surface?: string }).surface).toBeUndefined();
  });

  it("relays view tracking routes with visibility metadata", async () => {
    const seen: unknown[] = [];
    lb = new Loopback({
      onEvent: (k, payload) => { seen.push(k, payload); },
      onClick: () => {},
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;

    const r = await fetch(
      `${base}/view_threshold_met?surface=overlay&visible_ms=15100&session=session123&event_uuid=123e4567-e89b-42d3-a456-426614174000`,
      { method: "POST" },
    );

    expect(r.status).toBe(204);
    expect(seen).toEqual([
      "view_threshold_met",
      {
        surface: "overlay",
        visibleMs: 15100,
        sessionNonce: "session123",
        eventUuid: "123e4567-e89b-42d3-a456-426614174000",
        viewable: true,
        viewPct: 100,
        viewMs: 15100,
      },
    ]);
  });

  it("answers the CORS preflight so the vscode-webview origin can POST", async () => {
    lb = new Loopback({ onEvent: () => {}, onClick: () => {}, getActivity: () => ({}), getCurrentAd: () => null });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;

    // The block's dlog POST sends content-type: application/json, which makes
    // the webview fire an OPTIONS preflight first. It must succeed with ACAO.
    const pre = await fetch(`${base}/log`, { method: "OPTIONS" });
    expect(pre.status).toBe(204);
    expect(pre.headers.get("access-control-allow-origin")).toBe("*");
    expect((pre.headers.get("access-control-allow-methods") || "")).toContain("POST");
    expect((pre.headers.get("access-control-allow-headers") || "").toLowerCase())
      .toContain("content-type");
  });

  it("every real response carries Access-Control-Allow-Origin", async () => {
    lb = new Loopback({
      onEvent: () => {}, onClick: () => {},
      getActivity: () => ({ tool: "Bash" }),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;

    const act = await fetch(`${base}/activity`);
    expect(act.headers.get("access-control-allow-origin")).toBe("*");
    const imp = await fetch(`${base}/impression_rendered`, { method: "POST" });
    expect(imp.headers.get("access-control-allow-origin")).toBe("*");
    // even a 404 must carry it (a preflight to an unknown path still needs it)
    const miss = await fetch(`http://127.0.0.1:${port}/vibe-ads/WRONG/x`);
    expect(miss.status).toBe(404);
    expect(miss.headers.get("access-control-allow-origin")).toBe("*");
  });

  it("start() is resilient and stop() is idempotent", async () => {
    lb = new Loopback({ onEvent: () => {}, onClick: () => {}, getActivity: () => ({}), getCurrentAd: () => null });
    const a = await lb.start();
    expect(a.port).toBeGreaterThan(0);
    await lb.stop(); await lb.stop(); // no throw
  });

  it("setHandlers live-swaps every route on the running server (audit #7 — "
    + "the shared-loopback takeover)", async () => {
    const seen: string[] = [];
    lb = new Loopback({
      onEvent: (k) => seen.push(`a:${k}`),
      onClick: () => seen.push("a:click"),
      getActivity: () => ({ who: "a" }),
      getCurrentAd: () => ({ adText: "a-ad", clickUrl: "https://a.test",
        iconUrl: "", adId: "a", campaignId: "a" }),
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
    expect((await (await fetch(`${base}/ad`)).json()).adId).toBe("a");
    lb.setHandlers({
      onEvent: (k) => seen.push(`b:${k}`),
      onClick: () => seen.push("b:click"),
      getActivity: () => ({ who: "b" }),
      getCurrentAd: () => ({ adText: "b-ad", clickUrl: "https://b.test",
        iconUrl: "", adId: "b", campaignId: "b" }),
    });
    // Same bound port + token; every route now dispatches to B.
    expect((await (await fetch(`${base}/ad`)).json()).adId).toBe("b");
    expect(await (await fetch(`${base}/activity`)).json())
      .toEqual({ who: "b" });
    expect((await fetch(`${base}/view_tick?surface=overlay`,
      { method: "POST" })).status).toBe(204);
    expect((await fetch(`${base}/click?ct=ck`, { method: "POST" })).status)
      .toBe(204);
    expect(seen).toContain("b:view_tick");
    expect(seen).toContain("b:click");
    expect(seen.filter((s) => s.startsWith("a:"))).toEqual([]);
  });

  it("isRunning() tracks bind state across start/stop (audit #7 — stale "
    + "shared-server detection)", async () => {
    lb = new Loopback({ onEvent: () => {}, onClick: () => {},
      getActivity: () => ({}), getCurrentAd: () => null });
    expect(lb.isRunning()).toBe(false);
    await lb.start();
    expect(lb.isRunning()).toBe(true);
    await lb.stop();
    expect(lb.isRunning()).toBe(false);
  });
});