diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..24c0348 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +dist +docs +.claude diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a76bc8..2b069e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,6 @@ jobs: - name: Build run: npm run build + + - name: Integration tests (lifecycle) + run: npx vitest run --config vitest.integration.config.ts test/integration/lifecycle.test.ts diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..dee4f59 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,24 @@ +name: Security Scanner + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + scanner: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build integration image + run: docker compose -f test/integration/docker-compose.yml build + + - name: Run security scanner + run: > + docker compose -f test/integration/docker-compose.yml run --rm + integration npx vitest run --config vitest.integration.config.ts + test/integration/security-scanner.test.ts diff --git a/package.json b/package.json index 19ae15f..ca959c3 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "test:resilience:full": "npm run test:resilience:errors && npm run test:resilience:lifecycle && npm run test:resilience:stability:full", "test:e2e:tool-ids": "tsx test/e2e-tool-id-sanitization.ts", "test:docker:install": "bash test/run-docker-test.sh", - "test:docker:edge-cases": "bash test/docker/run-edge-cases.sh" + "test:docker:edge-cases": "bash test/docker/run-edge-cases.sh", + "test:docker:integration": "docker compose -f test/integration/docker-compose.yml up --build --abort-on-container-exit --exit-code-from integration" }, "keywords": [ "llm", diff --git a/test/integration/Dockerfile b/test/integration/Dockerfile new file mode 100644 index 0000000..be4f570 --- /dev/null +++ b/test/integration/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + jq \ + && rm -rf /var/lib/apt/lists/* + +# Install OpenClaw globally (peer dependency) +# --ignore-scripts avoids native compilation of optional deps like @discordjs/opus +RUN npm install -g --ignore-scripts openclaw@latest + +# Copy ClawRouter source and build +WORKDIR /opt/clawrouter +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Create OpenClaw config directory +RUN mkdir -p ~/.openclaw/blockrun + +# Run integration tests +CMD ["npx", "vitest", "run", "--config", "vitest.integration.config.ts"] diff --git a/test/integration/docker-compose.yml b/test/integration/docker-compose.yml new file mode 100644 index 0000000..d3c16ac --- /dev/null +++ b/test/integration/docker-compose.yml @@ -0,0 +1,9 @@ +services: + integration: + build: + context: ../.. + dockerfile: test/integration/Dockerfile + environment: + - BLOCKRUN_WALLET_KEY=${BLOCKRUN_WALLET_KEY:-} + - BLOCKRUN_PROXY_PORT=8402 + network_mode: host diff --git a/test/integration/full-flow.test.ts b/test/integration/full-flow.test.ts new file mode 100644 index 0000000..5dfc213 --- /dev/null +++ b/test/integration/full-flow.test.ts @@ -0,0 +1,82 @@ +/** + * Layer 2 — Full-flow integration tests (requires funded wallet). + * + * Gated on BLOCKRUN_WALLET_KEY env var. These tests make real API calls + * through the proxy to verify end-to-end chat completion flow. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { startTestProxy, stopTestProxy, getTestProxyUrl } from "./setup.js"; + +describe.skipIf(!process.env.BLOCKRUN_WALLET_KEY)("ClawRouter full-flow (funded wallet)", () => { + beforeAll(async () => { + await startTestProxy(); + }); + + afterAll(async () => { + await stopTestProxy(); + }); + + it("chat completion with blockrun/free returns valid response", async () => { + const res = await fetch(`${getTestProxyUrl()}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "blockrun/free", + messages: [{ role: "user", content: "Say hello in one word." }], + max_tokens: 50, + }), + }); + + expect(res.status).toBe(200); + + const body = (await res.json()) as { + choices: Array<{ message: { content: string } }>; + }; + expect(body.choices.length).toBeGreaterThan(0); + expect(body.choices[0].message.content).toBeTruthy(); + }, 60_000); + + it("chat completion with blockrun/auto resolves to a model", async () => { + const res = await fetch(`${getTestProxyUrl()}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "blockrun/auto", + messages: [{ role: "user", content: "What is 2+2?" }], + max_tokens: 50, + }), + }); + + expect(res.status).toBe(200); + + const body = (await res.json()) as { + model: string; + choices: Array<{ message: { content: string } }>; + }; + expect(body.model).toBeTruthy(); + expect(body.choices.length).toBeGreaterThan(0); + }, 60_000); + + it("streaming chat completion returns SSE with data and [DONE]", async () => { + const res = await fetch(`${getTestProxyUrl()}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "blockrun/free", + messages: [{ role: "user", content: "Say hi." }], + max_tokens: 50, + stream: true, + }), + }); + + expect(res.status).toBe(200); + + const contentType = res.headers.get("content-type") ?? ""; + expect(contentType).toContain("text/event-stream"); + + const text = await res.text(); + expect(text).toContain("data: "); + expect(text).toContain("[DONE]"); + }, 60_000); +}); diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts new file mode 100644 index 0000000..5bfdee5 --- /dev/null +++ b/test/integration/lifecycle.test.ts @@ -0,0 +1,76 @@ +/** + * Layer 1 — Lifecycle integration tests (no API keys required). + * + * Verifies proxy health, model listing, stats, and 404 handling. + * These tests always run regardless of wallet funding. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { startTestProxy, stopTestProxy, getTestProxyUrl } from "./setup.js"; + +describe("ClawRouter proxy lifecycle", () => { + beforeAll(async () => { + await startTestProxy(); + }); + + afterAll(async () => { + await stopTestProxy(); + }); + + it("GET /health returns 200 with status ok and valid wallet address", async () => { + const res = await fetch(`${getTestProxyUrl()}/health`); + expect(res.status).toBe(200); + + const body = (await res.json()) as { status: string; wallet: string }; + expect(body.status).toBe("ok"); + expect(body.wallet).toMatch(/^0x[0-9a-fA-F]{40}$/); + }); + + it("GET /health?full=true includes balance info", async () => { + const res = await fetch(`${getTestProxyUrl()}/health?full=true`); + expect(res.status).toBe(200); + + const body = (await res.json()) as Record; + expect(body.status).toBe("ok"); + + // Full health check includes either balance or balanceError + const hasBalanceInfo = "balance" in body || "balanceError" in body; + expect(hasBalanceInfo).toBe(true); + }); + + it("GET /v1/models returns model list with routing profiles", async () => { + const res = await fetch(`${getTestProxyUrl()}/v1/models`); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + object: string; + data: Array<{ id: string; object: string }>; + }; + expect(body.object).toBe("list"); + expect(body.data.length).toBeGreaterThan(0); + + const modelIds = body.data.map((m) => m.id); + // Routing profile models are registered without "blockrun/" prefix in BLOCKRUN_MODELS + expect(modelIds).toContain("auto"); + expect(modelIds).toContain("eco"); + expect(modelIds).toContain("free"); + expect(modelIds).toContain("premium"); + }); + + it("GET /nonexistent returns 404", async () => { + const res = await fetch(`${getTestProxyUrl()}/nonexistent`); + expect(res.status).toBe(404); + + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Not found"); + }); + + it("GET /stats returns stats JSON", async () => { + const res = await fetch(`${getTestProxyUrl()}/stats`); + expect(res.status).toBe(200); + + const body = (await res.json()) as Record; + expect(typeof body).toBe("object"); + expect(body).not.toBeNull(); + }); +}); diff --git a/test/integration/security-scanner.test.ts b/test/integration/security-scanner.test.ts new file mode 100644 index 0000000..9684ab7 --- /dev/null +++ b/test/integration/security-scanner.test.ts @@ -0,0 +1,140 @@ +/** + * OpenClaw security scanner integration tests. + * + * Runs OpenClaw's skill-scanner (the same one that fires during plugin install) + * against ClawRouter's built dist/ to catch regressions like process.env + * triggering env-harvesting warnings. + * + * The scanner is imported directly from the installed openclaw package. + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { execSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +interface ScanFinding { + ruleId: string; + severity: "critical" | "warn" | "info"; + file: string; + line: number; + message: string; + evidence: string; +} + +interface ScanSummary { + scannedFiles: number; + critical: number; + warn: number; + info: number; + findings: ScanFinding[]; +} + +type ScanFn = (dir: string) => Promise; + +/** Resolve openclaw dist dir — try global npm root first, fall back to Docker path. */ +function resolveOpenclawDist(): string { + try { + const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim(); + return `${globalRoot}/openclaw/dist/`; + } catch { + return "/usr/local/lib/node_modules/openclaw/dist/"; + } +} + +/** Resolve ClawRouter dist dir — relative to this file, fall back to Docker path. */ +function resolveClawrouterDist(): string { + const local = resolve(__dirname, "../../dist"); + try { + readdirSync(local); + return local; + } catch { + return "/opt/clawrouter/dist"; + } +} + +describe("OpenClaw security scanner", () => { + let scanDirectoryWithSummary: ScanFn | undefined; + let distDir: string; + + beforeAll(async () => { + distDir = resolveClawrouterDist(); + + // Locate openclaw's skill-scanner chunk in its dist/ + const openclawDist = resolveOpenclawDist(); + try { + const files = readdirSync(openclawDist); + const scannerFile = files.find((f) => f.startsWith("skill-scanner")); + if (!scannerFile) { + console.warn("[scanner] skill-scanner chunk not found in openclaw dist — skipping"); + return; + } + const mod = (await import(pathToFileURL(`${openclawDist}${scannerFile}`).href)) as Record< + string, + unknown + >; + // The scanner exports scanDirectoryWithSummary as a minified name + const fn = Object.values(mod).find((v) => typeof v === "function") as ScanFn | undefined; + if (fn) { + scanDirectoryWithSummary = fn; + } else { + console.warn("[scanner] No function export found in skill-scanner module — skipping"); + } + } catch (err) { + console.warn(`[scanner] Could not load openclaw scanner: ${err}`); + } + }); + + it("dist/ has zero critical findings (no env-harvesting)", async () => { + if (!scanDirectoryWithSummary) { + console.log("[scanner] Scanner not available — test passes vacuously"); + return; + } + + const result = await scanDirectoryWithSummary(distDir); + + console.log(`[scanner] Scanned ${result.scannedFiles} files`); + console.log( + `[scanner] Results: ${result.critical} critical, ${result.warn} warn, ${result.info} info`, + ); + + if (result.findings.length > 0) { + for (const f of result.findings) { + console.log(`[scanner] [${f.severity}] ${f.ruleId}: ${f.message}`); + console.log(`[scanner] ${f.file}:${f.line}`); + console.log(`[scanner] evidence: ${f.evidence}`); + } + } + + // No critical findings — this catches env-harvesting regressions + expect(result.critical).toBe(0); + + // Verify env-harvesting specifically is absent + const envHarvesting = result.findings.filter((f) => f.ruleId === "env-harvesting"); + expect(envHarvesting).toHaveLength(0); + }); + + it("dist/ has no unexpected warn-level findings", async () => { + if (!scanDirectoryWithSummary) { + console.log("[scanner] Scanner not available — test passes vacuously"); + return; + } + + const result = await scanDirectoryWithSummary(distDir); + + // potential-exfiltration is expected (wallet read + network send) + const unexpectedWarns = result.findings.filter( + (f) => f.severity === "warn" && f.ruleId !== "potential-exfiltration", + ); + + if (unexpectedWarns.length > 0) { + for (const f of unexpectedWarns) { + console.error(`[scanner] Unexpected warning: [${f.ruleId}] ${f.message}`); + console.error(`[scanner] ${f.file}:${f.line}`); + } + } + + expect(unexpectedWarns).toHaveLength(0); + }); +}); diff --git a/test/integration/setup.ts b/test/integration/setup.ts new file mode 100644 index 0000000..031fe01 --- /dev/null +++ b/test/integration/setup.ts @@ -0,0 +1,66 @@ +/** + * Integration test setup — programmatically starts ClawRouter proxy. + * + * Shared across all integration test files via beforeAll/afterAll. + * Starts the proxy on port 8402, waits for /health to return 200, + * and caches the handle so multiple test files share one instance. + */ + +import { startProxy } from "../../src/proxy.js"; +import { resolveOrGenerateWalletKey } from "../../src/auth.js"; +import type { ProxyHandle } from "../../src/proxy.js"; + +const TEST_PORT = 8402; +const HEALTH_POLL_INTERVAL_MS = 200; +const HEALTH_TIMEOUT_MS = 5_000; + +let proxyHandle: ProxyHandle | undefined; + +/** + * Start the test proxy on port 8402. + * Polls /health until it returns 200 (up to 5s), then returns the handle. + * Reuses an existing handle if already started. + */ +export async function startTestProxy(): Promise { + if (proxyHandle) return proxyHandle; + + const wallet = await resolveOrGenerateWalletKey(); + + proxyHandle = await startProxy({ + walletKey: wallet.key, + port: TEST_PORT, + skipBalanceCheck: true, + }); + + // Wait for /health to return 200 + const deadline = Date.now() + HEALTH_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const res = await fetch(`${proxyHandle.baseUrl}/health`); + if (res.ok) return proxyHandle; + } catch { + // proxy not ready yet + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS)); + } + + throw new Error(`Test proxy did not become healthy within ${HEALTH_TIMEOUT_MS}ms`); +} + +/** + * Stop the test proxy and clear the cached handle. + */ +export async function stopTestProxy(): Promise { + if (!proxyHandle) return; + await proxyHandle.close(); + proxyHandle = undefined; +} + +/** + * Get the base URL of the running test proxy (e.g. http://127.0.0.1:8402). + * Throws if the proxy has not been started. + */ +export function getTestProxyUrl(): string { + if (!proxyHandle) throw new Error("Test proxy not started — call startTestProxy() first"); + return proxyHandle.baseUrl; +} diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..84a23f2 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/integration/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 15_000, + }, +});