diff --git a/packages/manager/.changeset/pr-10110-tests-1706208561568.md b/packages/manager/.changeset/pr-10110-tests-1706208561568.md new file mode 100644 index 00000000000..49a1409e99b --- /dev/null +++ b/packages/manager/.changeset/pr-10110-tests-1706208561568.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress tests for account switching from Parent to Child ([#10110](https://github.com/linode/manager/pull/10110)) diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts new file mode 100644 index 00000000000..60c6488ef30 --- /dev/null +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -0,0 +1,303 @@ +import { + accountFactory, + appTokenFactory, + profileFactory, +} from '@src/factories'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { DateTime } from 'luxon'; +import { + mockCreateChildAccountToken, + mockCreateChildAccountTokenError, + mockGetAccount, + mockGetChildAccounts, + mockGetChildAccountsError, + mockGetUser, +} from 'support/intercepts/account'; +import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { mockAllApiRequests } from 'support/intercepts/general'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { assertLocalStorageValue } from 'support/util/local-storage'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; + +/** + * Confirms expected username and company name are shown in user menu button and yields the button. + * + * @param username - Username to expect in user menu button. + * @param companyName - Company name to expect in user menu button. + * + * @returns Cypress chainable that yields the user menu button. + */ +const assertUserMenuButton = (username: string, companyName: string) => { + return ui.userMenuButton + .find() + .should('be.visible') + .within(() => { + cy.findByText(username).should('be.visible'); + cy.findByText(companyName).should('be.visible'); + }); +}; + +/** + * Confirms that expected authentication values are set in Local Storage. + * + * @param token - Authentication token value to assert. + * @param expiry - Authentication expiry value to assert. + * @param scopes - Authentication scope value to assert. + */ +const assertAuthLocalStorage = ( + token: string, + expiry: string, + scopes: string +) => { + assertLocalStorageValue('authentication/token', token); + assertLocalStorageValue('authentication/expire', expiry); + assertLocalStorageValue('authentication/scopes', scopes); +}; + +const mockParentAccount = accountFactory.build({ + company: 'Parent Company', +}); + +const mockParentProfile = profileFactory.build({ + username: randomLabel(), + user_type: 'parent', +}); + +const mockParentUser = accountUserFactory.build({ + username: mockParentProfile.username, + user_type: 'parent', +}); + +const mockChildAccount = accountFactory.build({ + company: 'Child Company', +}); + +const mockChildAccountToken = appTokenFactory.build({ + id: randomNumber(), + created: DateTime.now().toISO(), + expiry: DateTime.now().plus({ hours: 1 }).toISO(), + label: `${mockChildAccount.company}_proxy`, + scopes: '*', + token: randomString(32), + website: undefined, + thumbnail_url: undefined, +}); + +const mockErrorMessage = 'An unknown error has occurred.'; + +describe('Parent/Child account switching', () => { + /* + * Tests to confirm that Parent account users can switch to Child accounts as expected. + */ + describe('From Parent to Proxy', () => { + beforeEach(() => { + // @TODO M3-7554, M3-7559: Remove feature flag mocks after feature launch and clean-up. + mockAppendFeatureFlags({ + parentChildAccountAccess: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms that Parent account user can switch to Child account from Account Billing page. + * - Confirms that Child account information is displayed in user menu button after switch. + * - Confirms that Cloud updates local storage auth values upon account switch. + */ + it('can switch from Parent account to Child account from Billing page', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccounts([mockChildAccount]); + mockGetUser(mockParentUser); + + cy.visitWithLogin('/account/billing'); + + // Confirm that "Switch Account" button is present, then click it. + ui.button + .findByTitle('Switch Account') + .should('be.visible') + .should('be.enabled') + .click(); + + mockCreateChildAccountToken(mockChildAccount, mockChildAccountToken).as( + 'switchAccount' + ); + + ui.drawer + .findByTitle('Switch Account') + .should('be.visible') + .within(() => { + cy.findByText(mockChildAccount.company).should('be.visible').click(); + }); + + cy.wait('@switchAccount'); + + // Confirm that Cloud Manager updates local storage authentication values. + // Satisfy TypeScript using non-null assertions since we know what the mock data contains. + assertAuthLocalStorage( + mockChildAccountToken.token!, + mockChildAccountToken.expiry!, + mockChildAccountToken.scopes + ); + + // From this point forward, we will not have a valid test account token stored in local storage, + // so all non-intercepted API requests will respond with a 401 status code and we will get booted to login. + // We'll mitigate this by broadly mocking ALL API-v4 requests, then applying more specific mocks to the + // individual requests as needed. + mockAllApiRequests(); + mockGetLinodes([]); + mockGetRegions([]); + mockGetEvents([]); + mockGetNotifications([]); + + mockGetAccount(mockChildAccount); + mockGetProfile(mockParentProfile); + mockGetUser(mockParentUser); + + // TODO Remove the call to `cy.reload()` once Cloud Manager automatically updates itself upon account switching. + // TODO Add assertions for toast upon account switch. This might involve improving mocks for events/notifications. + cy.reload(); + + // Confirm expected username and company are shown in user menu button. + assertUserMenuButton( + mockParentProfile.username, + mockChildAccount.company + ); + }); + + /* + * - Confirms that Parent account user can switch to Child account using the user menu. + * - Confirms that Parent account information is initially displayed in user menu button. + * - Confirms that Child account information is displayed in user menu button after switch. + * - Confirms that Cloud updates local storage auth values upon account switch. + */ + it('can switch from Parent account to Child account using user menu', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccounts([mockChildAccount]); + mockGetUser(mockParentUser); + + cy.visitWithLogin('/'); + + // Confirm that Parent account username and company name are shown in user + // menu button, then click the button. + assertUserMenuButton( + mockParentProfile.username, + mockParentAccount.company + ).click(); + + // Click "Switch Account" button in user menu. + ui.userMenu + .find() + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Switch Account') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click mock company name in "Switch Account" drawer. + mockCreateChildAccountToken(mockChildAccount, mockChildAccountToken).as( + 'switchAccount' + ); + + ui.drawer + .findByTitle('Switch Account') + .should('be.visible') + .within(() => { + cy.findByText(mockChildAccount.company).should('be.visible').click(); + }); + + cy.wait('@switchAccount'); + + // Confirm that Cloud Manager updates local storage authentication values. + // Satisfy TypeScript using non-null assertions since we know what the mock data contains. + assertAuthLocalStorage( + mockChildAccountToken.token!, + mockChildAccountToken.expiry!, + mockChildAccountToken.scopes + ); + + // From this point forward, we will not have a valid test account token stored in local storage, + // so all non-intercepted API requests will respond with a 401 status code and we will get booted to login. + // We'll mitigate this by broadly mocking ALL API-v4 requests, then applying more specific mocks to the + // individual requests as needed. + mockAllApiRequests(); + mockGetLinodes([]); + mockGetRegions([]); + mockGetEvents([]); + mockGetNotifications([]); + mockGetAccount(mockChildAccount); + mockGetProfile(mockParentProfile); + mockGetUser(mockParentUser); + + // TODO Remove the call to `cy.reload()` once Cloud Manager automatically updates itself upon account switching. + // TODO Add assertions for toast upon account switch. This might involve improving mocks for events/notifications. + cy.reload(); + + // Confirm expected username and company are shown in user menu button. + assertUserMenuButton( + mockParentProfile.username, + mockChildAccount.company + ); + }); + }); + + /* + * Tests to confirm that Cloud handles account switching errors gracefully. + */ + describe('Error flows', () => { + /* + * - Confirms error handling upon failure to fetch child accounts. + * - Confirms "Try Again" button can be used to re-fetch child accounts successfully. + * - Confirms error handling upon failure to create child account token. + */ + it('handles account switching API errors', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccountsError('An unknown error has occurred', 500); + mockGetUser(mockParentUser); + + cy.visitWithLogin('/account/billing'); + ui.button + .findByTitle('Switch Account') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle('Switch Account') + .should('be.visible') + .within(() => { + // Confirm error message upon failure to fetch child accounts. + cy.findByText('Unable to load data.').should('be.visible'); + cy.findByText( + 'Try again or contact support if the issue persists.' + ).should('be.visible'); + + // Click "Try Again" button and mock a successful response. + mockGetChildAccounts([mockChildAccount]); + ui.button + .findByTitle('Try again') + .should('be.visible') + .should('be.enabled') + .click(); + + // Click child company and mock an error. + // Confirm that Cloud Manager displays the error message in the drawer. + mockCreateChildAccountTokenError(mockChildAccount, mockErrorMessage); + cy.findByText(mockChildAccount.company).click(); + cy.findByText(mockErrorMessage).should('be.visible'); + }); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 5eb29242169..27838a71068 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -20,6 +20,7 @@ import type { InvoiceItem, Payment, PaymentMethod, + Token, User, } from '@linode/api-v4'; @@ -503,6 +504,7 @@ export const mockCancelAccountError = ( /** * Intercepts GET request to fetch the account agreements and mocks the response. * + * @param agreements - Agreements with which to mock response. * * @returns Cypress chainable. */ @@ -516,9 +518,86 @@ export const mockGetAccountAgreements = ( ); }; +/** + * Intercepts GET request to fetch child accounts and mocks the response. + * + * @param childAccounts - Child account objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetChildAccounts = ( + childAccounts: Account[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('account/child-accounts*'), + paginateResponse(childAccounts) + ); +}; + +/** + * Intercepts GET request to fetch child accounts and mocks an error response. + * + * @param errorMessage - API error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetChildAccountsError = ( + errorMessage: string = 'An unknown error has occurred', + statusCode: number = 500 +) => { + return cy.intercept( + 'GET', + apiMatcher('account/child-accounts*'), + makeErrorResponse(errorMessage, statusCode) + ); +}; + +/** + * Intercepts POST request to create a child account token and mocks the response. + * + * @param childAccount - Child account for which to create a token. + * @param childAccountToken - Token object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateChildAccountToken = ( + childAccount: Account, + childAccountToken: Token +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`account/child-accounts/${childAccount.euuid}/token`), + makeResponse(childAccountToken) + ); +}; + +/** + * Intercepts POST request to create a child account token and mocks error response. + * + * @param childAccount - Child account for which to mock error response. + * @param errorMessage - API error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateChildAccountTokenError = ( + childAccount: Account, + errorMessage: string = 'An unknown error has occurred', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`account/child-accounts/${childAccount.euuid}/token`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts GET request to fetch the account logins and mocks the response. * + * @param accountLogins - Account login objects with which to mock response. * * @returns Cypress chainable. */ diff --git a/packages/manager/cypress/support/intercepts/events.ts b/packages/manager/cypress/support/intercepts/events.ts index 2ae3685bd5e..eea7b5fac12 100644 --- a/packages/manager/cypress/support/intercepts/events.ts +++ b/packages/manager/cypress/support/intercepts/events.ts @@ -2,7 +2,7 @@ * @file Mocks and intercepts related to notification and event handling. */ -import { Event } from '@linode/api-v4'; +import type { Event, Notification } from '@linode/api-v4'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; @@ -20,3 +20,20 @@ export const mockGetEvents = (events: Event[]): Cypress.Chainable => { paginateResponse(events) ); }; + +/** + * Intercepts GET request to fetch notifications and mocks response. + * + * @param notifications - Notifications with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetNotifications = ( + notifications: Notification[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('account/notifications*'), + paginateResponse(notifications) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/general.ts b/packages/manager/cypress/support/intercepts/general.ts index 3cd13083bc5..09cd74e0167 100644 --- a/packages/manager/cypress/support/intercepts/general.ts +++ b/packages/manager/cypress/support/intercepts/general.ts @@ -2,6 +2,25 @@ import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { makeResponse } from 'support/util/response'; +/** + * Intercepts all requests to Linode API v4 and mocks an HTTP response. + * + * This is useful to apply a baseline mock on all Linode API v4 requests, e.g. + * to prevent 401 responses. More fine-grained mocking can be set up with + * subsequent calls to other mock utils. + * + * @param body - Body data with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockAllApiRequests = ( + body: any = {}, + statusCode: number = 200 +) => { + return cy.intercept(apiMatcher('**/*'), makeResponse(body, statusCode)); +}; + /** * Intercepts GET request to given URL and mocks an HTTP 200 response with the given content. * diff --git a/packages/manager/cypress/support/util/local-storage.ts b/packages/manager/cypress/support/util/local-storage.ts new file mode 100644 index 00000000000..db748cb4105 --- /dev/null +++ b/packages/manager/cypress/support/util/local-storage.ts @@ -0,0 +1,30 @@ +/** + * @file Utilities to access and validate Local Storage data. + */ + +/** + * Asserts that a local storage item has a given value. + * + * @param key - Local storage item key. + * @param value - Local storage item value to assert. + */ +export const assertLocalStorageValue = (key: string, value: any) => { + cy.getAllLocalStorage().then((localStorageData: any) => { + const origin = Cypress.config('baseUrl'); + if (!origin) { + // This should never happen in practice. + throw new Error('Unable to retrieve Cypress base URL configuration'); + } + if (!localStorageData[origin]) { + throw new Error( + `Unable to retrieve local storage data from origin '${origin}'` + ); + } + if (!localStorageData[origin][key]) { + throw new Error( + `No local storage data exists for key '${key}' and origin '${origin}'` + ); + } + expect(localStorageData[origin][key]).equals(value); + }); +};