-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathSignInAutomation.ts
More file actions
363 lines (300 loc) · 11.8 KB
/
SignInAutomation.ts
File metadata and controls
363 lines (300 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import * as os from "node:os";
import type { Browser, LaunchOptions, Page } from "@playwright/test";
import type { TestUserCredentials } from "./TestUsers";
import { testSelectors } from "./TestSelectors";
/** @internal configuration for automated sign in */
export interface AutomatedSignInConfig {
issuer: string;
/** optional endpoint configuration to verify when handling ping login page */
authorizationEndpoint?: string;
}
/** @internal base context for automated sign in and sign out functions */
interface AutomatedContextBase<T> {
page: Page;
/** a promise that resolves once the sign in callback is reached,
* with any data, e.g. a callback URL
* @defaults Promise.resolve()
*/
waitForCallback?: Promise<T>;
/** A function that takes the waitForCallback result data (e.g. a callback url)
* and finalizes the sign in process
*/
resultFromCallback?: (t: T) => any | Promise<any>; // eslint-disable-line @typescript-eslint/no-redundant-type-constituents
/** optionally provide the abort controller for errors,
* in case you need to cancel your waitForCallbackUrl */
abortController?: AbortController;
/** whether or not to kill the entire browser when cleaning up */
doNotKillBrowser?: boolean;
}
/** @internal context for automated sign in functions */
export interface AutomatedSignInContext<T> extends AutomatedContextBase<T> {
signInInitUrl: string;
user: TestUserCredentials;
config: AutomatedSignInConfig;
}
/** @internal context for automated sign in functions */
export interface AutomatedSignOutContext<T> extends AutomatedContextBase<T> {
signOutInitUrl: string;
}
/**
* given a context with configuration, user info, a playwright page,
* and iTwin services sign in url, sign in
* @internal
*/
export async function automatedSignIn<T>(
context: AutomatedSignInContext<T>,
): Promise<void> {
const { page } = context;
const waitForCallback = context.waitForCallback ?? Promise.resolve() as Promise<T>;
const controller = context.abortController ?? new AbortController();
try {
await page.goto(context.signInInitUrl);
try {
await handleErrorPage(context);
await handleLoginPage(context);
await handlePingLoginPage(context);
// Handle federated sign-in
await handleFederatedSignin(context);
} catch (err) {
controller.abort();
throw new Error(`Failed OIDC signin for ${context.user.email}.\n${err}`);
}
try {
await handleConsentPage(context);
} catch (error) {
// ignore, if we get the callback Url, we're good.
}
if (context.resultFromCallback)
// if we do not await here, logic in resultFromCallback can escape the cleanup in finally
// eslint-disable-next-line @typescript-eslint/return-await
return await context.resultFromCallback(await waitForCallback);
} finally {
await cleanup(page, controller.signal, waitForCallback, context.doNotKillBrowser);
}
}
/**
* given a context with configuration, user info, a playwright page,
* and iTwin services sign out url, sign out
* @internal
*/
export async function automatedSignOut<T>(
context: AutomatedSignOutContext<T>,
): Promise<void> {
const { page } = context;
const waitForCallback = context.waitForCallback ?? Promise.resolve() as Promise<T>;
const controller = context.abortController ?? new AbortController();
try {
await page.goto(context.signOutInitUrl);
} finally {
await cleanup(page, controller.signal, waitForCallback, context.doNotKillBrowser);
}
}
async function handleErrorPage<T>({ page }: AutomatedContextBase<T>): Promise<void> {
const pageTitle = await page.title();
let errMsgText;
if (pageTitle.toLocaleLowerCase() === "error")
errMsgText = await page.content();
if (null === errMsgText)
throw new Error("Unknown error page detected.");
if (undefined !== errMsgText)
throw new Error(errMsgText);
}
async function handleLoginPage<T>(context: AutomatedSignInContext<T>): Promise<void> {
const loginUrl = new URL("/IMS/Account/Login", context.config.issuer);
const { page } = context;
if (page.url().startsWith(loginUrl.toString())) {
await page.locator(testSelectors.imsEmail).fill(context.user.email);
await page.locator(testSelectors.imsPassword).fill(context.user.password);
const submit = page.locator(testSelectors.imsSubmit);
await submit.click();
}
// Check if there were any errors when performing sign-in
await checkErrorOnPage(page, "#errormessage");
}
async function handlePingLoginPage<T>(context: AutomatedSignInContext<T>): Promise<void> {
const { page } = context;
if (
context.config.authorizationEndpoint !== undefined && (
!page.url().startsWith(context.config.authorizationEndpoint) ||
-1 === page.url().indexOf("ims")
)
)
return;
const chooseAccountElement = page.getByText("Use another account");
const isChooseAccountInDom = await chooseAccountElement.isVisible();
if (isChooseAccountInDom) {
await chooseAccountElement.click();
}
await page.locator(testSelectors.pingEmail).fill(context.user.email);
await page.waitForSelector(testSelectors.pingAllowSubmit);
let allow = page.locator(testSelectors.pingAllowSubmit);
await allow.click();
// Cut out for federated sign-in
if (-1 !== page.url().indexOf("microsoftonline"))
return;
await page.locator(testSelectors.pingPassword).fill(context.user.password);
await page.waitForSelector(testSelectors.pingAllowSubmit);
allow = page.locator(testSelectors.pingAllowSubmit);
await allow.click();
const error = page.getByText(
"We didn't recognize the email address or password you entered. Please try again.",
);
const count = await error.count();
if (count) {
throw new Error(
"We didn't recognize the email address or password you entered. Please try again.",
);
}
// Check if there were any errors when performing sign-in
await checkErrorOnPage(page, ".ping-error");
}
// Bentley-specific federated login. This will get called if a redirect to a url including "microsoftonline".
async function handleFederatedSignin<T>(context: AutomatedSignInContext<T>): Promise<void> {
const { page } = context;
if (-1 === page.url().indexOf("microsoftonline"))
return;
// Wait for either msUserNameField or fedPassword to be visible
const msUserNameFieldPromise = page.waitForSelector(testSelectors.msUserNameField, { state: "visible" });
const fedPasswordPromise = page.waitForSelector(testSelectors.fedPassword, { state: "visible" });
await Promise.race([msUserNameFieldPromise, fedPasswordPromise]);
if (await checkSelectorExists(page, testSelectors.msUserNameField)) {
await page.locator(testSelectors.msUserNameField).fill(context.user.email);
const msSubmit = await page.waitForSelector(testSelectors.msSubmit);
await msSubmit.click();
// Checks for the error in username entered
await checkErrorOnPage(page, "#usernameError");
} else {
await page.locator(testSelectors.fedEmail).fill(context.user.email);
}
await page.locator(testSelectors.fedPassword).fill(context.user.password);
const submit = await page.waitForSelector(testSelectors.fedSubmit);
await submit.click();
// Need to check for invalid username/password directly after the submit button is pressed
let errorExists = false;
try {
errorExists = await checkSelectorExists(page, "#errorText");
} catch (err) {
// continue with navigation even if throws
}
if (errorExists)
await checkErrorOnPage(page, "#errorText");
// May need to accept an additional prompt.
if (
-1 !== page.url().indexOf("microsoftonline") &&
(await checkSelectorExists(page, testSelectors.msSubmit))
) {
const msSubmit = await page.waitForSelector(testSelectors.msSubmit);
await msSubmit.click();
}
}
async function handleConsentPage<T>(context: AutomatedSignInContext<T>): Promise<void> {
const { page } = context;
if ((await page.title()) === "localhost")
return; // we're done
const consentUrl = new URL("/consent", context.config.issuer);
if (page.url().startsWith(consentUrl.toString()))
await page.click("button[value=yes]");
const pageTitle = await page.title();
if (pageTitle === "Request for Approval") {
const pingSubmit = await page.waitForSelector(
testSelectors.pingAllowSubmit,
);
await pingSubmit.click();
} else if ((await page.title()) === "Permissions") {
// Another new consent page...
const acceptButton = await page.waitForSelector(
"xpath=(//button/span[text()='Accept'] | //div[contains(@class, 'ping-buttons')]/a[text()='Accept'])[1]",
);
// In EU there is a cookie consent banner covering the accept button, and it must be dismissed first
const cookieAcceptButton = page.locator("#onetrust-accept-btn-handler");
if (await cookieAcceptButton.isVisible()) {
await cookieAcceptButton.click();
}
await acceptButton.click();
}
}
async function checkSelectorExists(
page: Page,
selector: string,
): Promise<boolean> {
const element = await page.$(selector);
return !!element;
}
async function checkErrorOnPage(page: Page, selector: string): Promise<void> {
const errMsgElement = await page.$(selector);
if (errMsgElement) {
const errMsgText = await errMsgElement.textContent();
if (undefined !== errMsgText && null !== errMsgText)
throw new Error(errMsgText);
}
}
/** @internal use playwright to launch the default automation page, which is a chromium instance */
export async function launchDefaultAutomationPage(enableSlowNetworkConditions = false): Promise<Page> {
const launchOptions: LaunchOptions = {};
if (process.env.ODIC_SIGNIN_TOOL_EXTRA_LAUNCH_OPTS) {
const extraLaunchOpts = JSON.parse(process.env.ODIC_SIGNIN_TOOL_EXTRA_LAUNCH_OPTS);
Object.assign(launchOptions, extraLaunchOpts);
}
if (os.platform() === "linux") {
launchOptions.args = [...launchOptions.args ?? [], "--no-sandbox"];
}
const proxyUrl = process.env.HTTPS_PROXY;
if (proxyUrl) {
const proxyUrlObj = new URL(proxyUrl);
launchOptions.proxy = {
server: `${proxyUrlObj.protocol}//${proxyUrlObj.host}`,
username: proxyUrlObj.username,
password: proxyUrlObj.password,
};
}
let browser: Browser;
try {
const { chromium } = await import("@playwright/test");
browser = await chromium.launch(launchOptions);
} catch (err) {
/* eslint-disable no-console */
console.error("Original error:");
console.error(err);
/* eslint-enable no-console */
throw Error(
"Could not load @playwright/test. Do you have multiple playwright dependencies active? "
+ "If so, then you should provide your own playwright Page to automation APIs to avoid us "
+ "attempting to make our own by importing playwright",
);
}
let page: Page;
if (enableSlowNetworkConditions) {
const context = await browser.newContext();
page = await context.newPage();
const session = await context.newCDPSession(page);
await session.send("Network.emulateNetworkConditions", {
offline: false,
downloadThroughput: 200 * 1024,
uploadThroughput: 50 * 1024,
latency: 1000,
});
} else {
page = await browser.newPage();
}
return page;
}
async function cleanup(
page: Page,
signal: AbortSignal,
waitForCallbackUrl: Promise<any>,
doNotKillBrowser = false,
) {
if (signal.aborted)
await page.reload();
await waitForCallbackUrl;
await page.close();
const doKillBrowser = !doNotKillBrowser;
if (doKillBrowser) {
await page.context().close();
await page.context().browser()?.close();
}
}