← back to Kickbacks Ai Upstream

test/attribution.test.ts

319 lines

/** Rotation/poll-lag billing attribution (audit 2026-06-09 finding #17).
 *
 *  A rotation flips the host's activeAd instantly, but the webview only
 *  learns of it from its own 10s /ad poll — so for up to 10s it keeps
 *  emitting the OLD ad's view events (its metric GETs carry an `ad=` claim)
 *  and possibly a click on the OLD anchor. Pre-fix the host stamped
 *  adId/campaignId/sessionToken from the NEW activeAd at arrival time:
 *  cross-campaign billing misattribution on every rotation boundary.
 *
 *  Pins:
 *    • the loopback lifts the `ad=` claim into the onEvent payload and the
 *      onClick args;
 *    • an event/click claiming the OLD ad after a swap bills the OLD
 *      campaign AND old session token (10s-stale token is fine, TTL 300s);
 *    • the deployed block sends the ad TEXT as its claim (viewShow(AD, …)),
 *      so text claims resolve too;
 *    • unknown/absent claims fall back to activeAd (pre-fix behavior);
 *    • the impression dedupe keys on the RESOLVED ad, so the old ad's late
 *      impression can't consume the new ad's dedupe slot (and repeats of the
 *      old ad's impression are still deduped within its own ad);
 *    • the wave-2 canServeAds gate still runs FIRST — a gated install bills
 *      nothing even for a known old-ad claim.
 */
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../src/modes", () => ({
  webviewMode: () => "on",
  cliMode: () => "on",
  bannerOverride: () => "server",
  setWebviewMode: () => {},
  setCliMode: () => {},
  setBannerOverride: () => {},
}));

import { Loopback } from "../src/loopback";
import { resetServingGate, setKillPosture } from "../src/servingGate";
import { setupWebviewInjection } from "../src/activation/webviewInjection";
import { createActivationContext, type ActivationContext }
  from "../src/activation/context";
import { resetSharedLoopbackForTest } from "../src/util/loopbackBoot";
import { SessionState } from "../src/sessionState";
import { makeContext } from "./mocks/vscode";
import type { PatchAd, PortfolioResponse } from "../src/portfolio/client";

const AD_A: PatchAd = {
  adId: "ad-a", campaignId: "c-a", adText: "Linear -- plan, build, ship",
  iconRef: "icon.a", iconUrl: "", clickUrl: "https://a.test",
  bannerEnabled: false, sessionToken: "tok-a",
};
const AD_B: PatchAd = {
  adId: "ad-b", campaignId: "c-b", adText: "Railway -- deploy in seconds",
  iconRef: "icon.b", iconUrl: "", clickUrl: "https://b.test",
  bannerEnabled: false, sessionToken: "tok-b",
};
function mkResp(ads: PatchAd[]): PortfolioResponse {
  return { ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
    rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null };
}

beforeEach(() => { resetServingGate(); resetSharedLoopbackForTest(); });

// ── Harness: real loopback + real webview injection wiring ────────────────
async function mkHarness() {
  const actx = createActivationContext();
  const killedRef = { current: false };
  const adRef = { current: AD_A as PatchAd | null };
  const metricsSend = vi.fn();
  let inventory: PortfolioResponse | null = null;
  // Mutable so the namespace-guard tests can flip the live sign-in state
  // mid-scenario (the guard reads auth.accessToken() at event time).
  const authState = { token: "tok" as string | null };
  const deps = {
    ctx: makeContext(), actx,
    adapter: {
      name: "claude-code",
      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
      version: () => "2.1.143",
      isPatched: () => true,
      applyPatch: vi.fn(() => ({ ok: true })),
      restore: vi.fn(() => ({ ok: true, restored: true })),
    },
    auth: { accessToken: () => authState.token, clientId: () => "cid" },
    debugCtl: { setPortfolioAd: () => {} },
    session: new SessionState(),
    portfolio: { fetchPortfolio: async () => inventory,
                 fetchDemoPortfolio: async () => inventory },
    metrics: { send: metricsSend },
    logTail: { current: () => ({}), activityAgeMs: () => null },
    testHooks: { handleTestRoute: async () => ({ status: 404, body: {} }) },
    statusBar: { set: () => {} },
    ccVersion: "2.1.143",
    killed: false, killedRef, adRef,
    portfolioResp: mkResp([AD_A]),
    viewThresholdMs: 3_000,
    statusBarShowActive: async () => {},
    scheduleEarningsRefresh: () => {},
    desyncState: { lastApplyAt: 0, lastBlockStartAt: 0 },
  };
  const r = await setupWebviewInjection(deps as never);
  expect(r.lbInfo).not.toBeNull();
  const base = r.lbInfo!.base;
  return {
    r, actx, metricsSend, base, authState,
    /** Swap the live inventory and force a rotation apply (the real
     *  refreshPortfolio path — flips activeAd, re-mints corr, resets the
     *  impression dedupe, exactly what the 120s rotation tick does). */
    swapTo: async (ads: PatchAd[]) => {
      inventory = mkResp(ads);
      await r.refreshPortfolioNow!(true);
    },
  };
}

async function teardown(actx: ActivationContext): Promise<void> {
  for (const t of actx.timers) clearInterval(t as NodeJS.Timeout);
  actx.timers.length = 0;
  if (actx.loopback) { await actx.loopback.stop(); actx.loopback = null; }
}

type SentArgs = { adId: string; campaignId: string; sessionToken?: string };
const sent = (m: ReturnType<typeof vi.fn>): [string, SentArgs][] =>
  m.mock.calls.map((c) => [c[0] as string, c[1] as SentArgs]);

describe("rotation/poll-lag attribution (audit #17)", () => {

  it("a view event claiming the OLD ad id after a swap bills the OLD"
    + " campaign + session token; the new ad's events bill the new one",
    async () => {
    const h = await mkHarness();
    try {
      // Prime: one event while AD_A serves — registers it as recently served.
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
      expect(sent(h.metricsSend)[0][1]).toMatchObject(
        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
      await h.swapTo([AD_B]);
      h.metricsSend.mockClear();
      // The webview hasn't polled /ad yet: its OLD view session keeps firing.
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=10000`);
      await fetch(
        `${h.base}/view_threshold_met?surface=overlay&ad=ad-a&visible_ms=16000`);
      // …and after its poll, the NEW ad's session fires too.
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-b&visible_ms=2000`);
      const calls = sent(h.metricsSend);
      expect(calls).toHaveLength(3);
      expect(calls[0][0]).toBe("view_tick");
      expect(calls[0][1]).toMatchObject(
        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
      expect(calls[1][0]).toBe("view_threshold_met");
      expect(calls[1][1]).toMatchObject(
        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
      expect(calls[2][1]).toMatchObject(
        { adId: "ad-b", campaignId: "c-b", sessionToken: "tok-b" });
    } finally { await teardown(h.actx); }
  });

  it("claims by ad TEXT resolve too (the deployed block keys its view"
    + " sessions by AD text and sends that as ad=)", async () => {
    const h = await mkHarness();
    try {
      await fetch(`${h.base}/view_tick?surface=overlay&visible_ms=5000`);
      await h.swapTo([AD_B]);
      h.metricsSend.mockClear();
      await fetch(`${h.base}/view_tick?surface=overlay`
        + `&ad=${encodeURIComponent(AD_A.adText)}&visible_ms=9000`);
      expect(sent(h.metricsSend)[0][1]).toMatchObject(
        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
    } finally { await teardown(h.actx); }
  });

  it("a click claiming the OLD ad (above the 15s floor) bills the OLD"
    + " campaign + token", async () => {
    const h = await mkHarness();
    try {
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
      await h.swapTo([AD_B]);
      h.metricsSend.mockClear();
      await fetch(
        `${h.base}/click?ct=ck&surface=overlay&visible_ms=20000&ad=ad-a`);
      const calls = sent(h.metricsSend);
      expect(calls).toHaveLength(1);
      expect(calls[0][0]).toBe("click");
      expect(calls[0][1]).toMatchObject(
        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
    } finally { await teardown(h.actx); }
  });

  it("unknown claimed ad — and an absent claim — fall back to activeAd"
    + " (events and clicks)", async () => {
    const h = await mkHarness();
    try {
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
      await h.swapTo([AD_B]);
      h.metricsSend.mockClear();
      await fetch(
        `${h.base}/view_tick?surface=overlay&ad=never-served&visible_ms=1000`);
      await fetch(`${h.base}/view_tick?surface=overlay&visible_ms=1000`);
      await fetch(`${h.base}/click?ct=ck&surface=overlay&visible_ms=20000`);
      const calls = sent(h.metricsSend);
      expect(calls).toHaveLength(3);
      for (const [, a] of calls) {
        expect(a).toMatchObject(
          { adId: "ad-b", campaignId: "c-b", sessionToken: "tok-b" });
      }
    } finally { await teardown(h.actx); }
  });

  it("impression dedupe keys on the RESOLVED ad: the old ad's late"
    + " impression doesn't consume the new ad's slot, and repeats are deduped"
    + " within their own ad", async () => {
    const h = await mkHarness();
    try {
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
      await h.swapTo([AD_B]);
      h.metricsSend.mockClear();
      // Old ad's straggler impression (the poll-lag window)…
      await fetch(`${h.base}/impression_viewable?surface=overlay&ad=ad-a`);
      // …must NOT have consumed the NEW ad's dedupe slot…
      await fetch(`${h.base}/impression_viewable?surface=overlay&ad=ad-b`);
      // …while a REPEAT of the old ad's impression is deduped in its own ad.
      await fetch(`${h.base}/impression_viewable?surface=overlay&ad=ad-a`);
      const calls = sent(h.metricsSend);
      expect(calls).toHaveLength(2);
      expect(calls[0][1].adId).toBe("ad-a");
      expect(calls[1][1].adId).toBe("ad-b");
    } finally { await teardown(h.actx); }
  });

  it("the wave-2 canServeAds gate runs FIRST: a confirmed kill drops even a"
    + " known old-ad claim (event and click)", async () => {
    const h = await mkHarness();
    try {
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
      await h.swapTo([AD_B]);
      h.metricsSend.mockClear();
      setKillPosture("confirmed");
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=9000`);
      await fetch(
        `${h.base}/click?ct=ck&surface=overlay&visible_ms=20000&ad=ad-a`);
      expect(h.metricsSend).not.toHaveBeenCalled();
    } finally { await teardown(h.actx); }
  });
});

// ── Token-namespace guard (2026-06-11) ─────────────────────────────────────
// A session token is HMAC-bound to a uid namespace (real g-* vs
// demo:<client_id>), but MetricsClient picks its route by LIVE auth state.
// A demo-era token forwarded on the authed route (or a real token after a
// sign-out, forwarded on the demo route) is a 100%-guaranteed server 403 —
// in prod this was a chronic all-403 stream of entire view sessions. The
// host now drops the mismatch at the relay instead of burning the send.
describe("token-namespace guard", () => {
  const AD_DEMO: PatchAd = { ...AD_B, sessionToken: "demo-tok", demo: true };

  it("drops view events whose attribution is demo-stamped while signed in", async () => {
    const h = await mkHarness();
    try {
      await h.swapTo([AD_DEMO]);                 // demo object, authed host
      h.metricsSend.mockClear();
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-b&visible_ms=5000`);
      await fetch(`${h.base}/click?ct=ck&surface=overlay&visible_ms=20000&ad=ad-b`);
      expect(h.metricsSend).not.toHaveBeenCalled();
    } finally { await teardown(h.actx); }
  });

  it("drops view events carrying a REAL token once signed out", async () => {
    const h = await mkHarness();
    try {
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
      h.metricsSend.mockClear();
      h.authState.token = null;                  // sign-out; registry keeps tok-a
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=9000`);
      expect(h.metricsSend).not.toHaveBeenCalled();
    } finally { await teardown(h.actx); }
  });

  it("demo attribution + signed-out still bills (the legitimate demo path)", async () => {
    const h = await mkHarness();
    try {
      await h.swapTo([AD_DEMO]);
      h.authState.token = null;
      h.metricsSend.mockClear();
      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-b&visible_ms=5000`);
      const calls = sent(h.metricsSend);
      expect(calls).toHaveLength(1);
      expect(calls[0][1]).toMatchObject(
        { adId: "ad-b", campaignId: "c-b", sessionToken: "demo-tok" });
    } finally { await teardown(h.actx); }
  });
});

// ── The loopback lift itself (route → handler contract) ───────────────────
describe("loopback lifts the ad= claim (audit #17)", () => {
  it("metric routes surface claimedAdId in the payload; absent param stays"
    + " undefined; the click route passes it as the 5th onClick arg",
    async () => {
    const events: { kind: string; claimedAdId?: string }[] = [];
    const clicks: (string | undefined)[] = [];
    const lb = new Loopback({
      onEvent: (k, p) => { events.push({ kind: k, claimedAdId: p.claimedAdId }); },
      onClick: (_ct, _s, _v, _u, claimedAdId) => { clicks.push(claimedAdId); },
      getActivity: () => ({}),
      getCurrentAd: () => null,
    });
    const { port, token } = await lb.start();
    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
    try {
      await fetch(`${base}/view_tick?surface=overlay&ad=ad-77&visible_ms=5000`);
      await fetch(`${base}/view_tick?surface=overlay&visible_ms=5000`);
      await fetch(`${base}/click?ct=ck&surface=overlay&visible_ms=1&ad=ad-77`);
      await fetch(`${base}/click?ct=ck&surface=overlay&visible_ms=1`);
      expect(events).toEqual([
        { kind: "view_tick", claimedAdId: "ad-77" },
        { kind: "view_tick", claimedAdId: undefined },
      ]);
      expect(clicks).toEqual(["ad-77", undefined]);
    } finally { await lb.stop(); }
  });
});