From 1fb1716cc3a817c27cbc626c4eac78b4ca316940 Mon Sep 17 00:00:00 2001 From: Hallie Swan <26949006+hallieswan@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:13:33 -0700 Subject: [PATCH] SWC-6531: refactor SWC e2e helpers to use exposed SRC methods and remove duplicate code --- e2e/auth.setup.ts | 3 +- e2e/helpers/http.ts | 214 +++++++++++++++--------------------- e2e/helpers/messages.ts | 55 ++++++--- e2e/helpers/srcFetch.ts | 161 --------------------------- e2e/helpers/testUser.ts | 40 ++++--- e2e/helpers/verification.ts | 36 ++---- e2e/teams.loggedin.spec.ts | 2 + 7 files changed, 165 insertions(+), 346 deletions(-) delete mode 100644 e2e/helpers/srcFetch.ts diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts index 9ae229a450..0e7288a705 100644 --- a/e2e/auth.setup.ts +++ b/e2e/auth.setup.ts @@ -6,7 +6,6 @@ // - locally: set in .env file, read by dotenv import { Page, expect, test as setup } from '@playwright/test' -import { getEndpoint } from './helpers/http' import { setLocalStorage } from './helpers/localStorage' import { cleanupTestUser, @@ -38,7 +37,7 @@ for (const { setup.slow() await setup.step('create test user', async () => { - userId = await createTestUser(getEndpoint(), user, getAdminPAT()) + userId = await createTestUser(user, getAdminPAT(), userPage) expect(userId).not.toBeUndefined() await setLocalStorage(userPage, localStorageKey, user.username) diff --git a/e2e/helpers/http.ts b/e2e/helpers/http.ts index 5dfff90152..d4eed02604 100644 --- a/e2e/helpers/http.ts +++ b/e2e/helpers/http.ts @@ -1,141 +1,107 @@ -import { createHmac } from 'crypto' -import { fetchWithExponentialTimeout } from './srcFetch' +import { Page, expect } from '@playwright/test' +import { navigateToHomepageIfPageHasNotBeenLoaded } from './localStorage' -export const BASE64_ENCODING = 'base64' -export const DEFAULT_GETDELETE_HEADERS = { - Accept: '*/*', - 'User-Agent': 'SynapseWebClient', -} -export const DEFAULT_POST_HEADERS = { - ...DEFAULT_GETDELETE_HEADERS, - Accept: 'application/json; charset=UTF-8', - 'Content-Type': 'application/json; charset=UTF-8', -} - -function generateDigitalSignature( - data: string, - base64EncodedSecretKey: string, -) { - const hash = createHmac( - 'sha1', - Buffer.from(base64EncodedSecretKey, BASE64_ENCODING), - ) - .update(data) - .digest(BASE64_ENCODING) - return hash -} - -function getDigitalSignature( - uri: string, - username: string, - base64EncodedSecretKey: string, -) { - const timestamp = new Date().toISOString() - const signature = generateDigitalSignature( - `${username}${uri}${timestamp}`, - base64EncodedSecretKey, - ) - return { - userId: username, - signatureTimestamp: timestamp, - signature: signature, - } -} - -export function getUserIdFromJwt(token: string) { - const payload = JSON.parse( - Buffer.from(token.split('.')[1], BASE64_ENCODING).toString(), - ) - return payload.sub +export enum BackendDestinationEnum { + REPO_ENDPOINT, + PORTAL_ENDPOINT, } -function updateHeaders( - headers: { [key: string]: string }, - uri: string, - accessToken?: string, - userName?: string, - apiKey?: string, -) { - return { - ...headers, - ...(accessToken && { - 'Access-Control-Request-Headers': 'authorization', - Authorization: `Bearer ${accessToken}`, - }), - ...(apiKey && userName && getDigitalSignature(uri, userName, apiKey)), - } +export async function waitForSrcEndpointConfig(page: Page) { + // window only available after page has initially loaded + await navigateToHomepageIfPageHasNotBeenLoaded(page) + // ensure that endpoint config is set, + // ...so API calls point to the correct stack + await expect(async () => { + const response = await page.evaluate('window.SRC.OVERRIDE_ENDPOINT_CONFIG') + expect(response).not.toBeUndefined() + }).toPass() } export async function doPost( - endpoint: string, - uri: string, - requestContent: string, - accessToken?: string, - userName?: string, - apiKey?: string, + page: Page, + url: string, + requestJsonObject: unknown, + accessToken: string | undefined, + endpoint: BackendDestinationEnum, + additionalOptions: RequestInit = {}, ) { - const url: RequestInfo = `${endpoint}${uri}` - const options: RequestInit = { - body: requestContent, - headers: updateHeaders( - DEFAULT_POST_HEADERS, - uri, + await waitForSrcEndpointConfig(page) + const response = await page.evaluate( + async ({ + url, + requestJsonObject, accessToken, - userName, - apiKey, - ), - method: 'POST', - mode: 'cors', - } - return await fetchWithExponentialTimeout(url, options) + endpoint, + additionalOptions, + }) => { + // @ts-expect-error: Cannot find name 'SRC' + const srcEndpoint = await SRC.SynapseEnums.BackendDestinationEnum[ + endpoint + ] + // @ts-expect-error: Cannot find name 'SRC' + return await SRC.HttpClient.doPost( + url, + requestJsonObject, + accessToken, + srcEndpoint, + additionalOptions, + ) + }, + { url, requestJsonObject, accessToken, endpoint, additionalOptions }, + ) + return response } export async function doGet( - endpoint: string, - uri: string, - accessToken?: string, - userName?: string, - apiKey?: string, + page: Page, + url: string, + accessToken: string | undefined, + endpoint: BackendDestinationEnum, + additionalOptions: RequestInit = {}, ) { - const url: RequestInfo = `${endpoint}${uri}` - const options: RequestInit = { - body: null, - headers: updateHeaders( - DEFAULT_GETDELETE_HEADERS, - uri, - accessToken, - userName, - apiKey, - ), - method: 'GET', - mode: 'cors', - } - return await fetchWithExponentialTimeout(url, options) + await waitForSrcEndpointConfig(page) + const response = await page.evaluate( + async ({ url, accessToken, endpoint, additionalOptions }) => { + // @ts-expect-error: Cannot find name 'SRC' + const srcEndpoint = await SRC.SynapseEnums.BackendDestinationEnum[ + endpoint + ] + // @ts-expect-error: Cannot find name 'SRC' + return await SRC.HttpClient.doGet( + url, + accessToken, + srcEndpoint, + additionalOptions, + ) + }, + { url, accessToken, endpoint, additionalOptions }, + ) + return response } export async function doDelete( - endpoint: string, - uri: string, - accessToken?: string, - userName?: string, - apiKey?: string, + page: Page, + url: string, + accessToken: string | undefined, + endpoint: BackendDestinationEnum, + additionalOptions: RequestInit = {}, ) { - const url: RequestInfo = `${endpoint}${uri}` - const options: RequestInit = { - body: null, - headers: updateHeaders( - DEFAULT_GETDELETE_HEADERS, - uri, - accessToken, - userName, - apiKey, - ), - method: 'DELETE', - mode: 'cors', - } - return await fetchWithExponentialTimeout(url, options) -} - -export function getEndpoint() { - return 'https://repo-dev.dev.sagebase.org' + await waitForSrcEndpointConfig(page) + const response = await page.evaluate( + async ({ url, accessToken, endpoint, additionalOptions }) => { + // @ts-expect-error: Cannot find name 'SRC' + const srcEndpoint = await SRC.SynapseEnums.BackendDestinationEnum[ + endpoint + ] + // @ts-expect-error: Cannot find name 'SRC' + return await SRC.HttpClient.doDelete( + url, + accessToken, + srcEndpoint, + additionalOptions, + ) + }, + { url, accessToken, endpoint, additionalOptions }, + ) + return response } diff --git a/e2e/helpers/messages.ts b/e2e/helpers/messages.ts index eb73b1d232..4e607943f5 100644 --- a/e2e/helpers/messages.ts +++ b/e2e/helpers/messages.ts @@ -1,4 +1,5 @@ -import { doDelete, doGet, getEndpoint } from './http' +import { Page } from '@playwright/test' +import { BackendDestinationEnum, doDelete, doGet } from './http' import { FileHandle, MessageBundle, @@ -7,49 +8,70 @@ import { } from './types' // Retrieves the current authenticated user's outbox. -export async function getUserOutbox(accessToken: string) { +export async function getUserOutbox(accessToken: string, page: Page) { return (await doGet( - getEndpoint(), + page, '/repo/v1/message/outbox', accessToken, + BackendDestinationEnum.REPO_ENDPOINT, )) as PaginatedResults } // Retrieves the current authenticated user's inbox. // It may take several seconds for a message to appear in the inbox after creation. -async function getUserInbox(accessToken: string) { +async function getUserInbox(accessToken: string, page: Page) { return (await doGet( - getEndpoint(), + page, '/repo/v1/message/inbox', accessToken, + BackendDestinationEnum.REPO_ENDPOINT, )) as PaginatedResults } // Deletes a message. Only accessible to administrators. -async function deleteUserMessage(accessToken: string, messageId: string) { +async function deleteUserMessage( + accessToken: string, + messageId: string, + page: Page, +) { await doDelete( - getEndpoint(), + page, `/repo/v1/admin/message/${messageId}`, accessToken, + BackendDestinationEnum.REPO_ENDPOINT, ) return messageId } // Get a FileHandle using its ID. // Note: Only the user that created the FileHandle can access it directly. -async function getFileHandle(accessToken: string, handleId: string) { +async function getFileHandle( + accessToken: string, + handleId: string, + page: Page, +) { return (await doGet( - getEndpoint(), + page, `/file/v1/fileHandle/${handleId}`, accessToken, + BackendDestinationEnum.REPO_ENDPOINT, )) as FileHandle } // Delete a FileHandle using its ID. // Note: Only the user that created the FileHandle can delete it. // Also, a FileHandle cannot be deleted if it is associated with a FileEntity or WikiPage -async function deleteFileHandle(accessToken: string, handleId: string) { - await doDelete(getEndpoint(), `/file/v1/fileHandle/${handleId}`, accessToken) +async function deleteFileHandle( + accessToken: string, + handleId: string, + page: Page, +) { + await doDelete( + page, + `/file/v1/fileHandle/${handleId}`, + accessToken, + BackendDestinationEnum.REPO_ENDPOINT, + ) return handleId } @@ -65,8 +87,9 @@ export async function deleteUserOutboxMessageAndAssociatedFile( subject: string, userAccessToken: string, adminAccessToken: string, + page: Page, ) { - const messages = (await getUserOutbox(userAccessToken)).results.filter( + const messages = (await getUserOutbox(userAccessToken, page)).results.filter( message => message.subject === subject && arraysAreEqual(recipients.sort(), message.recipients.sort()), @@ -79,8 +102,8 @@ export async function deleteUserOutboxMessageAndAssociatedFile( } const message = messages[0] - await deleteUserMessage(adminAccessToken, message.id) - await deleteFileHandle(userAccessToken, message.fileHandleId) + await deleteUserMessage(adminAccessToken, message.id, page) + await deleteFileHandle(userAccessToken, message.fileHandleId, page) } export async function deleteTeamInvitationMessage( @@ -89,12 +112,14 @@ export async function deleteTeamInvitationMessage( teamName: string, inviterAccessToken: string, adminAccessToken: string, + page: Page, ) { await deleteUserOutboxMessageAndAssociatedFile( recipients, `${inviterUserName} has invited you to join the ${teamName} team`, inviterAccessToken, adminAccessToken, + page, ) } @@ -102,11 +127,13 @@ export async function deleteTeamInviteAcceptanceMessage( recipients: string[], accepterAccessToken: string, adminAccessToken: string, + page: Page, ) { await deleteUserOutboxMessageAndAssociatedFile( recipients, 'New Member Has Joined the Team', accepterAccessToken, adminAccessToken, + page, ) } diff --git a/e2e/helpers/srcFetch.ts b/e2e/helpers/srcFetch.ts deleted file mode 100644 index 9db54c43e2..0000000000 --- a/e2e/helpers/srcFetch.ts +++ /dev/null @@ -1,161 +0,0 @@ -export const NETWORK_UNAVAILABLE_MESSAGE = - 'This site cannot be reached. Either a connection is unavailable, or your network administrator has blocked you from accessing this site.' - -/** - * Error message returned by the Synapse Backend - */ -export type SynapseError = { - reason: string -} - -export enum ErrorResponseCode { - /* The user's password must be reset via email. */ - PASSWORD_RESET_VIA_EMAIL_REQUIRED = 'PASSWORD_RESET_VIA_EMAIL_REQUIRED', - /* The user has not passed the certification process. */ - USER_CERTIFICATION_REQUIRED = 'USER_CERTIFICATION_REQUIRED', - /* At least one of the columns listed in a FacetColumnRequest is not facet-able according to the table's schema. */ - INVALID_TABLE_QUERY_FACET_COLUMN_REQUEST = 'INVALID_TABLE_QUERY_FACET_COLUMN_REQUEST', - /* The OAuth Client is not verified. */ - OAUTH_CLIENT_NOT_VERIFIED = 'OAUTH_CLIENT_NOT_VERIFIED', - /* Two-factor authentication is required. */ - TWO_FA_REQUIRED = 'TWO_FA_REQUIRED', -} - -/** - * https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/ErrorResponse.html - */ -export type ErrorResponse = { - concreteType: 'org.sagebionetworks.repo.model.ErrorResponse' - reason: string - errorCode?: ErrorResponseCode -} - -export type TwoFactorAuthErrorResponse = { - concreteType: 'org.sagebionetworks.repo.model.auth.TwoFactorAuthErrorResponse' - /* The id of the user that attempted to authenticate.*/ - userId: number - /* Token included when two-factor authentication is required. If present the client will need to include this token as part of the TwoFactorAuthLoginRequest.*/ - twoFaToken: string - /* The reason for the error*/ - reason: string - /* A code to be used by clients to handle the error.*/ - errorCode: ErrorResponseCode.TWO_FA_REQUIRED -} - -/** - * Error message returned by the Synapse backend joined with the - * HTTP status code. - */ -export class SynapseClientError extends Error { - public status: number - public reason: string - public errorResponse?: - | SynapseError - | ErrorResponse - | TwoFactorAuthErrorResponse - public url: string - - constructor( - status: number, - reason: string, - url: string, - errorResponse?: SynapseError | ErrorResponse | TwoFactorAuthErrorResponse, - ) { - super(reason) - this.status = status - this.reason = reason - this.url = url - this.errorResponse = errorResponse - // See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget - Object.setPrototypeOf(this, new.target.prototype) - } -} - -/** - * Waits t number of milliseconds - * - * @export - * @param {number} t milliseconds - * @returns after t milliseconds - */ -export function delay(t: number) { - return new Promise(resolve => { - setTimeout(resolve.bind(null, {}), t) - }) -} - -/* - 0 - no internet connection - 429 - Too Many Requests - 502 - Bad Gateway - 503 - Service Unavailable - 504 - Gateway Timeout -*/ -const RETRY_STATUS_CODES = [0, 429, 502, 503, 504] -const MAX_RETRY_STATUS_CODES = [502, 503] -const MAX_RETRY = 3 -/** - * Fetches data, retrying if the HTTP status code indicates that it could be retried. Contains custom logic for - * handling errors returned by the Synapse backend. - * @throws SynapseClientError - */ -export const fetchWithExponentialTimeout = async ( - url: RequestInfo, - options: RequestInit, - delayMs = 1000, -): Promise => { - let response - try { - response = await fetch(url, options) - } catch (err) { - console.error(err) - throw new SynapseClientError(0, NETWORK_UNAVAILABLE_MESSAGE, url.toString()) - } - - let numOfTry = 1 - while (response.status && RETRY_STATUS_CODES.includes(response.status)) { - await delay(delayMs) - // Exponential backoff if we re-fetch - delayMs = delayMs * 2 - response = await fetch(url, options) - if (MAX_RETRY_STATUS_CODES.includes(response.status)) { - numOfTry++ - if (numOfTry == MAX_RETRY) { - break - } - } - } - - const contentType = response.headers.get('Content-Type') - const responseBody = await response.text() - let responseObject: TResponse | SynapseError | string = responseBody - try { - // try to parse it as json - if (contentType && contentType.includes('application/json')) { - responseObject = JSON.parse(responseBody) as TResponse | SynapseError - } - } catch (error) { - console.warn('Failed to parse response as JSON', responseBody) - } - - if (response.ok) { - return responseObject as TResponse - } else if ( - responseObject !== null && - typeof responseObject === 'object' && - 'reason' in responseObject - ) { - throw new SynapseClientError( - response.status, - responseObject.reason, - url.toString(), - responseObject, - ) - } else { - throw new SynapseClientError( - response.status, - JSON.stringify(responseObject), - url.toString(), - ) - } -} diff --git a/e2e/helpers/testUser.ts b/e2e/helpers/testUser.ts index 233d6e85ab..8bb2bf67ff 100644 --- a/e2e/helpers/testUser.ts +++ b/e2e/helpers/testUser.ts @@ -1,9 +1,10 @@ import { Page, expect } from '@playwright/test' -import { doDelete, doPost, getEndpoint, getUserIdFromJwt } from './http' +import { BackendDestinationEnum, doDelete, doPost } from './http' import { getLocalStorage } from './localStorage' import { LoginResponse, TestUser } from './types' import { deleteVerificationSubmissionIfExists } from './verification' +const BASE64_ENCODING = 'base64' const TEST_USER_URI = '/repo/v1/admin/user' export function getAdminPAT() { @@ -12,34 +13,39 @@ export function getAdminPAT() { return adminPAT } +function getUserIdFromJwt(token: string) { + const payload = JSON.parse( + Buffer.from(token.split('.')[1], BASE64_ENCODING).toString(), + ) + return payload.sub +} + export async function createTestUser( - endpoint: string, testUser: TestUser, - accessToken?: string, - adminUserName?: string, - adminApiKey?: string, + accessToken: string, + page: Page, ) { - const content = JSON.stringify(testUser) const responseObject = (await doPost( - endpoint, + page, TEST_USER_URI, - content, + testUser, accessToken, - adminUserName, - adminApiKey, + BackendDestinationEnum.REPO_ENDPOINT, )) as LoginResponse return getUserIdFromJwt(responseObject.accessToken) } export async function deleteTestUser( - endpoint: string, testUserId: string, - accessToken?: string, - adminUserName?: string, - adminApiKey?: string, + accessToken: string, + page: Page, ) { - const uri = `${TEST_USER_URI}/${testUserId}` - await doDelete(endpoint, uri, accessToken, adminUserName, adminApiKey) + await doDelete( + page, + `${TEST_USER_URI}/${testUserId}`, + accessToken, + BackendDestinationEnum.REPO_ENDPOINT, + ) return testUserId } @@ -49,7 +55,7 @@ export async function cleanupTestUser(testUserId: string, userPage: Page) { getAdminPAT(), userPage, ) - const result = await deleteTestUser(getEndpoint(), testUserId, getAdminPAT()) + const result = await deleteTestUser(testUserId, getAdminPAT(), userPage) expect(result).toEqual(testUserId) } diff --git a/e2e/helpers/verification.ts b/e2e/helpers/verification.ts index 22e36d34ed..23d734a212 100644 --- a/e2e/helpers/verification.ts +++ b/e2e/helpers/verification.ts @@ -1,16 +1,9 @@ -import { Page, expect } from '@playwright/test' -import { navigateToHomepageIfPageHasNotBeenLoaded } from './localStorage' - -export async function waitForSrcEndpointConfig(page: Page) { - // window only available after page has initially loaded - await navigateToHomepageIfPageHasNotBeenLoaded(page) - // ensure that endpoint config is set, - // ...so API calls point to the correct stack - await expect(async () => { - const response = await page.evaluate('window.SRC.OVERRIDE_ENDPOINT_CONFIG') - expect(response).not.toBeUndefined() - }).toPass() -} +import { Page } from '@playwright/test' +import { + BackendDestinationEnum, + doDelete, + waitForSrcEndpointConfig, +} from './http' export async function getVerificationSubmissionId( userId: string, @@ -37,21 +30,8 @@ export async function deleteVerificationSubmissionById( accessToken: string, page: Page, ) { - await waitForSrcEndpointConfig(page) - await page.evaluate( - async ({ verificationSubmissionId, accessToken }) => { - // @ts-expect-error: Cannot find name 'SRC' - const endpoint = await SRC.SynapseEnums.BackendDestinationEnum - .REPO_ENDPOINT - // @ts-expect-error: Cannot find name 'SRC' - return await SRC.HttpClient.doDelete( - `/repo/v1/verificationSubmission/${verificationSubmissionId}`, - accessToken, - endpoint, - ) - }, - { verificationSubmissionId, accessToken }, - ) + const url = `/repo/v1/verificationSubmission/${verificationSubmissionId}` + await doDelete(page, url, accessToken, BackendDestinationEnum.REPO_ENDPOINT) } export async function deleteVerificationSubmissionIfExists( diff --git a/e2e/teams.loggedin.spec.ts b/e2e/teams.loggedin.spec.ts index c58823a32c..1349c6a212 100644 --- a/e2e/teams.loggedin.spec.ts +++ b/e2e/teams.loggedin.spec.ts @@ -198,6 +198,7 @@ test.describe('Teams', () => { TEAM_NAME, userAccessToken, adminPAT, + userPage, ) // delete team acceptance: validated user -> user @@ -205,6 +206,7 @@ test.describe('Teams', () => { [userUserId!], validatedUserAccessToken, adminPAT, + validatedUserPage, ) // close pages