Files
pi-extensions/test/aws-sso.test.mjs
T
pi@mbp-m1-2020 4e48456591 aws-sso: read the session end from AWS's token cap; log in 30 s after it
Overnight 2026-10-12: in the session's last hour AWS issued tokens capped at
the end (00:18:48 local, 8 h after sign-in); the old 'expiresAt advanced' rule
read their millisecond jitter as 'refreshed'. A refusal at 00:13 led to a
login that inherited the ending session, and the 30-min wait then delayed the
useful login to 00:47.

Now: new full token = refreshed; new token issued for < 55 min = capped, its
expiry is the end (session_end_seen, status 'until HH:MM'), login at end+30 s;
nothing written = refused, login at once. anchor logs token_min/inherited from
the cap, not a clock. 2 min between unprompted logins after a success;
30 min, 1 h, 2 h, 4 h after unanswered ones.
2026-10-12 07:57:32 +02:00

207 lines
11 KiB
JavaScript

// Two-sided tests for the pure parts of extensions/aws-sso.ts (node >= 23 strips
// the types; the only pi import is type-only). Shapes observed 2026-10-10/11 on
// mbp-m1-2020.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
fingerprint, cacheKeyFor, parseIni, resolveSsoSession, parseDeviceOutput,
isSsoExpiredError, reanchor, fmtLeft, probeDue, probeWith, REFRESH_WINDOW_MS, sessionEndFrom, FULL_TOKEN_MS,
normalizeMode, resolveMode, autoLoginGap, ntfyRequest, describeTopic,
} from "../extensions/aws-sso.ts";
const START = "https://d-1234567890.awsapps.com/start";
const HOST = "d-1234567890.awsapps.com";
const CONFIG = `
[profile Base]
sso_session = MySession
sso_account_id = 111111111111
sso_role_name = Dev
[sso-session MySession]
sso_start_url = ${START}
sso_region = eu-west-1
[profile Bedrock]
role_arn = arn:aws:iam::111111111111:role/X
source_profile = Base
[profile LoopA]
source_profile = LoopB
[profile LoopB]
source_profile = LoopA
[profile Legacy]
sso_start_url = ${START}
[profile Dangling]
sso_session = Nope
`;
test("fingerprint: 8 hex chars, stable, never the value", () => {
const v = "aoaAAAAAGh0dHBzOi8vZXhhbXBsZS5jb20v";
assert.match(fingerprint(v), /^[0-9a-f]{8}$/);
assert.equal(fingerprint(v), fingerprint(v));
assert.notEqual(fingerprint(v), fingerprint(v + "x"));
assert.ok(!v.includes(fingerprint(v)));
assert.equal(fingerprint(""), "-");
});
test("cache key = sha1(session name) — the measured botocore file name", () => {
assert.equal(cacheKeyFor("GFTLIntelligenceOnTap"), "c8ef84539c753bc7f3a20b5dceef5c2f03e744a7");
});
test("resolveSsoSession follows source_profile to the sso-session", () => {
const r = resolveSsoSession(CONFIG, "Bedrock");
assert.equal(r.sessionName, "MySession");
assert.equal(r.startUrl, START);
assert.deepEqual(r.chain, ["Bedrock", "Base"]);
});
test("resolveSsoSession refuses broken configs with a reason", () => {
assert.throws(() => resolveSsoSession(CONFIG, "LoopA"), /loop/);
assert.throws(() => resolveSsoSession(CONFIG, "Missing"), /not found/);
assert.throws(() => resolveSsoSession(CONFIG, "Legacy"), /legacy/);
assert.throws(() => resolveSsoSession(CONFIG, "Dangling"), /sso-session Nope/);
assert.deepEqual(parseIni("# c\n[profile A]\nk = v = w\n"), { "profile A": { k: "v = w" } });
});
const CLI = (code, host = HOST) => `Browser will not be automatically opened.
Please visit the following URL:
https://${host}/start/#/device
Then enter the code:
${code}
Alternatively, you may visit the following URL which will autofill the code upon loading:
https://${host}/start/#/device?user_code=${code}
`;
test("parseDeviceOutput: real shape accepted; foreign host / bad code refused", () => {
const r = parseDeviceOutput(CLI("PCDB-WSGH"), START);
assert.equal(r.code, "PCDB-WSGH");
assert.equal(new URL(r.url).host, HOST);
assert.equal(parseDeviceOutput(CLI("PCDB-WSGH", "evil.example.com"), START), null);
assert.equal(parseDeviceOutput(CLI("PCDB-WSGH", `${HOST}.evil.example.com`), START), null);
assert.equal(parseDeviceOutput(CLI("pcdb-wsgh"), START), null);
assert.equal(parseDeviceOutput("Browser will not be automatically opened.\n", START), null);
});
test("isSsoExpiredError: the real pi error matches; unrelated failures do not", () => {
assert.ok(isSsoExpiredError("Token is expired. To refresh this SSO session run 'aws sso login' with the corresponding profile.")); // measured 2026-10-11T14:18:08Z
assert.ok(isSsoExpiredError("The SSO session associated with this profile has expired or is otherwise invalid"));
assert.ok(isSsoExpiredError("ExpiredTokenException: The security token included in the request is expired"));
for (const m of ["Provider stopped with: content_filtered", "getaddrinfo ENOTFOUND bedrock-runtime.eu-west-1.amazonaws.com", "AccessDeniedException: not authorized", "ThrottlingException: Too many requests"]) {
assert.ok(!isSsoExpiredError(m), m);
}
});
const H = 3600e3;
const T0 = Date.UTC(2026, 9, 11, 14, 19, 0);
const tok = (fp, accessExp) => ({ refreshFp: fp, accessExpiresAt: accessExp });
test("probeDue: only inside the SDK's 5-min window; 60 s apart while valid, 10 min apart once expired", () => {
const exp = T0 + H;
assert.equal(REFRESH_WINDOW_MS, 5 * 60e3); // token-providers 3.1127.0 EXPIRE_WINDOW_MS
assert.equal(probeDue(exp, exp - 6 * 60e3, 0), false); // outside: the SDK would not even try
assert.equal(probeDue(exp, exp - 5 * 60e3, 0), true);
assert.equal(probeDue(exp, exp - 4 * 60e3, exp - 4.5 * 60e3), false); // 30 s after the last attempt
assert.equal(probeDue(exp, exp - 3 * 60e3, exp - 4.5 * 60e3), true);
assert.equal(probeDue(exp, exp + 5 * 60e3, exp + 1 * 60e3), false); // expired: back off
assert.equal(probeDue(exp, exp + 12 * 60e3, exp + 1 * 60e3), true);
});
test("sessionEndFrom: a full 60-min token says nothing; a token issued for less marks the end", () => {
assert.equal(FULL_TOKEN_MS, 55 * 60e3);
assert.equal(sessionEndFrom({ accessExpiresAt: T0 + H, mtime: T0 }), null); // measured lifetime: 60 min
assert.equal(sessionEndFrom({ accessExpiresAt: T0 + 4 * 60e3, mtime: T0 }), T0 + 4 * 60e3);
assert.equal(sessionEndFrom(null), null);
assert.equal(sessionEndFrom({ accessExpiresAt: NaN, mtime: T0 }), null);
});
// probeWith: a fake SDK provider and a fake cache file (expiresAt + mtime), mirroring token-providers.
const cacheSim = (exp, mtime) => ({ exp, mtime, read() { return { accessExpiresAt: this.exp, mtime: this.mtime }; }, write(exp, at) { this.exp = exp; this.mtime = at; } });
test("probeWith: refreshed = new full token; capped = new token cut at the session end; refused = nothing written", async () => {
const t = T0 + H - 3 * 60e3, now = () => t;
const ok = cacheSim(T0 + H, T0);
let r = await probeWith(() => () => async () => { ok.write(t + H, t); return { token: "x" }; }, () => ok.read(), "Base", now);
assert.equal(r.result, "refreshed");
const capped = cacheSim(T0 + H, T0); // last hour: AWS issues a token that expires where the old one does
r = await probeWith(() => () => async () => { capped.write(T0 + H + 10, t); }, () => capped.read(), "Base", now);
assert.equal(r.result, "capped");
assert.equal(r.endsAt, T0 + H + 10);
const ended = cacheSim(T0 + H, T0); // refused: SDK keeps the old token, writes nothing, no throw
r = await probeWith(() => () => async () => ({ token: "old" }), () => ended.read(), "Base", now);
assert.equal(r.result, "refused");
r = await probeWith(() => () => async () => { throw new Error("Token is expired. To refresh this SSO session run 'aws sso login'"); }, () => ended.read(), "Base", () => T0 + 2 * H);
assert.equal(r.result, "refused");
assert.match(r.error, /expired/);
});
test("probeWith regression 2026-10-12: expiresAt moving by milliseconds is capped, not refreshed", async () => {
// Measured: 00:14:50 local, cache expiresAt 22:18:48.735Z -> 22:18:48.745Z, logged 'refreshed' by the old rule.
const before = Date.parse("2026-10-11T22:18:48.735Z"), after = Date.parse("2026-10-11T22:18:48.745Z");
const at = Date.parse("2026-10-11T22:14:50Z");
const c = cacheSim(before, Date.parse("2026-10-11T22:13:53Z"));
const r = await probeWith(() => () => async () => { c.write(after, at); }, () => c.read(), "Base", () => at);
assert.equal(r.result, "capped");
assert.equal(new Date(r.endsAt).toISOString(), "2026-10-11T22:18:48.745Z");
});
test("probeWith: never calls the SDK outside the window; a missing SDK is 'unavailable', not 'session ended'", async () => {
let called = 0;
const c = cacheSim(T0 + H, T0);
let r = await probeWith(() => () => async () => { called++; }, () => c.read(), "Base", () => T0);
assert.equal(r.result, "fresh");
assert.equal(called, 0);
r = await probeWith(() => { throw new Error("Cannot find module '@aws-sdk/token-providers'"); }, () => c.read(), "Base", () => T0 + H - 60e3);
assert.equal(r.result, "unavailable");
let seen;
await probeWith(() => (init) => { seen = init; return async () => {}; }, () => c.read(), "GFTLIoT-Base", () => T0 + H - 60e3);
assert.deepEqual(seen, { profile: "GFTLIoT-Base", ignoreCache: true }); // the SSO profile at the end of the chain
});
test("reanchor: a capped new token inherited the session (keeps its start); otherwise the new token starts one", () => {
const st = { anchor: { refreshFp: "aaaa1111", since: T0 } };
assert.equal(reanchor(st, "aaaa1111", T0 + 3 * H, false), null); // hourly access refresh: same refresh token
assert.deepEqual(reanchor(st, "bbbb2222", T0 + 7.9 * H, true), { refreshFp: "bbbb2222", since: T0 }); // 2026-10-12 00:13: silent login, token capped
assert.deepEqual(reanchor(st, "bbbb2222", T0 + 8.5 * H, false), { refreshFp: "bbbb2222", since: T0 + 8.5 * H }); // 00:47: full token, new session
assert.deepEqual(reanchor({}, "aaaa1111", T0, true), { refreshFp: "aaaa1111", since: T0 });
});
test("mode: command beats .env beats default local; old names map; junk ignored", () => {
assert.deepEqual(resolveMode({}, {}), { mode: "local", source: "default" });
assert.deepEqual(resolveMode({}, { PI_AWS_SSO_MODE: "remote" }), { mode: "remote", source: "env" });
assert.deepEqual(resolveMode({ mode: "local" }, { PI_AWS_SSO_MODE: "remote" }), { mode: "local", source: "command" });
assert.deepEqual(resolveMode({ mode: "device" }, {}), { mode: "remote", source: "command" });
assert.deepEqual(resolveMode({ mode: "mac" }, {}), { mode: "local", source: "command" });
assert.deepEqual(resolveMode({ mode: "bogus" }, { PI_AWS_SSO_MODE: " Remote " }), { mode: "remote", source: "env" });
assert.equal(normalizeMode("headless"), null);
});
test("autoLoginGap: 2 min after a success (the post-end login must not wait); 30 min, 1 h, 2 h, 4 h after unanswered ones", () => {
assert.deepEqual([0, 1, 2, 3, 4, 9].map((n) => autoLoginGap(n) / 60e3), [2, 30, 60, 120, 240, 240]);
});
test("ntfyRequest: title/click/priority headers, bearer only when given, ASCII-safe; bad URLs refused", () => {
const r = ntfyRequest("https://ntfy.sh/aws-x7Kq2", undefined, { title: "AWS SSO approval needed (mbp)", message: "Code PCDB-WSGH — tap", click: `https://${HOST}/start/#/device?user_code=PCDB-WSGH`, priority: "high", tags: ["key"] });
assert.equal(r.url, "https://ntfy.sh/aws-x7Kq2");
assert.equal(r.init.method, "POST");
assert.deepEqual(r.init.headers, { Title: "AWS SSO approval needed (mbp)", Priority: "high", Click: `https://${HOST}/start/#/device?user_code=PCDB-WSGH`, Tags: "key" });
assert.equal(r.init.body, "Code PCDB-WSGH — tap"); // the body may be UTF-8, headers may not
assert.equal(ntfyRequest("https://n.example/t", "tk_abc", { title: "x", message: "y" }).init.headers.Authorization, "Bearer tk_abc");
assert.equal(ntfyRequest("https://n.example/t", undefined, { title: "Förnyad ✓", message: "y" }).init.headers.Title, "F?rnyad ?");
assert.throws(() => ntfyRequest("https://ntfy.sh/", undefined, { title: "x", message: "y" }), /no topic/);
assert.throws(() => ntfyRequest("file:///etc/passwd", undefined, { title: "x", message: "y" }), /http/);
assert.throws(() => ntfyRequest("not a url", undefined, { title: "x", message: "y" }));
});
test("describeTopic never prints the topic (it is the secret)", () => {
const d = describeTopic("https://ntfy.sh/aws-x7Kq2-very-secret");
assert.match(d, /^ntfy\.sh \(topic [0-9a-f]{8}\)$/);
assert.ok(!d.includes("secret"));
assert.equal(describeTopic(undefined), "not configured");
});
test("fmtLeft", () => {
assert.equal(fmtLeft(-5), "0m");
assert.equal(fmtLeft(25 * 60e3), "25m");
assert.equal(fmtLeft(7 * H + 5 * 60e3), "7h05m");
});