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:
Fusion
2026-04-15 05:27:07 -07:00
committed by gsxdsm
parent e22c929264
commit b316b2e552
7 changed files with 645 additions and 13 deletions

View File

@@ -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,
});
});
});

View File

@@ -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");
});
});