← back to Kickbacks Ai Upstream

test/consentFlow.test.ts

203 lines

// Consent prompt wired into the full activate() lifecycle: a signed-in user
// whose tos_accepted_version differs from current_tos_version should see the
// one-time prompt and, on Agree, POST to /v1/me/consent. Existing
// consent.test.ts unit-tests the prompt in isolation; this file pins the
// integration — that activate() actually invokes maybePromptForConsent and
// the post-Agree POST is observed on the wire.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { activate, deactivate, __wireForTest } from "../src/extension";
import { ConsentClient } from "../src/consent/client";
import { makeContext, secrets, _opened, _shown, _openedDocs, commands, window }
  from "./mocks/vscode";

const mkAdapter = () => ({
  name: "claude-code" as const,
  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
  version: () => "2.1.143",
  applyPatch: vi.fn(() => ({ ok: true })),
  restore: vi.fn(() => ({ ok: true, restored: true })),
});

function stubFetch(opts: { hasAccepted?: boolean;
                            acceptResponseOk?: boolean } = {}) {
  const calls: { url: string; method: string;
                 headers: Record<string, string> }[] = [];
  const f = vi.fn(async (input: unknown, init?: { method?: string;
      headers?: Record<string, string> }) => {
    const url = typeof input === "string" ? input : String(input);
    const method = (init?.method || "GET").toUpperCase();
    calls.push({ url, method, headers: init?.headers || {} });
    if (url.includes("/v1/me/consent")) {
      if (method === "POST") {
        if (opts.acceptResponseOk === false) {
          return { ok: false, status: 500, json: async () => ({}) } as Response;
        }
        return { ok: true, status: 200, json: async () =>
          ({ tos_version: "v2", accepted_at: "2026-01-01T00:00:00Z" }) } as Response;
      }
      // GET — the user has accepted an older version OR not at all.
      return { ok: true, status: 200, json: async () => ({
        telemetry_opt_in: !!opts.hasAccepted,
        tos_accepted_version: opts.hasAccepted ? "v2" : "v1",
        current_tos_version: "v2",
      }) } as Response;
    }
    // benign 200 for everything else (portfolio / killswitch / earnings).
    return { ok: true, status: 200, json: async () => ({}) } as Response;
  });
  vi.stubGlobal("fetch", f);
  return { f, calls };
}

beforeEach(() => {
  secrets.clear();
  commands._handlers.clear();
  commands._executed.length = 0;
  _opened.length = 0;
  _shown.length = 0;
  _openedDocs.length = 0;
});

afterEach(() => { vi.unstubAllGlobals(); });

describe("consent flow — activate() wires maybePromptForConsent end-to-end", () => {

  it("user with stale tos_accepted_version sees the prompt and, on Agree,"
    + " POSTs /v1/me/consent", async () => {
    const home = mkdtempSync(join(tmpdir(), "kb-consent-"));
    const prevHome = process.env.HOME;
    const prevUser = process.env.USERPROFILE;
    process.env.HOME = home; process.env.USERPROFILE = home;
    const adapter = mkAdapter();
    const statusBar = { set: vi.fn(), dispose: vi.fn() };
    __wireForTest({ adapter, statusBar });
    const fetched = stubFetch({ hasAccepted: false });
    // Force the prompt to resolve to "Agree" without showing anything to a
    // real human. Restore the default in afterEach via vi.restoreAllMocks
    // is not needed — the mock module is process-shared and beforeEach
    // resets the _shown ring buffer.
    const origShow = window.showInformationMessage;
    window.showInformationMessage = (async (msg: unknown, ..._rest: unknown[]) => {
      _shown.push({ kind: "info", text: String(msg) });
      return "Agree";
    }) as never;
    const ctx = makeContext();
    await ctx.secrets.store("kickbacks.access", "AT-CONSENT");

    try {
      await activate(ctx as never);
      // maybePromptForConsent is fire-and-forget; drain a few ticks so the
      // GET → showInformationMessage → POST chain lands.
      await new Promise((r) => setTimeout(r, 50));

      const gets = fetched.calls.filter(
        (c) => c.url.endsWith("/v1/me/consent") && c.method === "GET");
      const posts = fetched.calls.filter(
        (c) => c.url.endsWith("/v1/me/consent") && c.method === "POST");
      expect(gets.length).toBeGreaterThan(0);
      expect(posts.length).toBe(1);
      expect(posts[0].headers.authorization).toBe("Bearer AT-CONSENT");
      // Prompt actually shown.
      expect(_shown.some(
        (s) => s.kind === "info" && /spinner/i.test(s.text))).toBe(true);
      // Post-Agree, the SHOWN_KEY is persisted so we don't nag this session.
      const stored = ctx.globalState.get<string>(
        "vibe-ads.consent.promptShownForVersion");
      expect(stored).toBe("v2");
    } finally {
      window.showInformationMessage = origShow;
      await deactivate();
      if (prevHome !== undefined) process.env.HOME = prevHome;
      else delete process.env.HOME;
      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
      else delete process.env.USERPROFILE;
      try { rmSync(home, { recursive: true, force: true }); } catch { /* ok */ }
    }
  });

  it("already-accepted user gets NO prompt + NO POST", async () => {
    const home = mkdtempSync(join(tmpdir(), "kb-consent2-"));
    const prevHome = process.env.HOME;
    const prevUser = process.env.USERPROFILE;
    process.env.HOME = home; process.env.USERPROFILE = home;
    const adapter = mkAdapter();
    const statusBar = { set: vi.fn(), dispose: vi.fn() };
    __wireForTest({ adapter, statusBar });
    const fetched = stubFetch({ hasAccepted: true });
    const ctx = makeContext();
    await ctx.secrets.store("kickbacks.access", "AT-CONSENT2");

    try {
      await activate(ctx as never);
      await new Promise((r) => setTimeout(r, 50));
      const posts = fetched.calls.filter(
        (c) => c.url.endsWith("/v1/me/consent") && c.method === "POST");
      expect(posts.length).toBe(0);
      expect(_shown.some(
        (s) => s.kind === "info" && /spinner/i.test(s.text))).toBe(false);
    } finally {
      await deactivate();
      if (prevHome !== undefined) process.env.HOME = prevHome;
      else delete process.env.HOME;
      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
      else delete process.env.USERPROFILE;
      try { rmSync(home, { recursive: true, force: true }); } catch { /* ok */ }
    }
  });

  it("signed-out user gets NO prompt + NO POST (no token => no GET)",
    async () => {
      const home = mkdtempSync(join(tmpdir(), "kb-consent3-"));
      const prevHome = process.env.HOME;
      const prevUser = process.env.USERPROFILE;
      process.env.HOME = home; process.env.USERPROFILE = home;
      const adapter = mkAdapter();
      const statusBar = { set: vi.fn(), dispose: vi.fn() };
      __wireForTest({ adapter, statusBar });
      const fetched = stubFetch({ hasAccepted: false });
      const ctx = makeContext();  // NOT signed in
      try {
        await activate(ctx as never);
        await new Promise((r) => setTimeout(r, 50));
        const consentCalls = fetched.calls.filter(
          (c) => c.url.endsWith("/v1/me/consent"));
        expect(consentCalls).toHaveLength(0);
      } finally {
        await deactivate();
        if (prevHome !== undefined) process.env.HOME = prevHome;
        else delete process.env.HOME;
        if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
        else delete process.env.USERPROFILE;
        try { rmSync(home, { recursive: true, force: true }); } catch { /* ok */ }
      }
    });
});

// audit-2026-06-09 #38: ConsentClient was the only client whose DEFAULT was
// bare `fetch` (no timeout) — the 2A-01 black-holed-connection hang class.
// Pin that the default-constructed client (the extension.ts wiring passes no
// fetch) sends every request with an AbortSignal.
describe("ConsentClient default fetch carries a timeout (audit #38)", () => {
  it("GET and POST both carry an abort signal", async () => {
    const inits: (RequestInit | undefined)[] = [];
    vi.stubGlobal("fetch", vi.fn(async (_u: unknown, init?: RequestInit) => {
      inits.push(init);
      return { ok: true, status: 200, json: async () => ({
        telemetry_opt_in: false, tos_accepted_version: "v1",
        current_tos_version: "v2",
        tos_version: "v2", accepted_at: "2026-01-01T00:00:00Z",
      }) } as Response;
    }));
    const c = new ConsentClient("http://x", () => "tok");
    expect(await c.read()).not.toBeNull();
    expect(await c.accept()).not.toBeNull();
    expect(inits).toHaveLength(2);
    // Pre-fix: the default was bare fetch -> no signal on either call.
    expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
    expect(inits[1]?.signal).toBeInstanceOf(AbortSignal);
  });
});