pi-lean-dimension 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/node_modules/pi-lean-host/AGENTS.md +13 -6
- package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
- package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
- package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
- package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
- package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
- package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
- package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
- package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
- package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
- package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
- package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
- package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
- package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
- package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
- package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
- package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
- package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
- package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
- package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
- package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
- package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
- package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
- package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
- package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
- package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
- package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
- package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
- package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
- package/node_modules/pi-lean-host/core/auth.ts +2 -1
- package/node_modules/pi-lean-host/core/helpers.ts +29 -35
- package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
- package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
- package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
- package/node_modules/pi-lean-host/core/transport.ts +1 -1
- package/node_modules/pi-lean-host/package.json +1 -1
- package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
- package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
- package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
- package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
- package/node_modules/pi-lean-host/tools/utils.ts +69 -0
- package/node_modules/pi-lean-portal/AGENTS.md +4 -3
- package/node_modules/pi-lean-portal/README.md +9 -9
- package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
- package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
- package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
- package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
- package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
- package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
- package/node_modules/pi-lean-portal/browser-install.ts +454 -0
- package/node_modules/pi-lean-portal/browser-status.ts +21 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
- package/node_modules/pi-lean-portal/core/router.ts +7 -32
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
- package/node_modules/pi-lean-portal/index.ts +1 -18
- package/node_modules/pi-lean-portal/package.json +2 -1
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
- package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
- package/node_modules/pi-lean-search/AGENTS.md +5 -3
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
- package/node_modules/pi-lean-search/index.ts +0 -3
- package/node_modules/pi-lean-search/package.json +1 -1
- package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +4 -4
|
@@ -513,6 +513,62 @@ describe("oauth init — interactive wizard", () => {
|
|
|
513
513
|
).toBe(true);
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
+
it("wizard paste Esc → recovery input: a typed corrected URI restarts in-flow — the seven wizard answers are not re-asked", async () => {
|
|
517
|
+
writeSecret("wzdr2.invalid", "client_id", "MY_CLIENT");
|
|
518
|
+
const CUSTOM = "http://localhost:5173/callback";
|
|
519
|
+
const seenBodies: string[] = [];
|
|
520
|
+
stubTokenEndpoint((_url, init) => {
|
|
521
|
+
seenBodies.push(String(init.body));
|
|
522
|
+
return tokenResponse({ access_token: "WZDR2", expires_in: 3600 });
|
|
523
|
+
});
|
|
524
|
+
const m = makeCtx({ hasUI: true });
|
|
525
|
+
const { inputs } = m;
|
|
526
|
+
// grant (select) → redirect URI (default) → tokenUrl, authorizeUrl,
|
|
527
|
+
// scopes (empty) + client-id / client-secret (omit) — seven answers.
|
|
528
|
+
m.selects.push("authorization_code", "client_id", OMIT);
|
|
529
|
+
m.inputs.push("", TOKEN_URL, AUTHORIZE_URL, "");
|
|
530
|
+
let pasteCalls = 0;
|
|
531
|
+
let recoveryCalls = 0;
|
|
532
|
+
(m.ctx.ui.input as ReturnType<typeof vi.fn>).mockImplementation(
|
|
533
|
+
async (title: string) => {
|
|
534
|
+
const queued = inputs.shift();
|
|
535
|
+
if (queued !== undefined) return queued; // the wizard answers
|
|
536
|
+
if (title.includes("Redirect URI — Enter to keep")) {
|
|
537
|
+
recoveryCalls++;
|
|
538
|
+
return CUSTOM; // corrected URI → in-flow restart
|
|
539
|
+
}
|
|
540
|
+
pasteCalls++;
|
|
541
|
+
const pending = readPendingFlow(
|
|
542
|
+
"wzdr2.invalid",
|
|
543
|
+
"authorization_code",
|
|
544
|
+
TOKEN_URL,
|
|
545
|
+
);
|
|
546
|
+
if (pasteCalls === 1) return undefined; // Esc at the first paste
|
|
547
|
+
return `${CUSTOM}?code=CB&state=${pending?.state}`;
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
await handleOauthSubcommand("init wzdr2.invalid", m.ctx);
|
|
551
|
+
// The mint completed with the corrected URI.
|
|
552
|
+
expect(
|
|
553
|
+
readToken("wzdr2.invalid", "authorization_code", TOKEN_URL)?.accessToken,
|
|
554
|
+
).toBe("WZDR2");
|
|
555
|
+
expect(
|
|
556
|
+
seenBodies.some((b) =>
|
|
557
|
+
b.includes(`redirect_uri=${encodeURIComponent(CUSTOM)}`),
|
|
558
|
+
),
|
|
559
|
+
).toBe(true);
|
|
560
|
+
// Only the paste was re-asked (Esc at paste → recovery → paste);
|
|
561
|
+
// the wizard's 3 selects + 4 inputs were never repeated.
|
|
562
|
+
expect(recoveryCalls).toBe(1);
|
|
563
|
+
expect(pasteCalls).toBe(2);
|
|
564
|
+
expect((m.ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
|
|
565
|
+
3,
|
|
566
|
+
);
|
|
567
|
+
expect((m.ctx.ui.input as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
|
|
568
|
+
7, // 4 wizard + 2 pastes + 1 recovery
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
516
572
|
it("wizard redirect-URI prompt: Esc cancels, nothing provisioned", async () => {
|
|
517
573
|
writeSecret("wzdc.invalid", "client_id", "MY_CLIENT");
|
|
518
574
|
const m = makeCtx({ hasUI: true });
|
|
@@ -527,6 +583,45 @@ describe("oauth init — interactive wizard", () => {
|
|
|
527
583
|
expect(readToken("wzdc.invalid", "authorization_code", TOKEN_URL)).toBeNull();
|
|
528
584
|
});
|
|
529
585
|
|
|
586
|
+
it("wizard paste cancel: after Esc at the paste and Esc at the recovery input, the wizard cancel message still prints with all seven answers intact", async () => {
|
|
587
|
+
writeSecret("wzdp.invalid", "client_id", "MY_CLIENT");
|
|
588
|
+
const m = makeCtx({ hasUI: true });
|
|
589
|
+
const { inputs } = m;
|
|
590
|
+
// grant (select) → redirect URI (empty = default) → tokenUrl,
|
|
591
|
+
// authorizeUrl, scopes (empty) + client-id / client-secret (omit) —
|
|
592
|
+
// the seven wizard answers. The queue is then exhausted, so the paste
|
|
593
|
+
// prompt gets undefined (Esc) and so does the recovery input.
|
|
594
|
+
m.selects.push("authorization_code", "client_id", OMIT);
|
|
595
|
+
m.inputs.push("", TOKEN_URL, AUTHORIZE_URL, "");
|
|
596
|
+
let restarts = 0;
|
|
597
|
+
(m.ctx.ui.input as ReturnType<typeof vi.fn>).mockImplementation(
|
|
598
|
+
async (title: string) => {
|
|
599
|
+
if (title.includes("Redirect URI — Enter to keep")) {
|
|
600
|
+
restarts++;
|
|
601
|
+
return undefined; // Esc at recovery → abort
|
|
602
|
+
}
|
|
603
|
+
return inputs.shift(); // wizard answers, then paste Esc (undefined)
|
|
604
|
+
},
|
|
605
|
+
);
|
|
606
|
+
await handleOauthSubcommand("init wzdp.invalid", m.ctx);
|
|
607
|
+
// The wizard's answers were consumed before the paste — the restart
|
|
608
|
+
// lives inside the flow, so nothing was re-asked after the Esc.
|
|
609
|
+
expect((m.ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
|
|
610
|
+
3,
|
|
611
|
+
);
|
|
612
|
+
expect(inputs).toHaveLength(0);
|
|
613
|
+
expect(restarts).toBe(1);
|
|
614
|
+
// Prompt count: 4 wizard inputs + the paste + the recovery input = 6.
|
|
615
|
+
expect((m.ctx.ui.input as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
|
|
616
|
+
6,
|
|
617
|
+
);
|
|
618
|
+
expect(m.out()).toContain("Cancelled. Re-run /api oauth init wzdp.invalid");
|
|
619
|
+
expect(
|
|
620
|
+
readPendingFlow("wzdp.invalid", "authorization_code", TOKEN_URL),
|
|
621
|
+
).not.toBeNull();
|
|
622
|
+
expect(readToken("wzdp.invalid", "authorization_code", TOKEN_URL)).toBeNull();
|
|
623
|
+
});
|
|
624
|
+
|
|
530
625
|
it("wizard with no provisioned secrets aborts with the /api secrets nudge", async () => {
|
|
531
626
|
const m = makeCtx({ hasUI: true });
|
|
532
627
|
m.selects.push("client_credentials");
|
|
@@ -517,6 +517,169 @@ describe("mintAuthCodeToken", () => {
|
|
|
517
517
|
expect(warned).toContain("state mismatch");
|
|
518
518
|
});
|
|
519
519
|
|
|
520
|
+
// The recovery input (opened by Esc at the paste) is routed by TITLE —
|
|
521
|
+
// keying a double on pending-flow state would feed a synthetic paste into
|
|
522
|
+
// it and mask the restart path instead of exercising it.
|
|
523
|
+
const RECOVERY_TITLE = "Redirect URI — Enter to keep";
|
|
524
|
+
|
|
525
|
+
it.each([
|
|
526
|
+
["empty Enter", ""],
|
|
527
|
+
// a prefilling client submits the default on plain Enter — same keep path
|
|
528
|
+
["a re-typed identical URI", REDIRECT_URI],
|
|
529
|
+
])(
|
|
530
|
+
"recovery input: %s keeps the flow — paste re-prompts, pending unchanged, already-issued code stays valid",
|
|
531
|
+
async (_label, keep) => {
|
|
532
|
+
const auth = makeAuthCodeAuth();
|
|
533
|
+
writeSecret("oauth.recovery", "client_id", "MY_CLIENT");
|
|
534
|
+
stubTokenEndpoint(() =>
|
|
535
|
+
tokenResponse({ access_token: "REPASTE", expires_in: 3600 }),
|
|
536
|
+
);
|
|
537
|
+
let firstState: string | undefined;
|
|
538
|
+
let recoveryCalls = 0;
|
|
539
|
+
const notify = vi.fn();
|
|
540
|
+
const ctx = {
|
|
541
|
+
hasUI: true,
|
|
542
|
+
ui: {
|
|
543
|
+
notify,
|
|
544
|
+
input: async (title: string) => {
|
|
545
|
+
if (title.includes(RECOVERY_TITLE)) {
|
|
546
|
+
recoveryCalls++;
|
|
547
|
+
// The current redirect URI is what's being kept or replaced.
|
|
548
|
+
expect(title).toContain(REDIRECT_URI);
|
|
549
|
+
return keep; // keep → paste again
|
|
550
|
+
}
|
|
551
|
+
const pending = readPendingFlow(
|
|
552
|
+
"oauth.recovery",
|
|
553
|
+
"authorization_code",
|
|
554
|
+
TOKEN_URL,
|
|
555
|
+
);
|
|
556
|
+
if (!firstState) {
|
|
557
|
+
firstState = pending?.state;
|
|
558
|
+
return undefined; // Esc at the paste → recovery input
|
|
559
|
+
}
|
|
560
|
+
// Same verifier/state as the first paste — nothing was rewritten.
|
|
561
|
+
expect(pending?.state).toBe(firstState);
|
|
562
|
+
return `http://127.0.0.1/callback?code=CB&state=${pending?.state}`;
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
} as any;
|
|
566
|
+
|
|
567
|
+
const token = await mintAuthCodeToken(auth, "oauth.recovery", ctx, {});
|
|
568
|
+
expect(token.accessToken).toBe("REPASTE");
|
|
569
|
+
expect(recoveryCalls).toBe(1);
|
|
570
|
+
// No second authorize-URL build — the keep re-prompts the paste only.
|
|
571
|
+
expect(
|
|
572
|
+
notify.mock.calls.filter((c: unknown[]) =>
|
|
573
|
+
String(c[0]).includes("code_challenge"),
|
|
574
|
+
),
|
|
575
|
+
).toHaveLength(1);
|
|
576
|
+
},
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
it("recovery input: a typed URI restarts the flow — regenerated pending, second authorize build, edited URI persisted", async () => {
|
|
580
|
+
const auth = makeAuthCodeAuth();
|
|
581
|
+
writeSecret("oauth.restart", "client_id", "MY_CLIENT");
|
|
582
|
+
const CUSTOM = "http://localhost:5173/callback";
|
|
583
|
+
const seenBodies: string[] = [];
|
|
584
|
+
stubTokenEndpoint((_url, init) => {
|
|
585
|
+
seenBodies.push(String(init.body));
|
|
586
|
+
return tokenResponse({ access_token: "RESTARTED", expires_in: 3600 });
|
|
587
|
+
});
|
|
588
|
+
let firstState: string | undefined;
|
|
589
|
+
const notify = vi.fn();
|
|
590
|
+
const ctx = {
|
|
591
|
+
hasUI: true,
|
|
592
|
+
ui: {
|
|
593
|
+
notify,
|
|
594
|
+
input: async (title: string) => {
|
|
595
|
+
if (title.includes(RECOVERY_TITLE)) {
|
|
596
|
+
expect(title).toContain(REDIRECT_URI);
|
|
597
|
+
return CUSTOM;
|
|
598
|
+
}
|
|
599
|
+
const pending = readPendingFlow(
|
|
600
|
+
"oauth.restart",
|
|
601
|
+
"authorization_code",
|
|
602
|
+
TOKEN_URL,
|
|
603
|
+
);
|
|
604
|
+
if (!firstState) {
|
|
605
|
+
firstState = pending?.state;
|
|
606
|
+
return undefined; // Esc at the paste → recovery input
|
|
607
|
+
}
|
|
608
|
+
// Restart regenerated the pending flow and persisted the edit.
|
|
609
|
+
expect(pending?.state).not.toBe(firstState);
|
|
610
|
+
expect(pending?.redirectUri).toBe(CUSTOM);
|
|
611
|
+
return `${CUSTOM}?code=CB&state=${pending?.state}`;
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
} as any;
|
|
615
|
+
|
|
616
|
+
const token = await mintAuthCodeToken(auth, "oauth.restart", ctx, {});
|
|
617
|
+
expect(token.accessToken).toBe("RESTARTED");
|
|
618
|
+
// Second authorize-URL build (the restart).
|
|
619
|
+
expect(
|
|
620
|
+
notify.mock.calls.filter((c: unknown[]) =>
|
|
621
|
+
String(c[0]).includes("code_challenge"),
|
|
622
|
+
),
|
|
623
|
+
).toHaveLength(2);
|
|
624
|
+
// The exchange used the edited URI (RFC 6749 §4.1.3).
|
|
625
|
+
expect(seenBodies[0]).toContain("redirect_uri=" + encodeURIComponent(CUSTOM));
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("recovery input: Esc there falls through to the existing cancel path; the pending flow survives", async () => {
|
|
629
|
+
const auth = makeAuthCodeAuth();
|
|
630
|
+
writeSecret("oauth.recesc", "client_id", "MY_CLIENT");
|
|
631
|
+
let recoveryCalls = 0;
|
|
632
|
+
const ctx = {
|
|
633
|
+
hasUI: true,
|
|
634
|
+
ui: {
|
|
635
|
+
notify: vi.fn(),
|
|
636
|
+
input: async (title: string) => {
|
|
637
|
+
if (title.includes(RECOVERY_TITLE)) recoveryCalls++;
|
|
638
|
+
return undefined; // Esc at the paste, then Esc at recovery
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
} as any;
|
|
642
|
+
|
|
643
|
+
await expect(
|
|
644
|
+
mintAuthCodeToken(auth, "oauth.recesc", ctx, {}),
|
|
645
|
+
).rejects.toBeInstanceOf(OAuthTokenMissingError);
|
|
646
|
+
// The aborted Esc was the recovery input's, not the paste's.
|
|
647
|
+
expect(recoveryCalls).toBe(1);
|
|
648
|
+
expect(
|
|
649
|
+
readPendingFlow("oauth.recesc", "authorization_code", TOKEN_URL),
|
|
650
|
+
).not.toBeNull();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("recovery input: the failed-paste retry notify offers recovery options, not a plain cancel", async () => {
|
|
654
|
+
const auth = makeAuthCodeAuth();
|
|
655
|
+
writeSecret("oauth.reccopy", "client_id", "MY_CLIENT");
|
|
656
|
+
let firstPaste = true;
|
|
657
|
+
const ctx = {
|
|
658
|
+
hasUI: true,
|
|
659
|
+
ui: {
|
|
660
|
+
notify: vi.fn(),
|
|
661
|
+
input: async (title: string) => {
|
|
662
|
+
if (title.includes(RECOVERY_TITLE)) return undefined; // Esc → abort
|
|
663
|
+
if (firstPaste) {
|
|
664
|
+
firstPaste = false;
|
|
665
|
+
// A failed paste (wrong state) fires the retry notify.
|
|
666
|
+
return "http://127.0.0.1/callback?code=CB&state=WRONG";
|
|
667
|
+
}
|
|
668
|
+
return undefined; // Esc at the second paste
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
} as any;
|
|
672
|
+
|
|
673
|
+
await expect(
|
|
674
|
+
mintAuthCodeToken(auth, "oauth.reccopy", ctx, {}),
|
|
675
|
+
).rejects.toBeInstanceOf(OAuthTokenMissingError);
|
|
676
|
+
const warned = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls
|
|
677
|
+
.map((c: unknown[]) => String(c[0]))
|
|
678
|
+
.join("\n");
|
|
679
|
+
expect(warned).toContain("escape for recovery options");
|
|
680
|
+
expect(warned).not.toContain("escape to cancel");
|
|
681
|
+
});
|
|
682
|
+
|
|
520
683
|
it("pending flows are slot-isolated: two issuers on one domain don't consume each other's verifier", async () => {
|
|
521
684
|
const TT2 = "https://other-issuer.example.com/oauth/token";
|
|
522
685
|
const authA = makeAuthCodeAuth(); // tokenUrl = TOKEN_URL
|
|
@@ -71,6 +71,10 @@ function makeToolCtx(
|
|
|
71
71
|
redirectResult?: string;
|
|
72
72
|
/** If true, the paste input returns undefined (user cancels at the paste). */
|
|
73
73
|
cancelPaste?: boolean;
|
|
74
|
+
/** Sequence of recovery-input results (the paste-loop Esc recovery;
|
|
75
|
+
* default undefined = Esc → abort — NEVER "", which would loop
|
|
76
|
+
* the cancelPaste test forever with no failing assertion). */
|
|
77
|
+
recoveryResults?: (string | undefined)[];
|
|
74
78
|
} = {},
|
|
75
79
|
): {
|
|
76
80
|
ctx: any;
|
|
@@ -107,18 +111,24 @@ function makeToolCtx(
|
|
|
107
111
|
}),
|
|
108
112
|
input: vi.fn(async (title: string) => {
|
|
109
113
|
orders.push("input");
|
|
110
|
-
// The
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
// The paste-loop recovery input is identified by title (opened by Esc
|
|
115
|
+
// at the paste); the redirect-URI edit by "Redirect URI for" (it only
|
|
116
|
+
// fires when "Change redirect URI" was selected); everything else is
|
|
117
|
+
// a paste.
|
|
118
|
+
let r: string | undefined;
|
|
119
|
+
if (title.includes("Redirect URI — Enter to keep")) {
|
|
120
|
+
r = opts.recoveryResults?.shift() ?? undefined; // default: Esc → abort
|
|
121
|
+
} else if (title.includes("Redirect URI for")) {
|
|
122
|
+
r = opts.redirectResult ?? "";
|
|
123
|
+
} else {
|
|
124
|
+
r = opts.cancelPaste
|
|
116
125
|
? undefined
|
|
117
126
|
: // Paste: simulate the user having authorized and pasting the
|
|
118
127
|
// address-bar URL with the pending flow's state.
|
|
119
128
|
`${REDIRECT_URI}?code=CB&state=${
|
|
120
129
|
readPendingFlow("mint.invalid", "authorization_code", TOKEN_URL)?.state
|
|
121
130
|
}`;
|
|
131
|
+
}
|
|
122
132
|
inputs.push([title, r]);
|
|
123
133
|
return r;
|
|
124
134
|
}),
|
|
@@ -550,6 +560,69 @@ describe("oauth-mint — authorization_code arm", () => {
|
|
|
550
560
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
551
561
|
});
|
|
552
562
|
|
|
563
|
+
it("tripwire: Esc at the paste reaches the recovery input, whose default (undefined, never '') aborts", async () => {
|
|
564
|
+
writeSecret("mint.invalid", "client_id", "MY_CLIENT");
|
|
565
|
+
const m = makeToolCtx({ mode: "rpc", cancelPaste: true });
|
|
566
|
+
await expect(
|
|
567
|
+
oauthMintTool.execute(
|
|
568
|
+
"test",
|
|
569
|
+
{
|
|
570
|
+
domain: "mint.invalid",
|
|
571
|
+
grant: "authorization_code",
|
|
572
|
+
tokenUrl: TOKEN_URL,
|
|
573
|
+
clientId: "client_id",
|
|
574
|
+
authorizeUrl: AUTHORIZE_URL,
|
|
575
|
+
},
|
|
576
|
+
undefined,
|
|
577
|
+
undefined,
|
|
578
|
+
m.ctx,
|
|
579
|
+
),
|
|
580
|
+
).rejects.toThrow(/--code <redirect-url-or-code>/);
|
|
581
|
+
// The double routed by title: paste Esc → recovery input → its
|
|
582
|
+
// undefined default aborted. A "" default here would have looped the
|
|
583
|
+
// paste-undefined → recovery-"" → paste-undefined cycle forever.
|
|
584
|
+
const recovery = m.inputs.find(([t]) =>
|
|
585
|
+
t.includes("Redirect URI — Enter to keep"),
|
|
586
|
+
);
|
|
587
|
+
expect(recovery).toBeDefined();
|
|
588
|
+
expect(
|
|
589
|
+
readPendingFlow("mint.invalid", "authorization_code", TOKEN_URL),
|
|
590
|
+
).not.toBeNull();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("recovery input: a garbage-typed URI restarts (regenerated flow) and a later Esc still aborts — no loop", async () => {
|
|
594
|
+
writeSecret("mint.invalid", "client_id", "MY_CLIENT");
|
|
595
|
+
const m = makeToolCtx({
|
|
596
|
+
mode: "rpc",
|
|
597
|
+
cancelPaste: true,
|
|
598
|
+
recoveryResults: ["not a uri at all"],
|
|
599
|
+
});
|
|
600
|
+
await expect(
|
|
601
|
+
oauthMintTool.execute(
|
|
602
|
+
"test",
|
|
603
|
+
{
|
|
604
|
+
domain: "mint.invalid",
|
|
605
|
+
grant: "authorization_code",
|
|
606
|
+
tokenUrl: TOKEN_URL,
|
|
607
|
+
clientId: "client_id",
|
|
608
|
+
authorizeUrl: AUTHORIZE_URL,
|
|
609
|
+
},
|
|
610
|
+
undefined,
|
|
611
|
+
undefined,
|
|
612
|
+
m.ctx,
|
|
613
|
+
),
|
|
614
|
+
).rejects.toThrow(/--code <redirect-url-or-code>/);
|
|
615
|
+
// Terminates: initial flow + one garbage restart, then the exhausted
|
|
616
|
+
// recovery queue (undefined) aborts.
|
|
617
|
+
const authorizeBuilds = m.inputs.filter(([t]) =>
|
|
618
|
+
t.includes("Open this URL in YOUR browser"),
|
|
619
|
+
);
|
|
620
|
+
expect(authorizeBuilds).toHaveLength(2);
|
|
621
|
+
expect(
|
|
622
|
+
readPendingFlow("mint.invalid", "authorization_code", TOKEN_URL),
|
|
623
|
+
).not.toBeNull();
|
|
624
|
+
});
|
|
625
|
+
|
|
553
626
|
it("auth-code redirect-URI edit: proposes the default, human can override inline; the override reaches the exchange", async () => {
|
|
554
627
|
writeSecret("mint.invalid", "client_id", "MY_CLIENT");
|
|
555
628
|
|