From ad41129335fa9b66f3e9f6db1417a0cacadf2134 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 16 Oct 2024 10:55:52 -0400 Subject: [PATCH] Fix: 726 changepassword cypress fix + firebase emulator (#751) * firebase emulator added * changepassword e2e tests fixed --- .github/workflows/web-app-deployer.yml | 6 +- web-app/README.md | 4 + web-app/cypress.config.ts | 10 ++ web-app/cypress/e2e/addFeedForm.cy.ts | 25 ++--- web-app/cypress/e2e/changepassword.cy.ts | 96 ++++++------------- web-app/cypress/support/commands.ts | 37 ++++++- web-app/cypress/support/index.ts | 48 ++++++---- web-app/firebase.json | 3 + web-app/package.json | 4 +- web-app/src/app/components/Header.tsx | 7 +- .../src/app/components/LogoutConfirmModal.tsx | 7 +- web-app/src/app/screens/Account.tsx | 1 + web-app/src/app/screens/SignIn.tsx | 2 + web-app/src/app/store/saga/auth-saga.ts | 16 ++-- web-app/src/firebase.ts | 5 + web-app/yarn.lock | 19 +++- 16 files changed, 176 insertions(+), 114 deletions(-) diff --git a/.github/workflows/web-app-deployer.yml b/.github/workflows/web-app-deployer.yml index 584912154..1cbc7cb44 100644 --- a/.github/workflows/web-app-deployer.yml +++ b/.github/workflows/web-app-deployer.yml @@ -106,8 +106,10 @@ jobs: - name: Cypress test uses: cypress-io/github-action@v6 with: - start: yarn start:test - wait-on: "npx wait-on --timeout 120000 http://127.0.0.1:3000" + start: | + yarn start:test + npx firebase emulators:start --only auth --project mobility-feeds-dev + wait-on: npx wait-on --timeout 120000 http://127.0.0.1:3000 http://127.0.0.1:9099 working-directory: web-app - uses: actions/upload-artifact@v4 diff --git a/web-app/README.md b/web-app/README.md index 8f9832ab6..f7c1e013a 100644 --- a/web-app/README.md +++ b/web-app/README.md @@ -82,6 +82,10 @@ npx firebase hosting:channel:deploy {channel_name} Component and E2E tests are executed with [Cypress](https://docs.cypress.io/). Cypress tests are located in the cypress folder. Cypress useful commands: +- Run the firebase emulator in a separate terminal +``` +yarn run firebase:auth:emulator:dev +``` - Run local headless tests ``` yarn start:dev diff --git a/web-app/cypress.config.ts b/web-app/cypress.config.ts index a7230223b..f313a6d6f 100644 --- a/web-app/cypress.config.ts +++ b/web-app/cypress.config.ts @@ -1,6 +1,16 @@ import { defineConfig } from 'cypress'; +import * as dotenv from 'dotenv'; +const localEnv = dotenv.config({ path: './src/.env.dev' }).parsed; +const ciEnv = dotenv.config({ path: './src/.env.test' }).parsed; + +const isEnvEmpty = (obj) => { + return Object.keys(obj).length === 0; +}; + +const chosenEnv = isEnvEmpty(localEnv) ? ciEnv : localEnv; export default defineConfig({ + env: chosenEnv, e2e: { baseUrl: 'http://localhost:3000', }, diff --git a/web-app/cypress/e2e/addFeedForm.cy.ts b/web-app/cypress/e2e/addFeedForm.cy.ts index 47574b461..eacf1d3a3 100644 --- a/web-app/cypress/e2e/addFeedForm.cy.ts +++ b/web-app/cypress/e2e/addFeedForm.cy.ts @@ -1,21 +1,26 @@ describe('Add Feed Form', () => { beforeEach(() => { - cy.viewport(1280, 720); - cy.visit('/'); - cy.get('[data-testid="home-title"]').should('exist'); - cy.visit('/contribute'); - cy.injectAuthenticatedUser(); cy.intercept('POST', '/writeToSheet', { statusCode: 200, body: { result: { message: 'Data written to the new sheet successfully!' }, }, - }).as('writeToSheet'); + }); + cy.visit('/'); + cy.get('[data-testid="home-title"]').should('exist'); + cy.createNewUserAndSignIn('cypressTestUser@mobilitydata.org', 'BigCoolPassword123!'); + + cy.get('[data-cy="accountHeader"]').should('exist'); // assures that the user is signed in + cy.visit('/contribute'); + // Assures that the firebase remote config has loaded for the first test + // Optimizations can be made to make the first test run faster + // Long timeout is to assure no flakiness + cy.get('[data-cy=isOfficialProducerYes]', { timeout: 25000 }).should('exist'); }); describe('Success Flows', () => { it('should submit a new gtfs scheduled feed as official producer', () => { - cy.get('[data-cy=isOfficialProducerYes]', { timeout: 6000 }).click({ + cy.get('[data-cy=isOfficialProducerYes]').click({ force: true, }); cy.get('[data-cy=feedLink] input').type('https://example.com/feed', { @@ -49,7 +54,7 @@ describe('Add Feed Form', () => { cy.url().should('include', '/contribute?step=2'); // step 2 cy.get('[data-cy=serviceAlertFeed] input').type( - 'https://example.com/feed/realtime' + 'https://example.com/feed/realtime', ); cy.get('[data-cy=secondStepRtSubmit]').click(); cy.url().should('include', '/contribute?step=3'); @@ -74,9 +79,7 @@ describe('Add Feed Form', () => { cy.get('[data-cy=feedLink] input').type('https://example.com/feed', { force: true, }); - cy.get('[data-cy=oldFeedLink] input').type( - 'https://example.com/feedOld' - ); + cy.get('[data-cy=oldFeedLink] input').type('https://example.com/feedOld'); cy.get('[data-cy=submitFirstStep]').click(); // Step 2 cy.get('[data-cy=secondStepSubmit]').click(); diff --git a/web-app/cypress/e2e/changepassword.cy.ts b/web-app/cypress/e2e/changepassword.cy.ts index bfb4d5cf3..33dd24fd3 100644 --- a/web-app/cypress/e2e/changepassword.cy.ts +++ b/web-app/cypress/e2e/changepassword.cy.ts @@ -1,99 +1,61 @@ -const email = Cypress.env('email'); -const currentPassword = Cypress.env('currentPassword'); -const newPassword = Cypress.env('currentPassword') + 'TEST'; - -let beforeEachFailed = false; - -describe.skip('Change Password Screen', () => { - before(() => {}); +const currentPassword = 'IloveOrangeCones123!'; +const newPassword = currentPassword + 'TEST'; +const email = 'cypressTestUser@mobilitydata.org'; +describe('Change Password Screen', () => { beforeEach(() => { - beforeEachFailed = false; - // As per issue #458 the beforeEach of this test file sometimes fail. - // Instead of failing the tests in that case issue a warning. - // This should be removed once the issue is resolved. - try { - // Visit the login page and login - cy.visit('/sign-in'); - cy.get('input[id="email"]').clear().type(email); - cy.get('input[id="password"]').clear().type(currentPassword); - cy.get('button[type="submit"]').click(); - // Wait for the user to be redirected to the home page - cy.location('pathname').should('eq', '/account', { timeout: 30000 }); - // Visit the change password page - cy.visit('/change-password'); - } catch (error) { - beforeEachFailed = true; - cy.log(`Warning: ${error.message}`); - } + cy.visit('/'); + cy.get('[data-testid="home-title"]').should('exist'); + cy.createNewUserAndSignIn(email, currentPassword); + cy.get('[data-cy="accountHeader"]').should('exist'); // assures that the user is signed in + cy.visit('/change-password'); }); it('should render components', () => { - if (beforeEachFailed) { - cy.log('Skipping test due to beforeEach failure'); - return; - } - // Check that the current password field exists cy.get('input[id="currentPassword"]').should('exist'); - - // Check that the new password field exists cy.get('input[id="newPassword"]').should('exist'); - - // Check that the confirm new password field exists cy.get('input[id="confirmNewPassword"]').should('exist'); }); it('should show error when current password is incorrect', () => { - if (beforeEachFailed) { - cy.log('Skipping test due to beforeEach failure'); - return; - } - // Type the wrong current password cy.get('input[id="currentPassword"]').type('wrong'); - - // Type the new password cy.get('input[id="newPassword"]').type(newPassword); - - // Confirm the new password cy.get('input[id="confirmNewPassword"]').type(newPassword); - - // Submit the form cy.get('button[type="submit"]').click(); - - // Check that the error message is displayed cy.contains( 'The password is invalid or the user does not have a password. (auth/wrong-password).', ).should('exist'); }); it('should change password', () => { - if (beforeEachFailed) { - cy.log('Skipping test due to beforeEach failure'); - return; - } - // Type the current password + cy.intercept('POST', '/retrieveUserInformation', { + statusCode: 200, + body: { + result: { + uid: 'ep4EwJvgNhfEER152EfzLSI0MBG2', + isRegisteredToReceiveAPIAnnouncements: false, + organization: '', + fullName: 'Alessandro', + registrationCompletionTime: '2024-09-24T15:34:55.381Z', + }, + }, + }); cy.get('input[id="currentPassword"]').type(currentPassword); - - // Type the new password cy.get('input[id="newPassword"]').type(newPassword); - - // Confirm the new password cy.get('input[id="confirmNewPassword"]').type(newPassword); - - // Submit the form cy.get('button[type="submit"]').click(); - // Check that the password was changed successfully cy.contains('Change Password Succeeded').should('exist'); cy.get('[cy-data="goToAccount"]').click(); cy.location('pathname').should('eq', '/account'); - // Reset the password back to the original password - cy.visit('/change-password'); - cy.get('input[id="currentPassword"]').type(newPassword); - cy.get('input[id="newPassword"]').type(currentPassword); - cy.get('input[id="confirmNewPassword"]').type(currentPassword); - cy.get('button[type="submit"]').click(); - cy.contains('Change Password Succeeded').should('exist'); + // logout + cy.get('[data-cy="signOutButton"]').click(); + cy.get('[data-cy="confirmSignOutButton"]').should('exist').click(); + cy.visit('/sign-in'); + cy.get('[data-cy="signInEmailInput"]').type(email); + cy.get('[data-cy="signInPasswordInput"]').type(newPassword); + cy.get('[data-testid="signin"]').click(); + cy.location('pathname').should('eq', '/account'); }); }); diff --git a/web-app/cypress/support/commands.ts b/web-app/cypress/support/commands.ts index 914788491..a7125a9ef 100644 --- a/web-app/cypress/support/commands.ts +++ b/web-app/cypress/support/commands.ts @@ -37,14 +37,31 @@ // } // } -Cypress.Commands.add('injectAuthenticatedUser', () => { +import firebase from 'firebase/compat/app'; +import 'firebase/compat/remote-config'; +import 'firebase/compat/auth'; + +const firebaseConfig = { + apiKey: Cypress.env('REACT_APP_FIREBASE_API_KEY'), + authDomain: Cypress.env('REACT_APP_FIREBASE_AUTH_DOMAIN'), + projectId: Cypress.env('REACT_APP_FIREBASE_PROJECT_ID'), + storageBucket: Cypress.env('REACT_APP_FIREBASE_STORAGE_BUCKET'), + messagingSenderId: Cypress.env('REACT_APP_FIREBASE_MESSAGING_SENDER_ID'), + appId: Cypress.env('REACT_APP_FIREBASE_APP_ID'), +}; + +const app = firebase.initializeApp(firebaseConfig); + +app.auth().useEmulator('http://localhost:9099/'); + +Cypress.Commands.add('injectAuthenticatedUser', (email: string) => { cy.window() .its('store') .invoke('dispatch', { type: 'userProfile/loginSuccess', payload: { fullName: 'Valery', - email: 'testuser@gmail.com', + email: email, isRegistered: true, isEmailVerified: true, organization: '', @@ -68,3 +85,19 @@ Cypress.Commands.add( Cypress.Commands.add('assetMuiError', (elementKey: string) => { cy.get(elementKey).should('have.class', 'Mui-error'); }); + +Cypress.Commands.add( + 'createNewUserAndSignIn', + (email: string, password: string) => { + const auth = app.auth(); + cy.then(async () => { + await fetch( + 'http://localhost:9099/emulator/v1/projects/mobility-feeds-dev/accounts', + { method: 'DELETE' }, + ); + await auth.createUserWithEmailAndPassword(email, password); + await auth.signInWithEmailAndPassword(email, password); + cy.injectAuthenticatedUser(email); + }); + }, +); diff --git a/web-app/cypress/support/index.ts b/web-app/cypress/support/index.ts index aed0eb51e..c33954800 100644 --- a/web-app/cypress/support/index.ts +++ b/web-app/cypress/support/index.ts @@ -1,26 +1,34 @@ import './commands'; declare global { - namespace Cypress { - interface Chainable { - /** - * Dispatches loginSuccess action to the store with the given user profile - * Simulates the login of a user - */ - injectAuthenticatedUser(): void; + namespace Cypress { + interface Chainable { + /** + * Dispatches loginSuccess action to the store with the given user profile + * Simulates the login of a user + * @param email email of the user to inject + */ + injectAuthenticatedUser(email: string): void; - /** - * Selects a dropdown item in a MUI dropdown - * @param elementKey selector of the dropdown element - * @param dropDownDataValue data value of the dropdown item to select - */ - muiDropdownSelect(elementKey: string, dropDownDataValue: string): void; + /** + * Selects a dropdown item in a MUI dropdown + * @param elementKey selector of the dropdown element + * @param dropDownDataValue data value of the dropdown item to select + */ + muiDropdownSelect(elementKey: string, dropDownDataValue: string): void; - /** - * Tests if an element has the MUI error - * @param elementKey selector of the element to assert the MUI error class - */ - assetMuiError(elementKey: string): void; - } + /** + * Tests if an element has the MUI error + * @param elementKey selector of the element to assert the MUI error class + */ + assetMuiError(elementKey: string): void; + + /** + * Wipes the firebase auth state, creates a user and signs in. Injects the user into the store + * @param email email of the new user + * @param password password of the new user + */ + createNewUserAndSignIn(email: string, password: string): void; } - } \ No newline at end of file + } +} diff --git a/web-app/firebase.json b/web-app/firebase.json index 320248ddf..f84b2438d 100644 --- a/web-app/firebase.json +++ b/web-app/firebase.json @@ -18,6 +18,9 @@ "storage": { "port": 9199 }, + "auth": { + "port": 9099 + }, "ui": { "enabled": true }, diff --git a/web-app/package.json b/web-app/package.json index d1d0463a2..2502747cc 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -69,6 +69,7 @@ "lint:fix": "eslint 'src/app/**/*.{js,ts,tsx}' --fix", "cypress:run": "cypress run", "cypress:open": "cypress open", + "firebase:auth:emulator:dev": "firebase emulators:start --only auth --project mobility-feeds-dev", "generate:api-types:output": "npx openapi-typescript ../docs/DatabaseCatalogAPI.yaml -o $OUTPUT_PATH_TYPES && eslint $OUTPUT_PATH_TYPES --fix", "generate:api-types": "OUTPUT_PATH_TYPES=src/app/services/feeds/types.ts npm run generate:api-types:output" }, @@ -103,7 +104,7 @@ "@types/jest": "^29.5.12", "@types/material-ui": "^0.21.12", "@types/mui-datatables": "^4.3.12", - "@types/node": "^20.8.10", + "@types/node": "^22.7.4", "@types/react": "^18.2.25", "@types/react-dom": "^18.2.7", "@types/react-google-recaptcha": "^2.1.8", @@ -114,6 +115,7 @@ "@typescript-eslint/parser": "^6.7.0", "babel-jest": "^29.7.0", "cypress": "^13.2.0", + "dotenv": "^16.4.5", "env-cmd": "^10.1.0", "eslint": "^8.49.0", "eslint-config-prettier": "^9.0.0", diff --git a/web-app/src/app/components/Header.tsx b/web-app/src/app/components/Header.tsx index 6a5d28e90..b2f390d6e 100644 --- a/web-app/src/app/components/Header.tsx +++ b/web-app/src/app/components/Header.tsx @@ -135,7 +135,12 @@ const DrawerContent: React.FC<{ defaultExpandIcon={} sx={{ textAlign: 'left' }} > - + Cancel - diff --git a/web-app/src/app/screens/Account.tsx b/web-app/src/app/screens/Account.tsx index 7555f1f18..2d8c8b3f7 100644 --- a/web-app/src/app/screens/Account.tsx +++ b/web-app/src/app/screens/Account.tsx @@ -379,6 +379,7 @@ export default function APIAccount(): React.ReactElement { sx={{ m: 1, mb: 0 }} startIcon={} onClick={handleSignOutClick} + data-cy='signOutButton' > {t('common:signOut')} diff --git a/web-app/src/app/screens/SignIn.tsx b/web-app/src/app/screens/SignIn.tsx index 294838a21..f4da44822 100644 --- a/web-app/src/app/screens/SignIn.tsx +++ b/web-app/src/app/screens/SignIn.tsx @@ -200,6 +200,7 @@ export default function SignIn(): React.ReactElement { onChange={formik.handleChange} value={formik.values.email} error={formik.errors.email != null} + data-cy='signInEmailInput' /> {formik.errors.email != null ? ( {formik.errors.email} @@ -216,6 +217,7 @@ export default function SignIn(): React.ReactElement { onChange={formik.handleChange} value={formik.values.password} error={formik.errors.password != null} + data-cy='signInPasswordInput' InputProps={{ endAdornment: ( diff --git a/web-app/src/app/store/saga/auth-saga.ts b/web-app/src/app/store/saga/auth-saga.ts index fd1cc3cd1..8859ce766 100644 --- a/web-app/src/app/store/saga/auth-saga.ts +++ b/web-app/src/app/store/saga/auth-saga.ts @@ -124,15 +124,15 @@ function* changePasswordSaga({ oldPassword: string; newPassword: string; }>): Generator { - const user = app.auth().currentUser; - if (user === null) { - throw new Error('User not found'); - } - if (user.email === null) { - throw new Error('User email not found'); - } - const credential = EmailAuthProvider.credential(user.email, oldPassword); try { + const user = app.auth().currentUser; + if (user === null) { + throw new Error('User not found'); + } + if (user.email === null) { + throw new Error('User email not found'); + } + const credential = EmailAuthProvider.credential(user.email, oldPassword); yield reauthenticateWithCredential(user, credential); yield user.updatePassword(newPassword); yield put(changePasswordSuccess()); diff --git a/web-app/src/firebase.ts b/web-app/src/firebase.ts index a33d31467..b17db1edf 100644 --- a/web-app/src/firebase.ts +++ b/web-app/src/firebase.ts @@ -1,5 +1,6 @@ import firebase from 'firebase/compat/app'; import 'firebase/compat/remote-config'; +import 'firebase/compat/auth'; const firebaseConfig = { apiKey: process.env.REACT_APP_FIREBASE_API_KEY, @@ -15,3 +16,7 @@ export const remoteConfig = firebase.remoteConfig(); remoteConfig.settings.minimumFetchIntervalMillis = Number( process.env.REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI ?? 3600000, // default to 12 hours ); + +if (window.Cypress) { + app.auth().useEmulator('http://localhost:9099/'); +} diff --git a/web-app/yarn.lock b/web-app/yarn.lock index c8ac7d2fb..34ef6bb3d 100644 --- a/web-app/yarn.lock +++ b/web-app/yarn.lock @@ -3593,7 +3593,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@^20.8.10": +"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0": version "20.9.4" resolved "https://registry.npmjs.org/@types/node/-/node-20.9.4.tgz" integrity sha512-wmyg8HUhcn6ACjsn8oKYjkN/zUzQeNtMy44weTJSM6p4MMzEOuKbA3OjJ267uPCOW7Xex9dyrNTful8XTQYoDA== @@ -3607,6 +3607,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@^22.7.4": + version "22.7.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.4.tgz#e35d6f48dca3255ce44256ddc05dee1c23353fcc" + integrity sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg== + dependencies: + undici-types "~6.19.2" + "@types/parse-json@^4.0.0": version "4.0.2" resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz" @@ -6505,6 +6512,11 @@ dotenv@^10.0.0: resolved "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + duplexer@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz" @@ -14518,6 +14530,11 @@ undici-types@~5.26.4: resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + undici@^5.28.4: version "5.28.4" resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068"