Files
awesome-copilot/extensions/connector-namespaces/install.test.mjs
T
github-actions[bot] d171be5b52 chore: publish from main
2026-07-16 00:30:12 +00:00

254 lines
12 KiB
JavaScript

// Regression guards for install-state selection.
//
// Run: node --test extensions/connector-namespaces/install.test.mjs
//
// These exist because getInstalledState used to collapse N gateway configs for
// one apiName down to a single tile via ARM list order (last-wins). A portal
// add, a duplicate Connect, or a re-auth would mint a sibling config; whichever
// ARM happened to return last owned the tile, so a tile could show
// "Re-authenticate" while a different config for the same connector was already
// Connected. deriveInstalledState now picks deterministically:
// inCli && Connected > inCli > Connected > any, configName wins ties.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { deriveInstalledState, getConsentUrl, getConnectionStatus, getMcpEndpointUrl, waitForConnected } from "./install.mjs";
// removeLocalEntry does file I/O (getInstalledState reads ARM + mcp configs,
// removeMcpEntry edits them) and calls both as same-module functions, so there
// is no import seam to stub. The invariant that matters — the default "Remove"
// only unlinks the CLI entry and NEVER deletes the Azure resource — is a
// source contract, so we assert it against the function body the same way
// renderer.test.mjs guards its CSS/HTML strings.
function functionBody(source, name) {
const exported = source.indexOf(`export async function ${name}(`);
const start = exported !== -1 ? exported : source.indexOf(`async function ${name}(`);
if (start === -1) return null;
const open = source.indexOf("{", start);
if (open === -1) return null;
let depth = 0;
for (let i = open; i < source.length; i++) {
const ch = source[i];
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return source.slice(open + 1, i);
}
}
return null;
}
const installSource = readFileSync(fileURLToPath(new URL("./install.mjs", import.meta.url)), "utf8");
// Build a fake ARM mcpserverConfig list entry.
function cfg(name, apiName, connName) {
return { name, properties: { connectors: [{ name: apiName, connectionName: connName }] } };
}
// Build a connName -> connection map with a given status.
function conns(...entries) {
const m = new Map();
for (const [connName, status] of entries) {
m.set(connName, { name: connName, properties: { statuses: [{ status }] } });
}
return m;
}
test("picks inCli+Connected over a not-inCli sibling that appears LAST (not last-wins)", () => {
// good config is FIRST; a broken sibling is LAST. Old last-wins would pick
// the last one — the fix must pick the good one regardless of order.
const configs = [
cfg("good", "shared-api", "connGood"),
cfg("bad", "shared-api", "connBad"),
];
const connByName = conns(["connGood", "Connected"], ["connBad", "Unknown"]);
const profileKeys = new Set(["good"]); // only the good config is in the CLI
const state = deriveInstalledState(configs, connByName, profileKeys, new Set(), null);
assert.equal(state["shared-api"].configName, "good");
assert.equal(state["shared-api"].connectionName, "connGood");
assert.equal(state["shared-api"].connectionStatus, "Connected");
assert.equal(state["shared-api"].inCli, true);
assert.equal(state["shared-api"]._configCount, 2);
assert.deepEqual(state["shared-api"]._candidates.map((item) => item.configName), ["good", "bad"]);
});
test("inCli beats a Connected-but-not-inCli sibling", () => {
// A is the config the local session points at but not yet Connected; B is
// Connected on ARM but not in the CLI. Prefer A so remove/re-auth act on the
// resource the user's session actually uses.
const configs = [
cfg("a-incli", "api", "connA"),
cfg("b-connected", "api", "connB"),
];
const connByName = conns(["connA", "Unknown"], ["connB", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["a-incli"]), new Set(), null);
assert.equal(state["api"].configName, "a-incli");
assert.equal(state["api"].inCli, true);
});
test("inCli && Connected beats inCli-only", () => {
const configs = [
cfg("incli-unknown", "api", "connU"),
cfg("incli-connected", "api", "connC"),
];
const connByName = conns(["connU", "Unknown"], ["connC", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["incli-unknown", "incli-connected"]), new Set(), null);
assert.equal(state["api"].configName, "incli-connected");
assert.equal(state["api"].connectionStatus, "Connected");
});
test("config name breaks equal-rank ties independently of ARM list order", () => {
const configs = [
cfg("z-config", "api", "connZ"),
cfg("a-config", "api", "connA"),
];
const connByName = conns(["connZ", "Connected"], ["connA", "Connected"]);
const local = new Set(["z-config", "a-config"]);
const forward = deriveInstalledState(configs, connByName, local, new Set(), null);
const reverse = deriveInstalledState([...configs].reverse(), connByName, local, new Set(), null);
assert.equal(forward.api.configName, "a-config");
assert.equal(reverse.api.configName, "a-config");
});
test("connection convergence reports non-connected terminal results as failures", async () => {
const states = ["Connecting", "Error"];
const delays = [];
await assert.rejects(
waitForConnected({}, "conn", {
maxPolls: 2,
getStatus: async () => states.shift(),
delay: async (ms) => delays.push(ms),
}),
/Connection ended in state "Error"/,
);
assert.deepEqual(delays, [1000]);
assert.equal(
await waitForConnected({}, "conn", {
getStatus: async () => "Connected",
delay: async () => assert.fail("connected state must not sleep"),
}),
"Connected",
);
});
test("single config passes through with no _configCount", () => {
const configs = [cfg("only", "api", "conn1")];
const connByName = conns(["conn1", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["only"]), new Set(), null);
assert.equal(state["api"].configName, "only");
assert.equal(state["api"]._configCount, undefined);
});
test("workspace membership counts as inCli and sets scope/path", () => {
const configs = [cfg("ws", "api", "conn1")];
const connByName = conns(["conn1", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(), new Set(["ws"]), "/repo/.mcp.json");
assert.equal(state["api"].inCli, true);
assert.equal(state["api"].cliScope, "workspace");
assert.equal(state["api"].cliPath, "/repo/.mcp.json");
});
test("connectionStatus falls back to overallStatus then Unknown", () => {
const configs = [cfg("c1", "api1", "connOverall"), cfg("c2", "api2", "connMissing")];
const connByName = new Map([
["connOverall", { name: "connOverall", properties: { overallStatus: "Connected" } }],
]);
const state = deriveInstalledState(configs, connByName, new Set(), new Set(), null);
assert.equal(state["api1"].connectionStatus, "Connected"); // from overallStatus
assert.equal(state["api2"].connectionStatus, "Unknown"); // no connection at all
});
test("configs with no connector are skipped", () => {
const configs = [
{ name: "broken", properties: { connectors: [] } },
cfg("ok", "api", "conn1"),
];
const connByName = conns(["conn1", "Connected"]);
const state = deriveInstalledState(configs, connByName, new Set(["ok"]), new Set(), null);
assert.equal(Object.keys(state).length, 1);
assert.equal(state["api"].configName, "ok");
});
test("removeLocalEntry unlinks the local CLI entry via removeMcpEntry", () => {
const body = functionBody(installSource, "removeLocalEntry");
assert.ok(body, "removeLocalEntry function not found in install.mjs");
assert.match(body, /removeMcpEntry\s*\(/, "removeLocalEntry must call removeMcpEntry to drop the CLI entry");
assert.match(body, /entry\._candidates/, "removeLocalEntry must process duplicate CLI configs");
assert.match(body, /candidate\.inCli/, "removeLocalEntry must unlink every local candidate");
});
test("uninstallConnector deletes every duplicate namespace config", () => {
const body = functionBody(installSource, "uninstallConnector");
const cleanup = functionBody(installSource, "cleanupConnectorResources");
assert.ok(body, "uninstallConnector function not found in install.mjs");
assert.ok(cleanup, "cleanupConnectorResources function not found in install.mjs");
assert.match(body, /entry\._candidates/, "namespace deletion must process duplicate configs");
assert.match(body, /cleanupConnectorResources\s*\(/, "uninstall must delegate all collected candidates to shared cleanup");
assert.match(cleanup, /deleteMcpServerConfigs\(config, configNames\)/);
assert.match(cleanup, /for \(const connectionName of connectionNames\)/);
assert.match(cleanup, /for \(const configName of configNames\)/);
});
test("removeLocalEntry never deletes the namespace resource (no armDelete)", () => {
const body = functionBody(installSource, "removeLocalEntry");
assert.ok(body, "removeLocalEntry function not found in install.mjs");
// The default Remove must stay local-only. If someone routes it through
// uninstallConnector or adds an ARM delete, this fails — which is the point.
assert.doesNotMatch(body, /armDelete\s*\(/, "removeLocalEntry must not call armDelete");
assert.doesNotMatch(body, /uninstallConnector\s*\(/, "removeLocalEntry must not delegate to uninstallConnector");
});
// --- ARM path-injection guard (client-reachable read sinks) ---
//
// finishInstall/finishReauth feed client-supplied body.connName / body.configName
// into getConsentUrl, getConnectionStatus, and getMcpEndpointUrl, which build ARM
// URLs. Those names must pass through armSegment() so a traversal / query payload
// can't escape the intended resource path (SSRF / path injection). armSegment
// throws synchronously while the URL is built, before any token or network call,
// so these run fully offline and deterministic. A valid config is used so the
// gatewayId() wrap doesn't throw first — only the bad NAME should reject.
const validConfig = { subscriptionId: "s", resourceGroup: "r", gatewayName: "g" };
const badNames = ["../../evil", "evil/../../secret", "x?injected=1"];
test("getConnectionStatus rejects traversal/injection connName before any ARM call", async () => {
for (const bad of badNames) {
await assert.rejects(
() => getConnectionStatus(validConfig, bad),
/Invalid ARM resource identifier/,
`getConnectionStatus should reject connName ${JSON.stringify(bad)}`,
);
}
});
test("getConsentUrl rejects traversal/injection connName before any ARM call", async () => {
for (const bad of badNames) {
await assert.rejects(
() => getConsentUrl(validConfig, bad, "http://127.0.0.1:0/auth/callback/x"),
/Invalid ARM resource identifier/,
`getConsentUrl should reject connName ${JSON.stringify(bad)}`,
);
}
});
test("getMcpEndpointUrl rejects traversal/injection configName before any ARM call", async () => {
for (const bad of badNames) {
await assert.rejects(
() => getMcpEndpointUrl(validConfig, bad),
/Invalid ARM resource identifier/,
`getMcpEndpointUrl should reject configName ${JSON.stringify(bad)}`,
);
}
});