mirror of
https://github.com/block/goose.git
synced 2026-04-28 03:29:36 +00:00
Some checks failed
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check OpenAPI Schema is Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Cargo Deny / deny (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish to npm / Generate ACP Schema (push) Has been cancelled
Publish to npm / Build goose CLI (darwin-arm64) (push) Has been cancelled
Publish to npm / Build goose CLI (darwin-x64) (push) Has been cancelled
Publish to npm / Build goose CLI (linux-arm64) (push) Has been cancelled
Publish to npm / Build goose CLI (linux-x64) (push) Has been cancelled
Publish to npm / Release to npm (push) Has been cancelled
Signed-off-by: Michael Neale <michael.neale@gmail.com>
306 lines
9.3 KiB
JavaScript
306 lines
9.3 KiB
JavaScript
import {
|
|
env,
|
|
createExecutionContext,
|
|
waitOnExecutionContext,
|
|
fetchMock,
|
|
} from "cloudflare:test";
|
|
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
import worker from "../src/index.js";
|
|
|
|
let testKeyPair;
|
|
let testJwk;
|
|
const TEST_KID = "test-kid-001";
|
|
|
|
beforeAll(async () => {
|
|
testKeyPair = await crypto.subtle.generateKey(
|
|
{
|
|
name: "RSASSA-PKCS1-v1_5",
|
|
modulusLength: 2048,
|
|
publicExponent: new Uint8Array([1, 0, 1]),
|
|
hash: "SHA-256",
|
|
},
|
|
true,
|
|
["sign", "verify"],
|
|
);
|
|
const exported = await crypto.subtle.exportKey("jwk", testKeyPair.publicKey);
|
|
testJwk = { ...exported, kid: TEST_KID, alg: "RS256", use: "sig" };
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchMock.deactivate();
|
|
});
|
|
|
|
function base64UrlEncode(data) {
|
|
const str = typeof data === "string" ? data : JSON.stringify(data);
|
|
return btoa(str).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
}
|
|
|
|
async function createSignedJwt(payload, kid = TEST_KID) {
|
|
const header = { alg: "RS256", typ: "JWT", kid };
|
|
const headerB64 = base64UrlEncode(header);
|
|
const payloadB64 = base64UrlEncode(payload);
|
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
const signature = await crypto.subtle.sign(
|
|
"RSASSA-PKCS1-v1_5",
|
|
testKeyPair.privateKey,
|
|
data,
|
|
);
|
|
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
return `${headerB64}.${payloadB64}.${sigB64}`;
|
|
}
|
|
|
|
let jtiCounter = 0;
|
|
function validPayload(overrides = {}) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return {
|
|
iss: "https://token.actions.githubusercontent.com",
|
|
aud: "goose-oidc-proxy",
|
|
iat: now - 10,
|
|
exp: now + 300,
|
|
jti: `test-jti-${++jtiCounter}`,
|
|
repository: "aaif-goose/goose",
|
|
ref: "refs/heads/main",
|
|
sub: "repo:aaif-goose/goose:ref:refs/heads/main",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockAll(upstreamStatus = 200, upstreamBody = { ok: true }) {
|
|
fetchMock.activate();
|
|
fetchMock.disableNetConnect();
|
|
|
|
const oidc = fetchMock.get("https://token.actions.githubusercontent.com");
|
|
oidc
|
|
.intercept({ path: "/.well-known/openid-configuration", method: "GET" })
|
|
.reply(
|
|
200,
|
|
JSON.stringify({
|
|
jwks_uri:
|
|
"https://token.actions.githubusercontent.com/.well-known/jwks",
|
|
}),
|
|
)
|
|
.persist();
|
|
oidc
|
|
.intercept({ path: "/.well-known/jwks", method: "GET" })
|
|
.reply(200, JSON.stringify({ keys: [testJwk] }))
|
|
.persist();
|
|
|
|
const upstream = fetchMock.get("https://api.anthropic.com");
|
|
upstream
|
|
.intercept({ path: /.*/, method: "POST" })
|
|
.reply(upstreamStatus, JSON.stringify(upstreamBody));
|
|
}
|
|
|
|
function mockAllPersistent(upstreamStatus = 200, upstreamBody = { ok: true }) {
|
|
fetchMock.activate();
|
|
fetchMock.disableNetConnect();
|
|
|
|
const oidc = fetchMock.get("https://token.actions.githubusercontent.com");
|
|
oidc
|
|
.intercept({ path: "/.well-known/openid-configuration", method: "GET" })
|
|
.reply(
|
|
200,
|
|
JSON.stringify({
|
|
jwks_uri:
|
|
"https://token.actions.githubusercontent.com/.well-known/jwks",
|
|
}),
|
|
)
|
|
.persist();
|
|
oidc
|
|
.intercept({ path: "/.well-known/jwks", method: "GET" })
|
|
.reply(200, JSON.stringify({ keys: [testJwk] }))
|
|
.persist();
|
|
|
|
const upstream = fetchMock.get("https://api.anthropic.com");
|
|
upstream
|
|
.intercept({ path: /.*/, method: "POST" })
|
|
.reply(upstreamStatus, JSON.stringify(upstreamBody))
|
|
.persist();
|
|
}
|
|
|
|
// Mock TokenBucket Durable Object for unit tests
|
|
function mockTokenBucket(overrides = {}) {
|
|
const defaults = { allowed: true, remaining: 199 };
|
|
const response = { ...defaults, ...overrides };
|
|
|
|
return {
|
|
idFromName: () => "mock-id",
|
|
get: () => ({
|
|
fetch: async () => Response.json(response),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function testEnv(overrides = {}) {
|
|
return {
|
|
OIDC_ISSUER: "https://token.actions.githubusercontent.com",
|
|
OIDC_AUDIENCE: "goose-oidc-proxy",
|
|
UPSTREAM_URL: "https://api.anthropic.com",
|
|
UPSTREAM_AUTH_HEADER: "x-api-key",
|
|
UPSTREAM_API_KEY: "sk-ant-real-key",
|
|
ALLOWED_REPOS: "aaif-goose/goose",
|
|
MAX_TOKEN_AGE_SECONDS: "1200",
|
|
MAX_REQUESTS_PER_TOKEN: "200",
|
|
RATE_LIMIT_PER_SECOND: "2",
|
|
TOKEN_BUCKET: mockTokenBucket(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("rejects invalid requests", () => {
|
|
it("missing auth", async () => {
|
|
const request = new Request("https://proxy.example.com/v1/messages");
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(request, testEnv(), ctx);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).error).toBe("Missing authentication");
|
|
});
|
|
|
|
it("malformed token", async () => {
|
|
const request = new Request("https://proxy.example.com/v1/messages", {
|
|
headers: { "x-api-key": "not-a-jwt" },
|
|
});
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(request, testEnv(), ctx);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).error).toBe("Malformed JWT");
|
|
});
|
|
|
|
it("wrong claims (repo, audience, issuer)", async () => {
|
|
for (const [override, expectedError] of [
|
|
[{ repository: "evil/repo" }, "not allowed"],
|
|
[{ aud: "wrong" }, "Invalid audience"],
|
|
[{ iss: "https://evil.example.com" }, "Invalid issuer"],
|
|
]) {
|
|
const token = await createSignedJwt(validPayload(override));
|
|
const request = new Request("https://proxy.example.com/v1/messages", {
|
|
headers: { "x-api-key": token },
|
|
});
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(request, testEnv(), ctx);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).error).toContain(expectedError);
|
|
}
|
|
});
|
|
|
|
it("token too old", async () => {
|
|
const token = await createSignedJwt(
|
|
validPayload({ iat: Math.floor(Date.now() / 1000) - 1500 }),
|
|
);
|
|
const request = new Request("https://proxy.example.com/v1/messages", {
|
|
headers: { "x-api-key": token },
|
|
});
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(request, testEnv(), ctx);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).error).toBe("Token too old");
|
|
});
|
|
});
|
|
|
|
describe("proxies valid requests", () => {
|
|
it("forwards to upstream with injected API key", async () => {
|
|
const token = await createSignedJwt(validPayload());
|
|
mockAll(200, { id: "msg_123", type: "message" });
|
|
|
|
const request = new Request("https://proxy.example.com/v1/messages", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-api-key": token,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ model: "claude-sonnet-4-20250514", messages: [] }),
|
|
});
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(request, testEnv(), ctx);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect((await response.json()).id).toBe("msg_123");
|
|
});
|
|
|
|
it("accepts recently-expired token within MAX_TOKEN_AGE_SECONDS", async () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const token = await createSignedJwt(
|
|
validPayload({ iat: now - 600, exp: now - 300 }),
|
|
);
|
|
mockAll(200, { ok: true });
|
|
|
|
const request = new Request("https://proxy.example.com/v1/messages", {
|
|
method: "POST",
|
|
headers: { "x-api-key": token, "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(request, testEnv(), ctx);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("token budget and rate limiting", () => {
|
|
it("rejects when budget exhausted", async () => {
|
|
const token = await createSignedJwt(validPayload());
|
|
mockAll();
|
|
|
|
const request = new Request("https://proxy.example.com/v1/messages", {
|
|
method: "POST",
|
|
headers: { "x-api-key": token, "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(
|
|
request,
|
|
testEnv({
|
|
TOKEN_BUCKET: mockTokenBucket({
|
|
allowed: false,
|
|
error: "budget_exhausted",
|
|
}),
|
|
}),
|
|
ctx,
|
|
);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(429);
|
|
expect((await response.json()).error).toBe("Token budget exhausted");
|
|
expect(response.headers.get("Retry-After")).toBeNull();
|
|
});
|
|
|
|
it("rejects with Retry-After when rate limited", async () => {
|
|
const token = await createSignedJwt(validPayload());
|
|
mockAll();
|
|
|
|
const request = new Request("https://proxy.example.com/v1/messages", {
|
|
method: "POST",
|
|
headers: { "x-api-key": token, "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const ctx = createExecutionContext();
|
|
const response = await worker.fetch(
|
|
request,
|
|
testEnv({
|
|
TOKEN_BUCKET: mockTokenBucket({
|
|
allowed: false,
|
|
error: "rate_limited",
|
|
}),
|
|
}),
|
|
ctx,
|
|
);
|
|
await waitOnExecutionContext(ctx);
|
|
|
|
expect(response.status).toBe(429);
|
|
expect((await response.json()).error).toBe("Rate limit exceeded");
|
|
expect(response.headers.get("Retry-After")).toBe("1");
|
|
});
|
|
});
|