feat(FN-3506): add discovery contract and integrate remote node discovery i

Merges node discovery onboarding (FN-3506) with two steps: a pre-registration discovery contract wired into the AddNodeModal UI, and full remote discovery integration with documentation. Also adds a local embedded runtime manager to the desktop app (FN-3404) and wires permanent-agent approval contex

Fusion-Task-Id: FN-3506
This commit is contained in:
Fusion
2026-05-07 19:54:23 -07:00
committed by gsxdsm
parent 4770b1225c
commit 9f60a0da50
11 changed files with 576 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import { request } from "../test-request.js";
@@ -100,6 +100,10 @@ function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
describe("Node routes", () => {
const app = createServer(new MockStore() as any);
afterEach(() => {
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
mockListNodes.mockResolvedValue([]);
@@ -163,6 +167,130 @@ describe("Node routes", () => {
mockListProjectNodePathMappingsForNode.mockResolvedValue([]);
});
describe("POST /api/nodes/discover-projects", () => {
it("returns normalized remote project discovery payload on success", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ([
{
id: "proj_1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
},
]),
});
vi.stubGlobal("fetch", fetchMock);
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com", apiKey: "secret" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
projects: [
{
id: "proj_1",
name: "Project One",
path: "/srv/project-one",
status: "active",
isolationMode: "in-process",
},
],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://node.example.com/api/projects",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer secret" },
signal: expect.any(AbortSignal),
}),
);
});
it("returns upstream HTTP failure status and message", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 503,
statusText: "Service Unavailable",
json: async () => ({ error: "upstream down" }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "upstream down" });
});
it("returns 401 when upstream rejects auth", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
json: async () => ({ error: "Invalid API key" }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com", apiKey: "wrong" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Invalid API key" });
});
it("rejects malformed upstream payload", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ projects: [] }),
}));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(502);
expect(res.body).toEqual({ error: "Remote node returned malformed project discovery payload" });
});
it("returns 504 on timeout/unreachable abort", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(Object.assign(new Error("aborted"), { name: "AbortError" })));
const res = await request(
app,
"POST",
"/api/nodes/discover-projects",
JSON.stringify({ url: "https://node.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(504);
expect(res.body).toEqual({ error: "Remote node discovery request timed out" });
});
});
it("GET /api/nodes/:id/path-mappings returns node mappings", async () => {
mockListProjectNodePathMappingsForNode.mockResolvedValue([
{