Skip to content
Open
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
138 changes: 38 additions & 100 deletions src/core/__tests__/trigger-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,129 +1,67 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import YAML from "yaml";
import { describe, expect, test } from "bun:test";
import { AuthMiddleware } from "../../mcp/auth.ts";
import { hashTokenSync } from "../../mcp/config.ts";
import type { McpConfig } from "../../mcp/types.ts";
import { setTriggerDeps, startServer } from "../server.ts";

/**
* Tests that the /trigger endpoint requires bearer token auth
* with operator scope. Closes ghostwright/phantom#9.
* Tests that /trigger auth logic requires bearer token with operator scope.
* Closes ghostwright/phantom#9.
*
* Tests the AuthMiddleware directly with constructed Request objects
* to avoid Bun.serve + fetch issues in GitHub Actions CI.
*/
describe("/trigger endpoint auth", () => {
const adminToken = "test-trigger-admin-token";
const readToken = "test-trigger-read-token";
const operatorToken = "test-trigger-operator-token";

const mcpConfigPath = "config/mcp.yaml";
let originalMcpYaml: string | null = null;
let server: ReturnType<typeof Bun.serve>;
let baseUrl: string;
const mcpConfig: McpConfig = {
tokens: [
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
],
rate_limit: { requests_per_minute: 60, burst: 10 },
};

beforeAll(() => {
// Back up the existing mcp.yaml so we can restore it after tests
if (existsSync(mcpConfigPath)) {
originalMcpYaml = readFileSync(mcpConfigPath, "utf-8");
}
const auth = new AuthMiddleware(mcpConfig);

// Write test tokens to mcp.yaml so loadMcpConfig picks them up
const mcpConfig: McpConfig = {
tokens: [
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
],
rate_limit: { requests_per_minute: 60, burst: 10 },
};

mkdirSync("config", { recursive: true });
writeFileSync(mcpConfigPath, YAML.stringify(mcpConfig), "utf-8");

// Start server with a random port
server = startServer({ name: "test", port: 0, role: "base" } as never, Date.now());
baseUrl = `http://localhost:${server.port}`;

// Wire trigger deps with a mock runtime
setTriggerDeps({
runtime: {
handleMessage: async () => ({
text: "ok",
cost: { totalUsd: 0 },
durationMs: 0,
}),
} as never,
function makeRequest(headers: Record<string, string> = {}): Request {
return new Request("http://localhost/trigger", {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({ task: "hello" }),
});
});

afterAll(() => {
server?.stop(true);
// Restore the original mcp.yaml
if (originalMcpYaml !== null) {
writeFileSync(mcpConfigPath, originalMcpYaml, "utf-8");
}
});

const triggerBody = JSON.stringify({ task: "hello" });
}

test("rejects request with no Authorization header", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: triggerBody,
});
expect(res.status).toBe(401);
const json = (await res.json()) as { status: string; message: string };
expect(json.message).toContain("Missing");
const result = await auth.authenticate(makeRequest());
expect(result.authenticated).toBe(false);
if (!result.authenticated) expect(result.error).toContain("Missing");
});

test("rejects request with invalid token", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer wrong-token",
},
body: triggerBody,
});
expect(res.status).toBe(401);
const result = await auth.authenticate(makeRequest({ Authorization: "Bearer wrong-token" }));
expect(result.authenticated).toBe(false);
if (!result.authenticated) expect(result.error).toContain("Invalid");
});

test("rejects read-only token (insufficient scope)", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${readToken}`,
},
body: triggerBody,
});
expect(res.status).toBe(403);
const json = (await res.json()) as { status: string; message: string };
expect(json.message).toContain("operator");
const result = await auth.authenticate(makeRequest({ Authorization: `Bearer ${readToken}` }));
expect(result.authenticated).toBe(true);
expect(auth.hasScope(result, "operator")).toBe(false);
});

test("accepts operator token", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${operatorToken}`,
},
body: triggerBody,
});
expect(res.status).toBe(200);
const json = (await res.json()) as { status: string };
expect(json.status).toBe("ok");
const result = await auth.authenticate(makeRequest({ Authorization: `Bearer ${operatorToken}` }));
expect(result.authenticated).toBe(true);
expect(auth.hasScope(result, "operator")).toBe(true);
});

test("accepts admin token", async () => {
const res = await fetch(`${baseUrl}/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${adminToken}`,
},
body: triggerBody,
});
expect(res.status).toBe(200);
const result = await auth.authenticate(makeRequest({ Authorization: `Bearer ${adminToken}` }));
expect(result.authenticated).toBe(true);
expect(auth.hasScope(result, "operator")).toBe(true);
expect(auth.hasScope(result, "admin")).toBe(true);
});
});