Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
337 lines
10 KiB
JavaScript
337 lines
10 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const http = require("node:http");
|
|
|
|
const BRIDGE_PATH = require.resolve("./oauthBridge.cjs");
|
|
|
|
function loadBridge() {
|
|
// Isolate module state between tests — oauthBridge keeps the bound server
|
|
// and pending promise in module-level closures.
|
|
delete require.cache[BRIDGE_PATH];
|
|
return require("./oauthBridge.cjs");
|
|
}
|
|
|
|
async function freshModule() {
|
|
const bridge = loadBridge();
|
|
const cleanup = async () => {
|
|
try {
|
|
bridge.cancelOAuthCallback();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
// Give the server a beat to actually close before the next test binds
|
|
// the same port.
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
};
|
|
return { bridge, cleanup };
|
|
}
|
|
|
|
function tryBindPort(port) {
|
|
return new Promise((resolve) => {
|
|
const s = http.createServer(() => {});
|
|
s.once("error", () => resolve(null));
|
|
s.once("listening", () => resolve(s));
|
|
s.listen(port, "127.0.0.1");
|
|
});
|
|
}
|
|
|
|
async function closeServer(s) {
|
|
if (!s) return;
|
|
await new Promise((resolve) => s.close(resolve));
|
|
}
|
|
|
|
function fetchCallback(port, query) {
|
|
return new Promise((resolve, reject) => {
|
|
const search = new URLSearchParams(query).toString();
|
|
const req = http.request(
|
|
{
|
|
hostname: "127.0.0.1",
|
|
port,
|
|
path: `/oauth/callback?${search}`,
|
|
method: "GET",
|
|
},
|
|
(res) => {
|
|
res.resume();
|
|
res.on("end", () => resolve(res.statusCode));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
test("prepareOAuthCallback binds and reports port + redirectUri", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port, redirectUri } = await bridge.prepareOAuthCallback();
|
|
assert.ok(Number.isInteger(port) && port > 0, `got port=${port}`);
|
|
assert.equal(redirectUri, `http://127.0.0.1:${port}/oauth/callback`);
|
|
assert.equal(bridge.getActiveOAuthPort(), port);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("prepareOAuthCallback prefers port 45678 when it is free", async () => {
|
|
// Probe: if 45678 is already held by something external, skip this test —
|
|
// the fallback path is exercised in the next test.
|
|
const probe = await tryBindPort(45678);
|
|
if (!probe) {
|
|
return; // treated as pass; environment doesn't permit this assertion
|
|
}
|
|
await closeServer(probe);
|
|
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port } = await bridge.prepareOAuthCallback();
|
|
assert.equal(port, 45678);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("prepareOAuthCallback falls back to an OS-assigned port when 45678 is busy (#823)", async () => {
|
|
// Hold port 45678 ourselves so the bridge MUST fall back.
|
|
const squatter = await tryBindPort(45678);
|
|
if (!squatter) {
|
|
// Something else is already holding 45678 — just ensure the bridge
|
|
// still produces a working port rather than crashing.
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port } = await bridge.prepareOAuthCallback();
|
|
assert.ok(port > 0 && port !== 45678);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port, redirectUri } = await bridge.prepareOAuthCallback();
|
|
assert.notEqual(port, 45678, "expected a different port when 45678 is in use");
|
|
assert.ok(port > 0);
|
|
assert.equal(redirectUri, `http://127.0.0.1:${port}/oauth/callback`);
|
|
assert.equal(bridge.getActiveOAuthPort(), port);
|
|
} finally {
|
|
await cleanup();
|
|
await closeServer(squatter);
|
|
}
|
|
});
|
|
|
|
test("awaitOAuthCallback resolves with the code when the browser hits the redirect URI", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port } = await bridge.prepareOAuthCallback();
|
|
const pending = bridge.awaitOAuthCallback();
|
|
|
|
const status = await fetchCallback(port, { code: "test-code", state: "anything" });
|
|
assert.equal(status, 200);
|
|
|
|
const result = await pending;
|
|
assert.equal(result.code, "test-code");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("awaitOAuthCallback rejects on state mismatch", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port } = await bridge.prepareOAuthCallback();
|
|
const pending = bridge.awaitOAuthCallback("expected-state");
|
|
// Attach the assertion BEFORE triggering the callback so the rejection
|
|
// is never unhandled.
|
|
const rejectsCheck = assert.rejects(pending, /State mismatch/);
|
|
|
|
await fetchCallback(port, { code: "test-code", state: "different-state" });
|
|
await rejectsCheck;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("awaitOAuthCallback rejects when the provider returns an error", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port } = await bridge.prepareOAuthCallback();
|
|
const pending = bridge.awaitOAuthCallback();
|
|
const rejectsCheck = assert.rejects(pending, /access_denied/);
|
|
|
|
await fetchCallback(port, { error: "access_denied", error_description: "access_denied" });
|
|
await rejectsCheck;
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("awaitOAuthCallback errors if called before prepareOAuthCallback", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
await assert.rejects(bridge.awaitOAuthCallback(), /not prepared/);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("cancelOAuthCallback releases the bound port", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port } = await bridge.prepareOAuthCallback();
|
|
assert.equal(bridge.getActiveOAuthPort(), port);
|
|
bridge.cancelOAuthCallback();
|
|
assert.equal(bridge.getActiveOAuthPort(), null);
|
|
|
|
// Give the OS a beat to release the socket, then confirm re-bindable.
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
const rebound = await tryBindPort(port);
|
|
assert.ok(rebound, `expected ${port} to be re-bindable after cancel`);
|
|
await closeServer(rebound);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("callbacks that arrive before awaitOAuthCallback do not consume the session", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { port } = await bridge.prepareOAuthCallback();
|
|
|
|
const earlyStatus = await fetchCallback(port, { code: "stale-code", state: "stale-state" });
|
|
assert.equal(earlyStatus, 200);
|
|
assert.equal(bridge.getActiveOAuthPort(), port);
|
|
|
|
const pending = bridge.awaitOAuthCallback("expected-state");
|
|
const status = await fetchCallback(port, {
|
|
code: "fresh-code",
|
|
state: "expected-state",
|
|
});
|
|
assert.equal(status, 200);
|
|
|
|
const result = await pending;
|
|
assert.equal(result.code, "fresh-code");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("cancelOAuthCallback before awaitOAuthCallback is replayed as a cancellation", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const { sessionId } = await bridge.prepareOAuthCallback();
|
|
bridge.cancelOAuthCallback(sessionId);
|
|
await assert.rejects(bridge.awaitOAuthCallback(undefined, sessionId), /cancelled/);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("cancelOAuthCallback during prepare rejects the pending prepare", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const pendingPrepare = bridge.prepareOAuthCallback();
|
|
bridge.cancelOAuthCallback();
|
|
|
|
await assert.rejects(pendingPrepare, /cancelled/);
|
|
assert.equal(bridge.getActiveOAuthPort(), null);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("cancelOAuthCallback ignores stale session ids", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const stale = await bridge.prepareOAuthCallback();
|
|
const fresh = await bridge.prepareOAuthCallback();
|
|
|
|
bridge.cancelOAuthCallback(stale.sessionId);
|
|
assert.equal(bridge.getActiveOAuthPort(), fresh.port);
|
|
|
|
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
|
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
|
assert.equal(status, 200);
|
|
|
|
const result = await pending;
|
|
assert.equal(result.code, "fresh-code");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("awaitOAuthCallback rejects stale session ids instead of attaching to the new flow", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const stale = await bridge.prepareOAuthCallback();
|
|
const fresh = await bridge.prepareOAuthCallback();
|
|
|
|
await assert.rejects(
|
|
bridge.awaitOAuthCallback(undefined, stale.sessionId),
|
|
/cancelled/
|
|
);
|
|
|
|
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
|
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
|
assert.equal(status, 200);
|
|
|
|
const result = await pending;
|
|
assert.equal(result.code, "fresh-code");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("concurrent prepareOAuthCallback calls cancel the superseded attempt", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const stalePrepare = bridge.prepareOAuthCallback();
|
|
const staleRejects = assert.rejects(stalePrepare, /cancelled/);
|
|
|
|
const freshPrepare = bridge.prepareOAuthCallback();
|
|
const fresh = await freshPrepare;
|
|
await staleRejects;
|
|
|
|
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
|
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
|
assert.equal(status, 200);
|
|
|
|
const result = await pending;
|
|
assert.equal(result.code, "fresh-code");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("prepareOAuthCallback replaces an in-flight await cleanly", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
const stale = await bridge.prepareOAuthCallback();
|
|
const stalePending = bridge.awaitOAuthCallback(undefined, stale.sessionId);
|
|
const staleRejects = assert.rejects(stalePending, /cancelled/);
|
|
|
|
const fresh = await bridge.prepareOAuthCallback();
|
|
await staleRejects;
|
|
|
|
const nextPending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
|
|
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
|
|
assert.equal(status, 200);
|
|
|
|
const result = await nextPending;
|
|
assert.equal(result.code, "fresh-code");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
test("getActiveOAuthPort is null before prepare and after cleanup", async () => {
|
|
const { bridge, cleanup } = await freshModule();
|
|
try {
|
|
assert.equal(bridge.getActiveOAuthPort(), null);
|
|
await bridge.prepareOAuthCallback();
|
|
assert.notEqual(bridge.getActiveOAuthPort(), null);
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
assert.equal(require("./oauthBridge.cjs").getActiveOAuthPort(), null);
|
|
});
|