mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 10:53:25 +00:00
254 lines
12 KiB
JavaScript
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)}`,
|
|
);
|
|
}
|
|
});
|