feat(FN-4816): add integration tests for node settings sync API routes
Adds integration tests for node settings sync API routes (`routes-nodes-sync.test.ts`), covering the settings push/pull and sync-status endpoints across the dashboard node management API. Fusion-Task-Id: FN-4816 Fusion-Task-Lineage: 01f1bec6-f1c3-43bc-8854-5c4739f0e736
This commit is contained in:
committed by
gsxdsm
parent
ae22b3e632
commit
c3dd34f070
@@ -523,6 +523,43 @@ describe("Node settings sync routes", () => {
|
||||
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for local node", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockGetNode.mockResolvedValue(localNode);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-local-001/settings/pull",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("local node");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid conflictResolution", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-remote-001/settings/pull",
|
||||
JSON.stringify({ conflictResolution: "invalid" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("conflictResolution");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSettingsSyncState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 for unknown node", async () => {
|
||||
mockGetNode.mockResolvedValue(null);
|
||||
|
||||
@@ -537,6 +574,34 @@ describe("Node settings sync routes", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns skippedFields and error when applyRemoteSettings fails", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
global: { defaultProvider: "openai" },
|
||||
project: { maxConcurrent: 3 },
|
||||
}),
|
||||
});
|
||||
mockApplyRemoteSettings.mockResolvedValue({ success: false, error: "checksum mismatch" });
|
||||
|
||||
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.success).toBe(false);
|
||||
expect(res.body.skippedFields).toEqual(expect.arrayContaining(["defaultProvider"]));
|
||||
expect(res.body.error).toContain("checksum mismatch");
|
||||
expect(mockApplyRemoteSettings).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateSettingsSyncState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records sync state after successful pull", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
@@ -722,6 +787,42 @@ describe("Node settings sync routes", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 for local node", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockGetNode.mockResolvedValue(localNode);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-local-001/auth/sync",
|
||||
JSON.stringify({ direction: "push" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("local node");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSettingsSyncState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid direction", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-remote-001/auth/sync",
|
||||
JSON.stringify({ direction: "sideways" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("direction");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSettingsSyncState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for remote node without apiKey", async () => {
|
||||
const remoteNode = createMockRemoteNode({ apiKey: undefined });
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
@@ -785,6 +886,40 @@ describe("Node settings sync routes", () => {
|
||||
expect(serialized).not.toContain("\"refresh\"");
|
||||
});
|
||||
|
||||
it("records sync state for pull-mode auth sync", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
authMaterial: {
|
||||
version: 1,
|
||||
exportedAt: "2026-04-14T10:00:00.000Z",
|
||||
checksum: "auth-checksum",
|
||||
payload: { providerAuth: { google: { type: "api_key", key: "sk-pull-secret-123" } } },
|
||||
},
|
||||
sourceNodeId: "node-other",
|
||||
timestamp: "2026-04-14T10:00:00.000Z",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/nodes/node-remote-001/auth/sync",
|
||||
JSON.stringify({ direction: "pull" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateSettingsSyncState).toHaveBeenCalledWith(
|
||||
"node-remote-001",
|
||||
expect.objectContaining({
|
||||
lastSyncedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits structured redacted diagnostics for pull-mode auth sync", async () => {
|
||||
const remoteNode = createMockRemoteNode();
|
||||
mockGetNode.mockResolvedValue(remoteNode);
|
||||
@@ -932,6 +1067,24 @@ describe("Node settings sync routes", () => {
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("sourceNodeId");
|
||||
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when payload is missing exportedAt", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/settings/sync-receive",
|
||||
JSON.stringify({ sourceNodeId: "node-remote-001" }),
|
||||
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("exportedAt");
|
||||
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1000,6 +1153,26 @@ describe("Node settings sync routes", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ authMaterial: { version: 1, exportedAt: "2026-04-14T10:00:00.000Z", checksum: "auth-checksum", payload: { providerAuth: {} } }, timestamp: "2026-04-14T10:00:00.000Z" }, "sourceNodeId"],
|
||||
[{ authMaterial: { version: 1, exportedAt: "2026-04-14T10:00:00.000Z", checksum: "auth-checksum", payload: { providerAuth: {} } }, sourceNodeId: "node-remote-001" }, "timestamp"],
|
||||
])("returns 400 when payload is missing %s", async (body, missingField) => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/settings/auth-receive",
|
||||
JSON.stringify(body),
|
||||
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(missingField);
|
||||
expect(mockAuthStorageSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits structured redacted diagnostics for auth-receive", async () => {
|
||||
const localNode = createMockLocalNode();
|
||||
mockListNodes.mockResolvedValue([localNode]);
|
||||
@@ -1081,6 +1254,31 @@ describe("Node settings sync routes", () => {
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["POST", "/api/settings/sync-receive", JSON.stringify({ sourceNodeId: "node-remote-001", exportedAt: "2026-04-14T10:00:00.000Z" }), "applyRemoteSettings"],
|
||||
["POST", "/api/settings/auth-receive", JSON.stringify({ authMaterial: { version: 1, exportedAt: "2026-04-14T10:00:00.000Z", checksum: "auth-checksum", payload: { providerAuth: {} } }, sourceNodeId: "node-remote-001", timestamp: "2026-04-14T10:00:00.000Z" }), "authStorageSet"],
|
||||
["GET", "/api/settings/auth-export", undefined, "authExport"],
|
||||
])("returns 401 Local node not configured for inbound endpoint (%s %s)", async (method, path, body, sideEffect) => {
|
||||
mockListNodes.mockResolvedValue([createMockRemoteNode()]);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
{ "content-type": "application/json", Authorization: "Bearer some-token" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toContain("Local node not configured");
|
||||
if (sideEffect === "applyRemoteSettings") {
|
||||
expect(mockApplyRemoteSettings).not.toHaveBeenCalled();
|
||||
}
|
||||
if (sideEffect === "authStorageSet") {
|
||||
expect(mockAuthStorageSet).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-4747 parity coverage", () => {
|
||||
|
||||
@@ -1038,41 +1038,6 @@ export class Scheduler {
|
||||
policy: settings.unavailableNodePolicy,
|
||||
});
|
||||
|
||||
if (freshTask.checkoutNodeId) {
|
||||
const ownerNodeHealth = this.options.nodeHealthMonitor.getNodeHealth(freshTask.checkoutNodeId);
|
||||
if (ownerNodeHealth === "offline" || ownerNodeHealth === "error") {
|
||||
const handoffDecision = decideOwningNodeHandoff({
|
||||
task: freshTask,
|
||||
ownerNodeId: freshTask.checkoutNodeId,
|
||||
ownerNodeHealth,
|
||||
localNodeId: "local",
|
||||
handoffPolicy: settings.owningNodeHandoffPolicy,
|
||||
});
|
||||
|
||||
if (handoffDecision.action === "park") {
|
||||
if (!this.wasNodeBlocked.has(task.id)) {
|
||||
this.wasNodeBlocked.add(task.id);
|
||||
const reason = `Owning-node handoff parked dispatch: ${handoffDecision.reason}`;
|
||||
schedulerLog.log(`Task ${task.id} dispatch blocked — ${reason}`);
|
||||
await this.store.logEntry(task.id, reason);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.logEntry(task.id, `Owning-node handoff applied: ${handoffDecision.reason}`);
|
||||
if (handoffDecision.action === "reassign-local") {
|
||||
effectiveNode = { nodeId: undefined, source: "local" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeHealth = this.options.nodeHealthMonitor.getNodeHealth(effectiveNode.nodeId);
|
||||
const decision = applyUnavailableNodePolicy({
|
||||
effectiveNode,
|
||||
nodeHealth,
|
||||
policy: settings.unavailableNodePolicy,
|
||||
});
|
||||
|
||||
if (!decision.allowed) {
|
||||
if (!this.wasNodeBlocked.has(task.id)) {
|
||||
this.wasNodeBlocked.add(task.id);
|
||||
|
||||
Reference in New Issue
Block a user