@botbuddy/cli 1.64.34 → 1.65.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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +2 -2
- package/src/pw/staging-auth.mjs +10 -4
- package/src/staging-pool/binding.mjs +6 -1
- package/src/staging-pool/cli.mjs +70 -15
- package/src/staging-pool/errors.mjs +96 -2
- package/src/staging-pool/fixtures/2026-10-14-okta-sso-signin.json +68 -0
- package/src/staging-pool/health.mjs +11 -2
- package/src/staging-pool/help.mjs +4 -4
- package/src/staging-pool/okta-steps.mjs +120 -0
- package/src/staging-pool/policy.mjs +4 -0
- package/src/staging-pool/pool.mjs +135 -23
- package/src/staging-pool/sign-in-kinds.mjs +87 -0
- package/src/staging-pool/signin-dispatch.mjs +31 -0
- package/src/staging-pool/signin-okta-saml.mjs +530 -0
- package/src/staging-pool/state-file.mjs +7 -1
- package/src/staging-pool/test-helpers/bindings.mjs +30 -0
- package/src/staging-pool/test-helpers/fake-okta-browser.mjs +239 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// BOT-3145 (ported from supplyspark-ent ENT-5553) test helper: a scripted stand-in for Playwright's chromium.launch() that plays
|
|
2
|
+
// the real Okta tenant. The identifier screen is built from the 2026-10-14 real capture
|
|
3
|
+
// (fixtures/2026-10-14-okta-sso-signin.json), so the okta-steps selectors are tested
|
|
4
|
+
// against the markup Okta actually serves; the later screens follow Okta Sign-In Widget 7.x.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
export const OKTA_FIXTURE = JSON.parse(
|
|
9
|
+
readFileSync(new URL("../fixtures/2026-10-14-okta-sso-signin.json", import.meta.url), "utf8"),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const OKTA_HOST = OKTA_FIXTURE.okta.host;
|
|
13
|
+
export const ACS_URL = OKTA_FIXTURE.gotrue.acs.request.replace(/^POST /, "");
|
|
14
|
+
|
|
15
|
+
function identifierScreen() {
|
|
16
|
+
const visible = new Map();
|
|
17
|
+
for (const input of OKTA_FIXTURE.okta.identifierScreen.inputs) {
|
|
18
|
+
if (input.type === "hidden") continue;
|
|
19
|
+
if (input.name) visible.set(`input[name="${input.name}"]`, "");
|
|
20
|
+
if (input.type === "submit") visible.set('input[type="submit"]', input.value);
|
|
21
|
+
}
|
|
22
|
+
visible.set('[data-se="o-form-head"]', OKTA_FIXTURE.okta.identifierScreen.visibleHeading);
|
|
23
|
+
return { name: "identifier", visible };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const passwordVisible = () =>
|
|
27
|
+
new Map([
|
|
28
|
+
['input[type="password"]', ""],
|
|
29
|
+
['input[name="credentials.passcode"]', ""],
|
|
30
|
+
['input[type="submit"]', "Verify"],
|
|
31
|
+
['[data-se="o-form-head"]', "Verify with your password"],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const SCREENS = {
|
|
35
|
+
identifier: identifierScreen,
|
|
36
|
+
password: () => ({ name: "password", visible: passwordVisible() }),
|
|
37
|
+
password_error: () => {
|
|
38
|
+
const visible = passwordVisible();
|
|
39
|
+
visible.set('[data-se="o-form-error-container"]', "Password is incorrect");
|
|
40
|
+
return { name: "password_error", visible };
|
|
41
|
+
},
|
|
42
|
+
locked: () => {
|
|
43
|
+
const visible = passwordVisible();
|
|
44
|
+
visible.set('[data-se="o-form-error-container"]', "Your account is locked. Please contact your administrator.");
|
|
45
|
+
return { name: "locked", visible };
|
|
46
|
+
},
|
|
47
|
+
mfa: () => ({
|
|
48
|
+
name: "mfa",
|
|
49
|
+
visible: new Map([
|
|
50
|
+
['[data-se="o-form-head"]', "Verify it's you with a security method"],
|
|
51
|
+
['[data-se="authenticator-verify-list"]', "Get a push notification Okta Verify"],
|
|
52
|
+
]),
|
|
53
|
+
}),
|
|
54
|
+
totp_challenge: () => ({
|
|
55
|
+
name: "totp_challenge",
|
|
56
|
+
visible: new Map([
|
|
57
|
+
['[data-se="o-form-head"]', "Verify with Google Authenticator"],
|
|
58
|
+
['input[name="credentials.passcode"]', ""],
|
|
59
|
+
['input[type="submit"]', "Verify"],
|
|
60
|
+
]),
|
|
61
|
+
}),
|
|
62
|
+
blank: () => ({ name: "blank", visible: new Map() }),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const splitSelector = (selector) => selector.split(/,\s*/).map((part) => part.trim());
|
|
66
|
+
|
|
67
|
+
// Playwright URL glob: `**` any characters, `*` any except "/".
|
|
68
|
+
function globToRegExp(glob) {
|
|
69
|
+
const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
|
|
70
|
+
return new RegExp(`^${escaped}$`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* outcome after the password submit (Okta auto-posts the SAML assertion to GoTrue's ACS):
|
|
75
|
+
* "success" → ACS answers 302 → app origin with a session fragment (`landingHash`)
|
|
76
|
+
* "callback_error" → ACS answers 302 → app origin with a GoTrue error fragment (`landingHash`)
|
|
77
|
+
* "acs_error_body" → ACS answers 422 with a GoTrue JSON error (`acsBody`)
|
|
78
|
+
* "password_error" | "locked" | "mfa" | "totp_challenge" → that Okta screen
|
|
79
|
+
* "hang" → nothing happens
|
|
80
|
+
* redirectHost: serve the identifier screen on another host (a look-alike). gotoError: page.goto throws it.
|
|
81
|
+
* "stuck_off_okta" → after Verify the browser leaves Okta and nothing ever answers
|
|
82
|
+
* "click_throws" → the Verify click throws after the password was filled
|
|
83
|
+
* "blank_on_okta" → after Verify Okta shows neither a form nor an error, on its own host
|
|
84
|
+
*
|
|
85
|
+
* Like real Playwright, a route on the ACS sees the POST; the 302's target is a redirect, which
|
|
86
|
+
* Playwright never routes — an unintercepted ACS therefore lands on the app (`log.appBooted`).
|
|
87
|
+
*/
|
|
88
|
+
export function createFakeOktaBrowser({ appOrigin, outcomes = ["success"], landingHash, acsBody = "", redirectHost, acsUrl = ACS_URL, gotoError } = {}) {
|
|
89
|
+
const log = { launches: 0, closes: 0, typed: [], routes: [], landings: [], acsFetches: [], blankFulfilled: 0, gotos: [], appBooted: 0 };
|
|
90
|
+
let launchIndex = 0;
|
|
91
|
+
|
|
92
|
+
function newPage(context) {
|
|
93
|
+
const handlers = [];
|
|
94
|
+
const frame = { url: () => page._url };
|
|
95
|
+
let screen = SCREENS.blank();
|
|
96
|
+
const outcome = outcomes[Math.min(launchIndex - 1, outcomes.length - 1)];
|
|
97
|
+
|
|
98
|
+
function commit(url) {
|
|
99
|
+
page._url = url;
|
|
100
|
+
for (const handler of handlers) handler(frame);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function acsResponse() {
|
|
104
|
+
if (outcome === "acs_error_body") return { status: 422, headers: { "content-type": "application/json" }, body: acsBody };
|
|
105
|
+
// Real staging (fixture): success is a 302, an ACS error a 303 redirect with error params.
|
|
106
|
+
const status = outcome === "callback_error" ? OKTA_FIXTURE.gotrue.acs.errorRedirect.status : OKTA_FIXTURE.gotrue.acs.status;
|
|
107
|
+
return { status, headers: { location: `${appOrigin}/#${landingHash}` }, body: "" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function postToAcs() {
|
|
111
|
+
const response = acsResponse();
|
|
112
|
+
const routed = context.routes.find((route) => globToRegExp(route.pattern).test(acsUrl));
|
|
113
|
+
if (routed) {
|
|
114
|
+
let fulfilled = false;
|
|
115
|
+
await routed.handler({
|
|
116
|
+
request: () => ({ url: () => acsUrl, method: () => "POST" }),
|
|
117
|
+
async fetch(options = {}) {
|
|
118
|
+
log.acsFetches.push(options);
|
|
119
|
+
if (options.maxRedirects !== 0) throw new Error("the recipe must fetch the ACS without following its redirect");
|
|
120
|
+
return { status: () => response.status, headers: () => response.headers, text: async () => response.body };
|
|
121
|
+
},
|
|
122
|
+
async fulfill() {
|
|
123
|
+
fulfilled = true;
|
|
124
|
+
log.blankFulfilled += 1;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
if (!fulfilled) throw new Error("ACS route neither fulfilled nor continued");
|
|
128
|
+
screen = SCREENS.blank();
|
|
129
|
+
commit(acsUrl);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Not intercepted: the browser follows the 302 and the real app boots on the fragment.
|
|
133
|
+
if (response.status === 302 || response.status === 303) {
|
|
134
|
+
log.appBooted += 1;
|
|
135
|
+
log.landings.push(response.headers.location);
|
|
136
|
+
screen = SCREENS.blank();
|
|
137
|
+
commit(response.headers.location);
|
|
138
|
+
} else {
|
|
139
|
+
screen = SCREENS.blank();
|
|
140
|
+
commit(acsUrl);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function afterPasswordSubmit() {
|
|
145
|
+
if (outcome === "success" || outcome === "callback_error" || outcome === "acs_error_body") {
|
|
146
|
+
await postToAcs();
|
|
147
|
+
} else if (outcome === "click_throws") {
|
|
148
|
+
// BOT-3145 review: the Verify click itself fails after the password went in.
|
|
149
|
+
throw new Error("locator.click: Target page, context or browser has been closed");
|
|
150
|
+
} else if (outcome === "blank_on_okta") {
|
|
151
|
+
// BOT-3145 review: Okta stays on its own host with no form and no error after Verify.
|
|
152
|
+
screen = SCREENS.blank();
|
|
153
|
+
} else if (outcome === "about_blank") {
|
|
154
|
+
// BOT-3145 review round 2: the tab goes blank after Verify (no host, proves nothing).
|
|
155
|
+
screen = SCREENS.blank();
|
|
156
|
+
commit("about:blank");
|
|
157
|
+
} else if (outcome === "stuck_off_okta") {
|
|
158
|
+
// Okta moved on (e.g. to GoTrue) but nothing ever answers.
|
|
159
|
+
screen = SCREENS.blank();
|
|
160
|
+
commit("https://staging-api.supply-guard.io/auth/v1/sso/saml/pending");
|
|
161
|
+
} else if (outcome !== "hang") {
|
|
162
|
+
screen = SCREENS[outcome]();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const locator = (selector) => {
|
|
167
|
+
const parts = splitSelector(selector);
|
|
168
|
+
const match = () => parts.find((part) => screen.visible.has(part));
|
|
169
|
+
const api = {
|
|
170
|
+
first: () => api,
|
|
171
|
+
async isVisible() {
|
|
172
|
+
return Boolean(match());
|
|
173
|
+
},
|
|
174
|
+
async waitFor() {
|
|
175
|
+
if (!match()) throw new Error(`Timeout waiting for ${selector} on ${screen.name}`);
|
|
176
|
+
},
|
|
177
|
+
async innerText() {
|
|
178
|
+
const part = match();
|
|
179
|
+
if (!part) throw new Error("not visible");
|
|
180
|
+
return screen.visible.get(part);
|
|
181
|
+
},
|
|
182
|
+
async fill(value) {
|
|
183
|
+
const part = match();
|
|
184
|
+
if (!part) throw new Error(`fill: ${selector} not visible`);
|
|
185
|
+
log.typed.push({ host: new URL(page._url).hostname, field: part.includes("identifier") ? "identifier" : "password", value });
|
|
186
|
+
},
|
|
187
|
+
async click() {
|
|
188
|
+
if (screen.name === "identifier") screen = SCREENS.password();
|
|
189
|
+
else if (screen.name === "password") await afterPasswordSubmit();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
return api;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const page = {
|
|
196
|
+
_url: "about:blank",
|
|
197
|
+
on(event, handler) {
|
|
198
|
+
if (event === "framenavigated") handlers.push(handler);
|
|
199
|
+
},
|
|
200
|
+
mainFrame: () => frame,
|
|
201
|
+
url: () => page._url,
|
|
202
|
+
locator,
|
|
203
|
+
async goto(url) {
|
|
204
|
+
log.gotos.push(url);
|
|
205
|
+
if (gotoError) throw gotoError;
|
|
206
|
+
const parsed = new URL(url);
|
|
207
|
+
if (redirectHost) parsed.hostname = redirectHost;
|
|
208
|
+
commit(parsed.toString());
|
|
209
|
+
if (parsed.hostname === OKTA_HOST || redirectHost) screen = SCREENS.identifier();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
return page;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function launchBrowser() {
|
|
216
|
+
log.launches += 1;
|
|
217
|
+
launchIndex += 1;
|
|
218
|
+
return {
|
|
219
|
+
async newContext() {
|
|
220
|
+
const context = {
|
|
221
|
+
routes: [],
|
|
222
|
+
async route(pattern, handler) {
|
|
223
|
+
context.routes.push({ pattern, handler });
|
|
224
|
+
log.routes.push(pattern);
|
|
225
|
+
},
|
|
226
|
+
async newPage() {
|
|
227
|
+
return newPage(context);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
return context;
|
|
231
|
+
},
|
|
232
|
+
async close() {
|
|
233
|
+
log.closes += 1;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { launchBrowser, log };
|
|
239
|
+
}
|