Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.git
dist
docs
.claude
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions test/integration/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions test/integration/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions test/integration/full-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
76 changes: 76 additions & 0 deletions test/integration/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
expect(typeof body).toBe("object");
expect(body).not.toBeNull();
});
});
140 changes: 140 additions & 0 deletions test/integration/security-scanner.test.ts
Original file line number Diff line number Diff line change
@@ -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<ScanSummary>;

/** 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);
});
});
Loading