test(FN-4755): complete Step 2 — add sync contract endpoint catalog
Fusion-Task-Id: FN-4755 Fusion-Task-Lineage: 1cc9ee4c-fa97-4820-83c4-4a20d66a1148
This commit is contained in:
committed by
gsxdsm
parent
26fe54abf8
commit
a71cc8b783
@@ -0,0 +1,425 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { request, get } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
import { MISSING_REMOTE_NODE_API_KEY_MESSAGE } from "../routes/register-settings-sync-helpers.js";
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
default: {
|
||||
readFileSync: vi.fn().mockReturnValue(JSON.stringify({
|
||||
anthropic: { type: "api_key", key: "sk-ant-test123" },
|
||||
openai: { type: "api_key", key: "sk-test456" },
|
||||
})),
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
readFileSync: vi.fn().mockReturnValue(JSON.stringify({
|
||||
anthropic: { type: "api_key", key: "sk-ant-test123" },
|
||||
openai: { type: "api_key", key: "sk-test456" },
|
||||
})),
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockListNodes = vi.fn();
|
||||
const mockGetNode = vi.fn();
|
||||
const mockGetLocalPeerInfo = vi.fn();
|
||||
const mockGetSettingsSyncState = vi.fn();
|
||||
const mockUpdateSettingsSyncState = vi.fn();
|
||||
const mockApplyRemoteSettings = vi.fn();
|
||||
const mockGetSettingsForSync = vi.fn();
|
||||
const mockGetAuthMaterialSnapshot = vi.fn();
|
||||
const mockApplyAuthMaterialSnapshot = vi.fn();
|
||||
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
CentralCore: class MockCentralCore {
|
||||
init = mockInit;
|
||||
close = mockClose;
|
||||
listNodes = mockListNodes;
|
||||
getNode = mockGetNode;
|
||||
getLocalPeerInfo = mockGetLocalPeerInfo;
|
||||
getSettingsSyncState = mockGetSettingsSyncState;
|
||||
updateSettingsSyncState = mockUpdateSettingsSyncState;
|
||||
applyRemoteSettings = mockApplyRemoteSettings;
|
||||
getSettingsForSync = mockGetSettingsForSync;
|
||||
getAuthMaterialSnapshot = mockGetAuthMaterialSnapshot;
|
||||
applyAuthMaterialSnapshot = mockApplyAuthMaterialSnapshot;
|
||||
},
|
||||
ChatStore: class MockChatStore {
|
||||
init = mockChatStoreInit;
|
||||
},
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockAgentStoreInit;
|
||||
getAgent = mockAgentStoreGetAgent;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockAuthStorageSet = vi.fn();
|
||||
const mockAuthStorageGetOAuthProviders = vi.fn().mockReturnValue([]);
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => {
|
||||
return {
|
||||
AuthStorage: {
|
||||
create: vi.fn(() => ({
|
||||
set: mockAuthStorageSet,
|
||||
get: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
getOAuthProviders: mockAuthStorageGetOAuthProviders,
|
||||
reload: vi.fn(),
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-4755-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-4755-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async getSettingsByScope() {
|
||||
return {
|
||||
global: { defaultProvider: "anthropic", defaultModelId: "claude-3-5-sonnet" },
|
||||
project: { maxConcurrent: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
getGlobalSettingsStore() {
|
||||
return {
|
||||
async getSettings() {
|
||||
return { defaultProvider: "anthropic", defaultModelId: "claude-3-5-sonnet" };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createMockRemoteNode(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "node-remote-001",
|
||||
name: "Remote Node",
|
||||
type: "remote" as const,
|
||||
status: "online" as const,
|
||||
url: "http://192.168.1.100:3001",
|
||||
apiKey: "test-api-key-123",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockLocalNode(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "node-local-001",
|
||||
name: "Local Node",
|
||||
type: "local" as const,
|
||||
status: "online" as const,
|
||||
url: null,
|
||||
apiKey: "local-api-key-456",
|
||||
maxConcurrent: 2,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type SyncEndpoint = {
|
||||
name: string;
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
direction: "outbound" | "inbound";
|
||||
requiresNodeApiKey?: boolean;
|
||||
requiresBearer?: boolean;
|
||||
sampleBody?: () => unknown;
|
||||
exceptions?: string[];
|
||||
};
|
||||
|
||||
const ENDPOINTS: readonly SyncEndpoint[] = [
|
||||
{ name: "get-settings", method: "GET", path: "/api/nodes/node-remote-001/settings", direction: "outbound", requiresNodeApiKey: true },
|
||||
{ name: "push-settings", method: "POST", path: "/api/nodes/node-remote-001/settings/push", direction: "outbound", requiresNodeApiKey: true, sampleBody: () => ({}) },
|
||||
{ name: "pull-settings", method: "POST", path: "/api/nodes/node-remote-001/settings/pull", direction: "outbound", requiresNodeApiKey: true, sampleBody: () => ({ conflictResolution: "last-write-wins" }) },
|
||||
{
|
||||
name: "sync-status",
|
||||
method: "GET",
|
||||
path: "/api/nodes/node-remote-001/settings/sync-status",
|
||||
direction: "outbound",
|
||||
requiresNodeApiKey: true,
|
||||
exceptions: ["missing-node-apiKey-returns-200-degraded"], // Why: endpoint intentionally degrades to remoteReachable=false instead of throwing.
|
||||
},
|
||||
{ name: "auth-sync", method: "POST", path: "/api/nodes/node-remote-001/auth/sync", direction: "outbound", requiresNodeApiKey: true, sampleBody: () => ({}) },
|
||||
{ name: "sync-receive", method: "POST", path: "/api/settings/sync-receive", direction: "inbound", requiresBearer: true, sampleBody: () => ({ sourceNodeId: "node-1", exportedAt: "2026-04-14T10:00:00.000Z" }) },
|
||||
{ name: "auth-receive", method: "POST", path: "/api/settings/auth-receive", direction: "inbound", requiresBearer: true, sampleBody: () => ({ authMaterial: {}, sourceNodeId: "node-1", timestamp: "2026-04-14T10:00:00.000Z" }) },
|
||||
{ name: "auth-export", method: "GET", path: "/api/settings/auth-export", direction: "inbound", requiresBearer: true },
|
||||
] as const;
|
||||
|
||||
describe("Node settings/auth sync contract matrix", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof createServer>;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockClose.mockResolvedValue(undefined);
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
mockGetNode.mockResolvedValue(null);
|
||||
mockGetLocalPeerInfo.mockResolvedValue({ nodeId: "node-local-001", nodeName: "Local Node" });
|
||||
mockGetSettingsSyncState.mockResolvedValue(null);
|
||||
mockUpdateSettingsSyncState.mockResolvedValue({});
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
|
||||
mockGetSettingsForSync.mockResolvedValue({});
|
||||
mockGetAuthMaterialSnapshot.mockReturnValue({
|
||||
version: 1,
|
||||
exportedAt: "2026-04-14T10:00:00.000Z",
|
||||
checksum: "auth-checksum",
|
||||
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-test" } } },
|
||||
});
|
||||
mockApplyAuthMaterialSnapshot.mockReturnValue({
|
||||
success: true,
|
||||
authCount: 1,
|
||||
providerAuth: { anthropic: { type: "api_key", key: "sk-ant-received" } },
|
||||
});
|
||||
mockAuthStorageSet.mockResolvedValue(undefined);
|
||||
mockAuthStorageGetOAuthProviders.mockReturnValue([]);
|
||||
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
store = new MockStore();
|
||||
app = createServer(store as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each(ENDPOINTS.filter((endpoint) => endpoint.direction === "outbound" && endpoint.requiresNodeApiKey))(
|
||||
"enforces outbound missing remote apiKey contract ($name)",
|
||||
async (endpoint) => {
|
||||
if (endpoint.exceptions?.includes("missing-node-apiKey-returns-200-degraded")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteNode = createMockRemoteNode({ apiKey: undefined });
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
|
||||
const body = endpoint.sampleBody ? JSON.stringify(endpoint.sampleBody()) : undefined;
|
||||
const headers = endpoint.method === "POST" ? { "content-type": "application/json" } : undefined;
|
||||
const res = endpoint.method === "GET"
|
||||
? await get(app, endpoint.path)
|
||||
: await request(app, endpoint.method, endpoint.path, body, headers);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe(MISSING_REMOTE_NODE_API_KEY_MESSAGE);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("sync-status returns remoteReachable=false for missing apiKey", async () => {
|
||||
const remoteNode = createMockRemoteNode({ apiKey: undefined });
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
|
||||
const res = await get(app, "/api/nodes/node-remote-001/settings/sync-status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.remoteReachable).toBe(false);
|
||||
expect(res.body.diff).toEqual({ global: [], project: [] });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(ENDPOINTS.filter((endpoint) => endpoint.direction === "inbound"))(
|
||||
"returns 401 for missing Authorization header ($name)",
|
||||
async (endpoint) => {
|
||||
const body = endpoint.sampleBody ? JSON.stringify(endpoint.sampleBody()) : undefined;
|
||||
const headers = endpoint.method === "POST" ? { "content-type": "application/json" } : undefined;
|
||||
const res = await request(app, endpoint.method, endpoint.path, body, headers);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe("Missing or invalid Authorization header");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ENDPOINTS.filter((endpoint) => endpoint.direction === "inbound"))(
|
||||
"returns 401 for wrong auth scheme ($name)",
|
||||
async (endpoint) => {
|
||||
const body = endpoint.sampleBody ? JSON.stringify(endpoint.sampleBody()) : undefined;
|
||||
const headers = endpoint.method === "POST"
|
||||
? { "content-type": "application/json", Authorization: "Token abc" }
|
||||
: { Authorization: "Token abc" };
|
||||
const res = await request(app, endpoint.method, endpoint.path, body, headers);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe("Missing or invalid Authorization header");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ENDPOINTS.filter((endpoint) => endpoint.direction === "inbound"))(
|
||||
"returns 401 for mismatched bearer token ($name)",
|
||||
async (endpoint) => {
|
||||
mockListNodes.mockResolvedValue([createMockLocalNode()]);
|
||||
const body = endpoint.sampleBody ? JSON.stringify(endpoint.sampleBody()) : undefined;
|
||||
const headers = endpoint.method === "POST"
|
||||
? { "content-type": "application/json", Authorization: "Bearer wrong-token" }
|
||||
: { Authorization: "Bearer wrong-token" };
|
||||
|
||||
const res = await request(app, endpoint.method, endpoint.path, body, headers);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe("Invalid apiKey");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ENDPOINTS.filter((endpoint) => endpoint.direction === "inbound"))(
|
||||
"returns 401 when local node is not configured ($name)",
|
||||
async (endpoint) => {
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
const body = endpoint.sampleBody ? JSON.stringify(endpoint.sampleBody()) : undefined;
|
||||
const headers = endpoint.method === "POST"
|
||||
? { "content-type": "application/json", Authorization: "Bearer local-api-key-456" }
|
||||
: { Authorization: "Bearer local-api-key-456" };
|
||||
|
||||
const res = await request(app, endpoint.method, endpoint.path, body, headers);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe("Local node not configured");
|
||||
},
|
||||
);
|
||||
|
||||
it("push payload is accepted by inbound sync-receive", async () => {
|
||||
// Regression backstop for FN-4752 (push→sync-receive sourceNodeId).
|
||||
mockGetNode.mockResolvedValue(createMockRemoteNode());
|
||||
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
|
||||
const pushRes = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-remote-001/settings/push",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(pushRes.status).toBe(200);
|
||||
|
||||
const [, pushOptions] = mockFetch.mock.calls[0] as [string, { body?: string }];
|
||||
const pushBody = pushOptions.body;
|
||||
expect(typeof pushBody).toBe("string");
|
||||
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
const inboundRes = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/settings/sync-receive",
|
||||
pushBody,
|
||||
{ "content-type": "application/json", Authorization: `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
expect(inboundRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it("pull returns NodeSettingsSyncResult-compatible success shape", async () => {
|
||||
mockGetNode.mockResolvedValue(createMockRemoteNode());
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
global: { defaultProvider: "openai" },
|
||||
project: { maxConcurrent: 3 },
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-remote-001/settings/pull",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(expect.objectContaining({ success: true }));
|
||||
expect(typeof res.body.success).toBe("boolean");
|
||||
if (res.body.syncedFields !== undefined) {
|
||||
expect(Array.isArray(res.body.syncedFields)).toBe(true);
|
||||
}
|
||||
if (res.body.appliedFields !== undefined) {
|
||||
expect(Array.isArray(res.body.appliedFields)).toBe(true);
|
||||
}
|
||||
if (res.body.skippedFields !== undefined) {
|
||||
expect(Array.isArray(res.body.skippedFields)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("auth-sync payload is accepted by inbound auth-receive", async () => {
|
||||
// Regression backstop for FN-4752 symmetry (auth/sync→auth-receive required fields).
|
||||
mockGetNode.mockResolvedValue(createMockRemoteNode());
|
||||
mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true }) });
|
||||
|
||||
const syncRes = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-remote-001/auth/sync",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(syncRes.status).toBe(200);
|
||||
const [, syncOptions] = mockFetch.mock.calls[0] as [string, { body?: string }];
|
||||
const outboundBody = JSON.parse(syncOptions.body ?? "{}");
|
||||
expect(outboundBody).toEqual(expect.objectContaining({
|
||||
authMaterial: expect.any(Object),
|
||||
sourceNodeId: expect.any(String),
|
||||
timestamp: expect.any(String),
|
||||
}));
|
||||
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
const inboundRes = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/settings/auth-receive",
|
||||
JSON.stringify(outboundBody),
|
||||
{ "content-type": "application/json", Authorization: `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
expect(inboundRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it("auth-export returns auth-receive compatible response shape", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"GET",
|
||||
"/api/settings/auth-export",
|
||||
undefined,
|
||||
{ Authorization: `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
authMaterial: expect.any(Object),
|
||||
sourceNodeId: expect.any(String),
|
||||
timestamp: expect.any(String),
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user