feat(FN-1832): add daemon mode and auth-aware node connect
- Add --daemon flag to fn serve command for headless node operation - Add daemon-aware messaging to fn node connect (shows auth status) - Add CLI integration tests for daemon mode (serve.test.ts) - Add auth middleware integration tests (auth-middleware-integration.test.ts) - Fix auth middleware test for /api/health subpath - Update bin.test.ts with daemon mode test coverage
This commit is contained in:
@@ -328,6 +328,29 @@ describe("bin", () => {
|
||||
paused: true,
|
||||
interactive: true,
|
||||
host: "127.0.0.1",
|
||||
daemon: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes serve command with --daemon flag", async () => {
|
||||
await runBin(["serve", "--daemon"]);
|
||||
|
||||
expect(runServe).toHaveBeenCalledWith(4040, {
|
||||
paused: false,
|
||||
interactive: false,
|
||||
host: undefined,
|
||||
daemon: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes serve command with --daemon flag combined with other options", async () => {
|
||||
await runBin(["serve", "--port", "6060", "--daemon", "--host", "0.0.0.0"]);
|
||||
|
||||
expect(runServe).toHaveBeenCalledWith(6060, {
|
||||
paused: false,
|
||||
interactive: false,
|
||||
host: "0.0.0.0",
|
||||
daemon: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,7 +426,7 @@ describe("bin", () => {
|
||||
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(help).toContain("fn project list | ls");
|
||||
expect(help).toContain("fn node list | ls");
|
||||
expect(help).toContain("fn serve [--port <port>] [--host <host>] [--paused]");
|
||||
expect(help).toContain("fn serve [--port <port>] [--host <host>] [--paused] [--daemon]");
|
||||
expect(help).toContain("fn task comments <id>");
|
||||
expect(help).toContain("--project, -P <name>");
|
||||
});
|
||||
|
||||
@@ -68,8 +68,9 @@ Usage:
|
||||
fn dashboard --paused Start with automation paused
|
||||
fn dashboard --dev Start web UI only (no AI engine)
|
||||
fn dashboard --interactive Start with interactive port selection
|
||||
fn serve [--port <port>] [--host <host>] [--paused]
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon]
|
||||
Start Fusion as a headless node (API + engine, no UI)
|
||||
Use --daemon to enable bearer token authentication
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only]
|
||||
Start Fusion daemon (API + engine, auth required)
|
||||
fn desktop Launch the Fusion desktop app (Electron)
|
||||
@@ -327,7 +328,8 @@ async function main() {
|
||||
const interactive = args.includes("--interactive");
|
||||
const hostIdx = args.indexOf("--host");
|
||||
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
|
||||
await runServe(port, { paused, interactive, host });
|
||||
const daemon = args.includes("--daemon");
|
||||
await runServe(port, { paused, interactive, host, daemon });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -669,3 +669,136 @@ describe("node commands", () => {
|
||||
await expect(runNodeList()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon-aware node connect", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit");
|
||||
}) as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
mockRegisterNode.mockResolvedValue(makeNode());
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockGetNodeByName.mockResolvedValue(undefined);
|
||||
mockUnregisterNode.mockResolvedValue(undefined);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows '(authenticated)' when apiKey provided and health check succeeds", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "daemon-node",
|
||||
type: "remote",
|
||||
url: "https://daemon.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeConnect("daemon-node", {
|
||||
url: "https://daemon.example.com",
|
||||
apiKey: "fn_daemontoken123456",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("(authenticated)");
|
||||
expect(output).toContain("daemon-node");
|
||||
});
|
||||
|
||||
it("shows standard success message when no apiKey provided", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "regular-node",
|
||||
type: "remote",
|
||||
url: "https://regular.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeConnect("regular-node", {
|
||||
url: "https://regular.example.com",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Node connected successfully");
|
||||
expect(output).not.toContain("(authenticated)");
|
||||
});
|
||||
|
||||
it("shows auth failure hint when apiKey provided but node is offline", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "offline-daemon",
|
||||
type: "remote",
|
||||
url: "https://offline.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("offline");
|
||||
|
||||
await runNodeConnect("offline-daemon", {
|
||||
url: "https://offline.example.com",
|
||||
apiKey: "fn_wrongtoken123456",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("API key may be incorrect");
|
||||
expect(output).toContain("offline");
|
||||
});
|
||||
|
||||
it("shows auth failure hint when apiKey provided but health check returns error", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "error-daemon",
|
||||
type: "remote",
|
||||
url: "https://error.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("error");
|
||||
|
||||
await runNodeConnect("error-daemon", {
|
||||
url: "https://error.example.com",
|
||||
apiKey: "fn_badtoken123456",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("API key may be incorrect");
|
||||
expect(output).toContain("error");
|
||||
});
|
||||
|
||||
it("registers node with apiKey when connecting to daemon", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "auth-node",
|
||||
type: "remote",
|
||||
url: "https://auth.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeConnect("auth-node", {
|
||||
url: "https://auth.example.com",
|
||||
apiKey: "fn_secret1234567890",
|
||||
maxConcurrent: 3,
|
||||
});
|
||||
|
||||
expect(mockRegisterNode).toHaveBeenCalledWith({
|
||||
name: "auth-node",
|
||||
type: "remote",
|
||||
url: "https://auth.example.com",
|
||||
apiKey: "fn_secret1234567890",
|
||||
maxConcurrent: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,6 +459,13 @@ vi.mock("@fusion/core", () => ({
|
||||
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
||||
processAndAuditInsightExtraction: mocks.processAndAuditInsightExtractionMock,
|
||||
DaemonTokenManager: vi.fn().mockImplementation(() => ({
|
||||
getToken: vi.fn().mockResolvedValue(null),
|
||||
generateToken: vi.fn().mockResolvedValue("fn_generated1234567890"),
|
||||
storeToken: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
GlobalSettingsStore: vi.fn().mockImplementation(() => ({})),
|
||||
resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
@@ -1238,3 +1245,129 @@ describe("runServe — Peer exchange and discovery", () => {
|
||||
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "offline" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runServe --daemon flag", () => {
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
const originalExit = process.exit;
|
||||
const originalEnv = process.env.FUSION_DAEMON_TOKEN;
|
||||
|
||||
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processOnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
const handlers = signalHandlers[signal];
|
||||
expect(handlers.length).toBeGreaterThan(0);
|
||||
handlers[handlers.length - 1]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.reset();
|
||||
|
||||
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
||||
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
|
||||
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers[event].push(listener);
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.on);
|
||||
process.exit = vi.fn() as never;
|
||||
|
||||
// Clear env var before each test
|
||||
delete process.env.FUSION_DAEMON_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
processOnSpy.mockRestore();
|
||||
process.cwd = originalCwd;
|
||||
process.on = originalOn;
|
||||
process.exit = originalExit;
|
||||
|
||||
// Restore env var
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.FUSION_DAEMON_TOKEN = originalEnv;
|
||||
} else {
|
||||
delete process.env.FUSION_DAEMON_TOKEN;
|
||||
}
|
||||
});
|
||||
|
||||
it("passes daemonToken to createServer when daemon: true", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon).toBeDefined();
|
||||
expect(serverOpts.daemon?.token).toBeDefined();
|
||||
expect(typeof serverOpts.daemon?.token).toBe("string");
|
||||
expect(serverOpts.daemon?.token).toMatch(/^fn_/);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("shows '(daemon mode)' in startup banner when daemon: true", async () => {
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("(daemon mode)");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("shows 'fn node connect' hint in startup banner when daemon: true", async () => {
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("fn node connect");
|
||||
expect(output).toContain("--api-key");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("resolves token from FUSION_DAEMON_TOKEN env var", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
process.env.FUSION_DAEMON_TOKEN = "fn_envtest1234567890";
|
||||
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon?.token).toBe("fn_envtest1234567890");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does not pass daemon to createServer when daemon: false", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, { daemon: false });
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon).toBeUndefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does not pass daemon to createServer when daemon option is omitted", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon).toBeUndefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,7 +288,13 @@ export async function runNodeConnect(
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(" ✓ Node connected successfully");
|
||||
if (options.apiKey && status === "online") {
|
||||
console.log(` ✓ Node '${node.name}' connected (authenticated)`);
|
||||
} else if (options.apiKey && (status === "offline" || status === "error")) {
|
||||
console.log(` ⚠ Node is offline — the API key may be incorrect or the node may be unreachable`);
|
||||
} else {
|
||||
console.log(" ✓ Node connected successfully");
|
||||
}
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
getTaskMergeBlocker,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
||||
processAndAuditInsightExtraction,
|
||||
DaemonTokenManager,
|
||||
GlobalSettingsStore,
|
||||
resolveGlobalDir,
|
||||
} from "@fusion/core";
|
||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
|
||||
@@ -186,7 +189,7 @@ function ensureProcessDiagnostics(): void {
|
||||
|
||||
export async function runServe(
|
||||
port: number,
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string } = {},
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean } = {},
|
||||
) {
|
||||
serveStartTime = Date.now();
|
||||
ensureProcessDiagnostics();
|
||||
@@ -510,6 +513,34 @@ export async function runServe(
|
||||
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
|
||||
|
||||
// ── Daemon token resolution ─────────────────────────────────────────────
|
||||
//
|
||||
// When --daemon flag is set, resolve the daemon token using the same
|
||||
// priority as fn daemon: env var > stored token > generate new token.
|
||||
//
|
||||
let daemonToken: string | undefined;
|
||||
if (opts.daemon) {
|
||||
// 1. Check environment variable first
|
||||
daemonToken = process.env.FUSION_DAEMON_TOKEN;
|
||||
|
||||
// 2. Check stored token in global settings
|
||||
if (!daemonToken) {
|
||||
const globalDir = resolveGlobalDir();
|
||||
const settingsStore = new GlobalSettingsStore(globalDir);
|
||||
const tokenManager = new DaemonTokenManager(settingsStore);
|
||||
daemonToken = await tokenManager.getToken();
|
||||
}
|
||||
|
||||
// 3. Generate and store a new token if none exists
|
||||
if (!daemonToken) {
|
||||
const globalDir = resolveGlobalDir();
|
||||
const settingsStore = new GlobalSettingsStore(globalDir);
|
||||
const tokenManager = new DaemonTokenManager(settingsStore);
|
||||
daemonToken = await tokenManager.generateToken();
|
||||
await tokenManager.storeToken(daemonToken);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skills adapter for skills discovery and execution toggling ─────────────
|
||||
//
|
||||
// Create the skills adapter using the same DefaultPackageManager instance
|
||||
@@ -547,6 +578,7 @@ export async function runServe(
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
headless: true,
|
||||
skillsAdapter,
|
||||
daemon: daemonToken ? { token: daemonToken } : undefined,
|
||||
});
|
||||
|
||||
const server = app.listen(selectedPort, selectedHost);
|
||||
@@ -610,15 +642,34 @@ export async function runServe(
|
||||
console.warn(`[serve] Failed to set local node online: ${message}`);
|
||||
}
|
||||
|
||||
// Import maskApiKey helper for token display
|
||||
const { maskApiKey } = await import("./node.js");
|
||||
|
||||
console.log();
|
||||
console.log(` Fusion Node`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://${selectedHost}:${actualPort}`);
|
||||
console.log();
|
||||
console.log(` Health: GET /api/health`);
|
||||
console.log(` API: /api/*`);
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
if (daemonToken) {
|
||||
console.log(` Fusion Node (daemon mode)`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://${selectedHost}:${actualPort}`);
|
||||
console.log();
|
||||
console.log(` Token: fn_${maskApiKey(daemonToken)}`);
|
||||
console.log();
|
||||
console.log(` Connect from another machine:`);
|
||||
console.log(` fn node connect <name> --url http://<host>:<port> --api-key ${daemonToken}`);
|
||||
console.log();
|
||||
console.log(` Health: GET /api/health`);
|
||||
console.log(` API: /api/* (bearer token required)`);
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
} else {
|
||||
console.log(` Fusion Node`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://${selectedHost}:${actualPort}`);
|
||||
console.log();
|
||||
console.log(` Health: GET /api/health`);
|
||||
console.log(` API: /api/*`);
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Integration tests for auth middleware with createServer.
|
||||
* Tests the full end-to-end authentication flow: valid token accepted,
|
||||
* no/invalid token rejected, health endpoint exempt, backward compatibility.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: mockInit,
|
||||
close: mockClose,
|
||||
getLocalNode: vi.fn().mockResolvedValue({
|
||||
id: "node_local",
|
||||
name: "local",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "node_local",
|
||||
name: "local",
|
||||
type: "local",
|
||||
status: "online",
|
||||
maxConcurrent: 2,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-auth-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-auth-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
getMissionStore() {
|
||||
return {
|
||||
listMissions: vi.fn().mockResolvedValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listTemplates: vi.fn().mockResolvedValue([]),
|
||||
createTemplate: vi.fn(),
|
||||
getTemplate: vi.fn(),
|
||||
updateTemplate: vi.fn(),
|
||||
deleteTemplate: vi.fn(),
|
||||
instantiateMission: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("Auth middleware integration with createServer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("with daemonToken option", () => {
|
||||
it("accepts valid bearer token", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
undefined,
|
||||
{ Authorization: "Bearer fn_test1234567890abcdef" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects request without Authorization header", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
const response = await request(app, "GET", "/api/tasks");
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "Unauthorized",
|
||||
message: "Valid bearer token required",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects request with invalid bearer token", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
undefined,
|
||||
{ Authorization: "Bearer fn_wrong_token" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "Unauthorized",
|
||||
message: "Valid bearer token required",
|
||||
});
|
||||
});
|
||||
|
||||
it("exempts /api/health from authentication", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
const response = await request(app, "GET", "/api/health");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("exempts /api/health/ with subpath from authentication", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
// /api/health returns 200 (health check endpoint)
|
||||
// The middleware correctly exempts paths starting with /api/health/
|
||||
// but the specific path /api/health/detailed may not exist (404 vs 401)
|
||||
// We test that the request is NOT rejected with 401 (auth not enforced)
|
||||
const response = await request(app, "GET", "/api/health/detailed");
|
||||
|
||||
// The auth middleware is bypassed, so we get 404 (route not found) not 401 (unauthorized)
|
||||
expect(response.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it("accepts valid token with Bearer prefix variation", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
// Test that the middleware correctly parses Bearer prefix
|
||||
const response = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
undefined,
|
||||
{ Authorization: "Bearer fn_test1234567890abcdef" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects request with wrong Bearer prefix", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
undefined,
|
||||
{ Authorization: "Basic fn_test1234567890abcdef" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects request with empty Bearer token", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore, {
|
||||
daemon: { token: "fn_test1234567890abcdef" },
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
undefined,
|
||||
{ Authorization: "Bearer " }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without daemonToken option (backward compatibility)", () => {
|
||||
it("allows unauthenticated access when no daemonToken is set", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore);
|
||||
|
||||
const response = await request(app, "GET", "/api/tasks");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows access to /api/health without daemonToken", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore);
|
||||
|
||||
const response = await request(app, "GET", "/api/health");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with FUSION_DAEMON_TOKEN environment variable", () => {
|
||||
const originalEnv = process.env.FUSION_DAEMON_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.FUSION_DAEMON_TOKEN = originalEnv;
|
||||
} else {
|
||||
delete process.env.FUSION_DAEMON_TOKEN;
|
||||
}
|
||||
});
|
||||
|
||||
it("activates auth when FUSION_DAEMON_TOKEN env var is set", async () => {
|
||||
process.env.FUSION_DAEMON_TOKEN = "fn_envtoken1234567890";
|
||||
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as unknown as TaskStore);
|
||||
|
||||
// Should reject without token
|
||||
const noAuthResponse = await request(app, "GET", "/api/tasks");
|
||||
expect(noAuthResponse.status).toBe(401);
|
||||
|
||||
// Should accept with correct token
|
||||
const authResponse = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/tasks",
|
||||
undefined,
|
||||
{ Authorization: "Bearer fn_envtoken1234567890" }
|
||||
);
|
||||
expect(authResponse.status).toBe(200);
|
||||
|
||||
// Should exempt health endpoint
|
||||
const healthResponse = await request(app, "GET", "/api/health");
|
||||
expect(healthResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user