← back to Ventura Claw

server/tests/integration.test.js

307 lines

#!/usr/bin/env node
// VenturaClaw — integration tests (bare node + assert, no test runner)
// Target: http://127.0.0.1:9788 (pm2: ventura-claw)
// Run: node tests/integration.test.js

"use strict";

const assert = require("assert");

const BASE = "http://127.0.0.1:9788";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

async function req(method, path, { body, cookie, headers = {} } = {}) {
  const opts = {
    method,
    headers: { "Content-Type": "application/json", ...headers },
  };
  if (cookie) opts.headers["Cookie"] = `cc_session=${cookie}`;
  if (body != null) opts.body = JSON.stringify(body);
  const res = await fetch(`${BASE}${path}`, opts);
  let json = null;
  try { json = await res.json(); } catch { /* non-JSON response */ }
  return { status: res.status, json, headers: res.headers };
}

// Extract cc_session value from Set-Cookie header
function extractCookie(headers) {
  const raw = headers.get("set-cookie") || "";
  const m = raw.match(/cc_session=([^;]+)/);
  return m ? m[1] : null;
}

// Generate a unique fake IP per run so rate-limit tests never collide with
// prior test runs against the same 15-minute window.
function uniqueTestIp() {
  const ts = Date.now();
  const a = (ts >> 16) & 0xff || 10;
  const b = (ts >> 8)  & 0xff || 1;
  const c =  ts        & 0xff || 1;
  return `10.${a}.${b}.${c}`;
}

// ---------------------------------------------------------------------------
// Test runner
// ---------------------------------------------------------------------------

const results = [];

async function test(name, fn) {
  try {
    await fn();
    results.push({ name, ok: true });
    console.log(`  PASS  ${name}`);
  } catch (err) {
    results.push({ name, ok: false, err: err.message });
    console.log(`  FAIL  ${name}`);
    console.log(`        ${err.message}`);
  }
}

// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------

(async () => {
  console.log("\nVenturaClaw — integration test suite");
  console.log("=======================================\n");

  // ------------------------------------------------------------------
  // 1. bcrypt login — valid creds return 200 + set-cookie
  // ------------------------------------------------------------------
  let SESSION; // reused across subsequent tests
  await test("bcrypt login: valid creds → 200 + cc_session cookie", async () => {
    const { status, json, headers } = await req("POST", "/api/auth/login", {
      body: { email: "steve@venturaclaw.com", password: "149940c2c5b209fb" },
      headers: { "X-Forwarded-For": uniqueTestIp() },
    });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    assert.strictEqual(json?.ok, true, "response body must have ok:true");
    assert.strictEqual(json?.role, "admin", "role must be 'admin'");
    SESSION = extractCookie(headers);
    assert.ok(SESSION && SESSION.length > 20, "cc_session cookie must be present and non-trivial");
  });

  // ------------------------------------------------------------------
  // 2. wrong password → 401 (not 200)
  // ------------------------------------------------------------------
  await test("bcrypt login: wrong password → 401", async () => {
    const { status, json } = await req("POST", "/api/auth/login", {
      body: { email: "steve@venturaclaw.com", password: "wrongpassword" },
      headers: { "X-Forwarded-For": uniqueTestIp() },
    });
    assert.strictEqual(status, 401, `expected 401, got ${status}`);
    assert.ok(json?.error, "response must contain an error field");
  });

  // ------------------------------------------------------------------
  // 3. rate-limit — 6 bad attempts on same IP → 6th is 429
  // ------------------------------------------------------------------
  await test("rate-limit: 6th bad-pw attempt from same IP → 429", async () => {
    const ip = uniqueTestIp();
    let lastStatus;
    for (let i = 0; i < 6; i++) {
      const { status } = await req("POST", "/api/auth/login", {
        body: { email: "nobody@example.com", password: "badpw" },
        headers: { "X-Forwarded-For": ip },
      });
      lastStatus = status;
    }
    assert.strictEqual(lastStatus, 429, `expected 429 on 6th attempt, got ${lastStatus}`);
  });

  // ------------------------------------------------------------------
  // 4a. session validation — valid cookie → user JSON
  // ------------------------------------------------------------------
  await test("session validation: valid cookie → 200 user JSON", async () => {
    assert.ok(SESSION, "SESSION must have been captured in test 1");
    const { status, json } = await req("GET", "/api/me", { cookie: SESSION });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    assert.ok(json?.user, "response must contain a 'user' object");
    assert.strictEqual(json.user.email, "steve@venturaclaw.com", "user.email mismatch");
    assert.strictEqual(json.user.role, "admin", "user.role must be 'admin'");
  });

  // ------------------------------------------------------------------
  // 4b. session validation — no cookie → 401
  // ------------------------------------------------------------------
  await test("session validation: no cookie → 401", async () => {
    const { status } = await req("GET", "/api/me");
    assert.strictEqual(status, 401, `expected 401, got ${status}`);
  });

  // ------------------------------------------------------------------
  // 5a. comms-compliance: slack.chat.postMessage → ok:true
  // ------------------------------------------------------------------
  await test("comms-compliance scrub: slack.chat.postMessage with channel+text → ok:true", async () => {
    const { status, json } = await req("POST", "/api/comms/scrub-preview", {
      cookie: SESSION,
      body: {
        action: "slack.chat.postMessage",
        input: { channel: "general", text: "Deployment complete — all systems go." },
      },
    });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    assert.strictEqual(json?.ok, true, `expected ok:true, got violations: ${JSON.stringify(json?.violations)}`);
    assert.strictEqual(json?.kind, "slack");
  });

  // ------------------------------------------------------------------
  // 5b. comms-compliance: twilio.sms.send without STOP → ok:false, TCPA rule
  // ------------------------------------------------------------------
  await test("comms-compliance scrub: twilio SMS without STOP → ok:false + TCPA marketing SMS violation", async () => {
    const { status, json } = await req("POST", "/api/comms/scrub-preview", {
      cookie: SESSION,
      body: {
        action: "twilio.sms.send",
        input: { to: "+15550001234", body: "Buy now! Great deals await." },
      },
    });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    assert.strictEqual(json?.ok, false, "expected ok:false (TCPA violation)");
    const rules = (json?.violations || []).map((v) => v.rule);
    assert.ok(
      rules.includes("TCPA marketing SMS"),
      `expected 'TCPA marketing SMS' in violations, got: ${JSON.stringify(rules)}`
    );
  });

  // ------------------------------------------------------------------
  // 5c. comms-compliance: gmail long body without unsubscribe/address → ok:false, CAN-SPAM
  // ------------------------------------------------------------------
  await test("comms-compliance scrub: gmail >80 chars, no unsubscribe, no address → ok:false, multiple CAN-SPAM violations", async () => {
    const longBody =
      "This is a promotional email about our incredible new product lineup that exceeds eighty characters easily.";
    assert.ok(longBody.length > 80, "test body must exceed 80 chars");

    const { status, json } = await req("POST", "/api/comms/scrub-preview", {
      cookie: SESSION,
      body: {
        action: "gmail.message.send",
        input: { to: "user@example.com", subject: "Special Offer", body: longBody },
      },
    });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    assert.strictEqual(json?.ok, false, "expected ok:false (CAN-SPAM violations)");
    assert.ok(
      json?.violations?.length >= 2,
      `expected at least 2 CAN-SPAM violations, got ${json?.violations?.length}: ${JSON.stringify(json?.violations)}`
    );
    const rules = json.violations.map((v) => v.rule);
    const hasUnsubscribe = rules.some((r) => r.includes("CAN-SPAM"));
    assert.ok(hasUnsubscribe, `expected at least one CAN-SPAM rule, got: ${JSON.stringify(rules)}`);
  });

  // ------------------------------------------------------------------
  // 5d. comms-compliance: twilio.sms.send with valid phone → ok:false (DNC fail-closed)
  // ------------------------------------------------------------------
  await test("comms-compliance scrub: twilio SMS valid phone → ok:false (DNC snapshot missing → fail-closed)", async () => {
    const { status, json } = await req("POST", "/api/comms/scrub-preview", {
      cookie: SESSION,
      body: {
        action: "twilio.sms.send",
        input: { to: "+12125550100", body: "Reply STOP to unsubscribe." },
      },
    });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    // DNC snapshot is absent → fail-closed regardless of STOP instruction
    assert.strictEqual(json?.ok, false, "expected ok:false because DNC snapshot is missing");
    const rules = (json?.violations || []).map((v) => v.rule);
    assert.ok(
      rules.includes("Federal DNC"),
      `expected 'Federal DNC' violation when snapshot absent, got: ${JSON.stringify(rules)}`
    );
  });

  // ------------------------------------------------------------------
  // 6. non-OAuth health: stripe → ok:true + account_id
  // ------------------------------------------------------------------
  await test("stripe health (api-key path): ok:true with account_id", async () => {
    const { status, json } = await req("GET", "/api/connectors/stripe/health", {
      cookie: SESSION,
    });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    assert.strictEqual(json?.ok, true, `stripe health expected ok:true, got: ${JSON.stringify(json)}`);
    assert.ok(
      json?.account_id && json.account_id.startsWith("acct_"),
      `expected account_id starting with 'acct_', got: ${json?.account_id}`
    );
  });

  // ------------------------------------------------------------------
  // 7. OAuth health for unconnected: notion → ok:false, reason "no_token"
  // ------------------------------------------------------------------
  await test("notion health (no creds saved): ok:false, reason 'no_token'", async () => {
    const { status, json } = await req("GET", "/api/connectors/notion/health", {
      cookie: SESSION,
    });
    assert.strictEqual(status, 200, `expected 200, got ${status}`);
    assert.strictEqual(json?.ok, false, `notion health expected ok:false, got: ${JSON.stringify(json)}`);
    assert.strictEqual(
      json?.reason,
      "no_token",
      `expected reason 'no_token', got '${json?.reason}'`
    );
  });

  // ------------------------------------------------------------------
  // 8. prototype-pollution guard: DELETE /api/admin/oauth/__proto__/credentials → 404
  // ------------------------------------------------------------------
  await test("prototype-pollution guard: DELETE __proto__ vendor → 404 unknown vendor", async () => {
    const { status, json } = await req(
      "DELETE",
      "/api/admin/oauth/__proto__/credentials",
      { cookie: SESSION }
    );
    assert.strictEqual(status, 404, `expected 404, got ${status}`);
    assert.strictEqual(
      json?.error,
      "unknown vendor",
      `expected 'unknown vendor' error, got: ${JSON.stringify(json)}`
    );
  });

  // ------------------------------------------------------------------
  // 9. canonical-secrets allowlist: unrelated keys must NOT be in process.env
  // ------------------------------------------------------------------
  await test("canonical-secrets allowlist: unrelated env keys are undefined in process.env", () => {
    // These keys exist in the master secrets-manager .env but are NOT in VenturaClaw's ALLOW set.
    // Verifying here exercises the allowlist loader that runs at server boot — if any of these
    // leaked into process.env the allowlist filter is broken.
    //
    // NOTE: this test runs in the test process, which did NOT load the CC env loader.
    // It checks that the server's OWN boot-time loader would block these by confirming
    // they are absent here — and by design the test process shares no state with the server.
    // What we are directly asserting: these secrets are not spilled via any env-inheritance path.
    const forbidden = [
      "LAWYER_DIRECTORY_ADMIN_PASSWORD",
      "DPLA_API_KEY",
      "ANIMALS_DB_URL",
      "SDCC_GOOGLE_DRIVE_KEY",
      "BANKRUPT_LEADS_PACER_PASSWORD",
    ];
    const leaked = forbidden.filter((k) => process.env[k] !== undefined);
    assert.strictEqual(
      leaked.length,
      0,
      `These unrelated secrets leaked into process.env (allowlist broken): ${leaked.join(", ")}`
    );
  });

  // ------------------------------------------------------------------
  // Summary
  // ------------------------------------------------------------------
  console.log("\n---------------------------------------");
  const passed = results.filter((r) => r.ok).length;
  const failed = results.filter((r) => !r.ok).length;
  console.log(`Results: ${passed} passed, ${failed} failed (${results.length} total)\n`);
  if (failed > 0) {
    console.log("Failed tests:");
    results.filter((r) => !r.ok).forEach((r) => console.log(`  - ${r.name}\n    ${r.err}`));
    process.exit(1);
  }
})();