Merge branch 'master' into 2632-feature-dockerfile-for-federation

This commit is contained in:
clauspeterhuebner 2023-02-16 14:43:02 +01:00 committed by GitHub
commit 891eef4e9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 734 additions and 501 deletions

View File

@ -274,7 +274,7 @@ jobs:
run: docker run --rm gradido/admin:test yarn run lint run: docker run --rm gradido/admin:test yarn run lint
############################################################################## ##############################################################################
# JOB: STYLELINT ADMIN INTERFACE ############################################## # JOB: STYLELINT ADMIN INTERFACE #############################################
############################################################################## ##############################################################################
stylelint_admin: stylelint_admin:
name: Stylelint - Admin Interface name: Stylelint - Admin Interface
@ -577,7 +577,7 @@ jobs:
end-to-end-tests: end-to-end-tests:
name: End-to-End Tests name: End-to-End Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build_test_mariadb, build_test_database_up, build_test_backend, build_test_admin, build_test_frontend, build_test_nginx] needs: [build_test_mariadb, build_test_database_up, build_test_admin, build_test_frontend, build_test_nginx]
steps: steps:
########################################################################## ##########################################################################
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
@ -601,13 +601,6 @@ jobs:
path: /tmp path: /tmp
- name: Load Docker Image (Database Up) - name: Load Docker Image (Database Up)
run: docker load < /tmp/database_up.tar run: docker load < /tmp/database_up.tar
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v3
with:
name: docker-backend-test
path: /tmp
- name: Load Docker Image (Backend)
run: docker load < /tmp/backend.tar
- name: Download Docker Image (Frontend) - name: Download Docker Image (Frontend)
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
@ -640,7 +633,11 @@ jobs:
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
- name: Boot up test system | docker-compose backend - name: Boot up test system | docker-compose backend
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend run: |
cd backend
cp .env.test_e2e .env
cd ..
docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend
- name: Sleep for 10 seconds - name: Sleep for 10 seconds
run: sleep 10s run: sleep 10s
@ -657,6 +654,9 @@ jobs:
- name: Boot up test system | docker-compose frontends - name: Boot up test system | docker-compose frontends
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps frontend admin nginx run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps frontend admin nginx
- name: Boot up test system | docker-compose mailserver
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mailserver
- name: Sleep for 15 seconds - name: Sleep for 15 seconds
run: sleep 15s run: sleep 15s
@ -666,12 +666,12 @@ jobs:
- name: End-to-end tests | run tests - name: End-to-end tests | run tests
id: e2e-tests id: e2e-tests
run: | run: |
cd e2e-tests/cypress/tests/ cd e2e-tests/
yarn yarn
yarn run cypress run --spec cypress/e2e/User.Authentication.feature yarn run cypress run --spec cypress/e2e/User.Authentication.feature,cypress/e2e/User.Authentication.ResetPassword.feature
- name: End-to-end tests | if tests failed, upload screenshots - name: End-to-end tests | if tests failed, upload screenshots
if: steps.e2e-tests.outcome == 'failure' if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: cypress-screenshots name: cypress-screenshots
path: /home/runner/work/gradido/gradido/e2e-tests/cypress/tests/cypress/screenshots/ path: /home/runner/work/gradido/gradido/e2e-tests/cypress/screenshots/

9
backend/.env.test_e2e Normal file
View File

@ -0,0 +1,9 @@
# Server
JWT_EXPIRES_IN=1m
# Email
EMAIL=true
EMAIL_TEST_MODUS=false
EMAIL_TLS=false
# for testing password reset
EMAIL_CODE_REQUEST_TIME=1

26
e2e-tests/.eslintrc.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
root: true,
env: {
node: true,
cypress: true,
},
parser: '@typescript-eslint/parser',
plugins: ['cypress', 'prettier', '@typescript-eslint' /*, 'jest' */],
extends: [
'standard',
'eslint:recommended',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
],
// add your custom rules here
rules: {
'no-console': ['error'],
'no-debugger': 'error',
'prettier/prettier': [
'error',
{
htmlWhitespaceSensitivity: 'ignore',
},
],
},
}

5
e2e-tests/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
cypress/screenshots/
cypress/videos/
cucumber-messages.ndjson

9
e2e-tests/.prettierrc.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
semi: false,
printWidth: 100,
singleQuote: true,
trailingComma: "all",
tabWidth: 2,
bracketSpacing: true,
endOfLine: "auto",
};

View File

@ -11,7 +11,7 @@
############################################################################### ###############################################################################
FROM cypress/base:16.14.2-slim FROM cypress/base:16.14.2-slim
ARG DOCKER_WORKDIR=/tests/ ARG DOCKER_WORKDIR="/tests"
WORKDIR $DOCKER_WORKDIR WORKDIR $DOCKER_WORKDIR
# install dependencies # install dependencies

View File

@ -1,7 +1,73 @@
# Gradido end-to-end tests # Gradido End-to-End Testing with [Cypress](https://www.cypress.io/) (CI-ready via Docker)
This is still WIP. A setup to show-case Cypress as an end-to-end testing tool for Gradido running in a Docker container.
The tests are organized in feature files written in Gherkin syntax.
For automated end-to-end testing one of the frameworks Cypress or Playwright will be utilized.
For more details on how to run them, see the subfolders' README instructions. ## Features under test
So far these features are initially tested
- [User authentication](https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/User.Authentication.feature)
- [User profile - change password](https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/UserProfile.ChangePassword.feature)
- [User registration]((https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/User.Registration.feature)) (WIP)
## Precondition
Before running the tests, change to the repo's root directory (gradido).
### Boot up the system under test
```bash
docker-compose up
```
### Seed the database
The database has to be seeded upfront to every test run.
```bash
# change to the backend directory
cd /path/to/gradido/gradido/backend
# install all dependencies
yarn
# seed the database (everytime before running the tests)
yarn seed
```
## Execute the test
This setup will be integrated in the Gradido Github Actions to automatically support the CI/CD process.
For now the test setup can only be used locally in two modes.
### Run Cypress directly from the code
```bash
# change to the tests directory
cd /path/to/gradido/e2e-tests/
# install all dependencies
yarn install
# a) run the tests on command line
yarn cypress run
# b) open the Cypress GUI to run the tests in interactive mode
yarn cypress open
```
### Run Cyprss from a separate Docker container
```bash
# change to the cypress directory
cd /path/to/gradido/e2e-tests/
# build a Docker image from the Dockerfile
docker build -t gradido_e2e-tests-cypress .
# run the Docker image and execute the given tests
docker run -it --network=host gradido_e2e-tests-cypress yarn cypress-e2e
```

View File

@ -0,0 +1,79 @@
import { defineConfig } from 'cypress'
import { addCucumberPreprocessorPlugin } from '@badeball/cypress-cucumber-preprocessor'
import browserify from '@badeball/cypress-cucumber-preprocessor/browserify'
let resetPasswordLink: string
async function setupNodeEvents(
on: Cypress.PluginEvents,
config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
await addCucumberPreprocessorPlugin(on, config)
on(
'file:preprocessor',
browserify(config, {
typescript: require.resolve('typescript'),
})
)
on('task', {
setResetPasswordLink: (val) => {
return (resetPasswordLink = val)
},
getResetPasswordLink: () => {
return resetPasswordLink
},
})
on('after:run', (results) => {
if (results) {
// results will be undefined in interactive mode
// eslint-disable-next-line no-console
console.log(results.status)
}
})
return config
}
export default defineConfig({
e2e: {
specPattern: '**/*.feature',
excludeSpecPattern: '*.js',
experimentalSessionAndOrigin: true,
baseUrl: 'http://localhost:3000',
chromeWebSecurity: false,
defaultCommandTimeout: 10000,
supportFile: 'cypress/support/index.ts',
viewportHeight: 720,
viewportWidth: 1280,
video: false,
retries: {
runMode: 2,
openMode: 0,
},
env: {
backendURL: 'http://localhost:4000',
mailserverURL: 'http://localhost:1080',
loginQuery: `query ($email: String!, $password: String!, $publisherId: Int) {
login(email: $email, password: $password, publisherId: $publisherId) {
email
firstName
lastName
language
klickTipp {
newsletterState
__typename
}
hasElopage
publisherId
isAdmin
creation
__typename
}
}`,
},
setupNodeEvents,
},
})

View File

@ -1,4 +0,0 @@
tests/node_modules/
tests/cypress/screenshots/
tests/cypress/videos/
tests/cucumber-messages.ndjson

View File

@ -1,73 +0,0 @@
# Gradido End-to-End Testing with [Cypress](https://www.cypress.io/) (CI-ready via Docker)
A setup to show-case Cypress as an end-to-end testing tool for Gradido running in a Docker container.
The tests are organized in feature files written in Gherkin syntax.
## Features under test
So far these features are initially tested
- [User authentication](https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/User.Authentication.feature)
- [User profile - change password](https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/UserProfile.ChangePassword.feature)
- [User registration]((https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/User.Registration.feature)) (WIP)
## Precondition
Before running the tests, change to the repo's root directory (gradido).
### Boot up the system under test
```bash
docker-compose up
```
### Seed the database
The database has to be seeded upfront to every test run.
```bash
# change to the backend directory
cd /path/to/gradido/gradido/backend
# install all dependencies
yarn
# seed the database (everytime before running the tests)
yarn seed
```
## Execute the test
This setup will be integrated in the Gradido Github Actions to automatically support the CI/CD process.
For now the test setup can only be used locally in two modes.
### Run Cypress directly from the code
```bash
# change to the tests directory
cd /path/to/gradido/e2e-tests/cypress/tests
# install all dependencies
yarn install
# a) run the tests on command line
yarn cypress run
# b) open the Cypress GUI to run the tests in interactive mode
yarn cypress open
```
### Run Cyprss from a separate Docker container
```bash
# change to the cypress directory
cd /path/to/gradido/e2e-tests/cypress/
# build a Docker image from the Dockerfile
docker build -t gradido_e2e-tests-cypress .
# run the Docker image and execute the given tests
docker run -it --network=host gradido_e2e-tests-cypress yarn cypress-e2e
```

View File

@ -0,0 +1,25 @@
Feature: User Authentication - reset password
As a user
I want to reset my password from the sign in page
# TODO for these pre-conditions utilize seeding or API check, if user exists in test system
# Background:
# Given the following "users" are in the database:
# | email | password | name |
# | bibi@bloxberg.de | Aa12345_ | Bibi Bloxberg |
Scenario: Reset password from signin page successfully
Given the user navigates to page "/login"
And the user navigates to the forgot password page
When the user enters the e-mail address "bibi@bloxberg.de"
And the user submits the e-mail form
Then the user receives an e-mail containing the password reset link
When the user opens the password reset link in the browser
And the user enters the password "12345Aa_"
And the user repeats the password "12345Aa_"
And the user submits the password form
And the user clicks the sign in button
Then the user submits the credentials "bibi@bloxberg.de" "Aa12345_"
And the user cannot login
But the user submits the credentials "bibi@bloxberg.de" "12345Aa_"
And the user is logged in with username "Bibi Bloxberg"

View File

@ -11,7 +11,7 @@ Feature: User authentication
# | bibi@bloxberg.de | Aa12345_ | Bibi Bloxberg | # | bibi@bloxberg.de | Aa12345_ | Bibi Bloxberg |
Scenario: Log in successfully Scenario: Log in successfully
Given the browser navigates to page "/login" Given the user navigates to page "/login"
When the user submits the credentials "bibi@bloxberg.de" "Aa12345_" When the user submits the credentials "bibi@bloxberg.de" "Aa12345_"
Then the user is logged in with username "Bibi Bloxberg" Then the user is logged in with username "Bibi Bloxberg"

View File

@ -4,7 +4,7 @@ Feature: User registration
@skip @skip
Scenario: Register successfully Scenario: Register successfully
Given the browser navigates to page "/register" Given the user navigates to page "/register"
When the user fills name and email "Regina" "Register" "regina@register.com" When the user fills name and email "Regina" "Register" "regina@register.com"
And the user agrees to the privacy policy And the user agrees to the privacy policy
And the user submits the registration form And the user submits the registration form

View File

@ -12,7 +12,7 @@ Feature: User profile - change password
Given the user is logged in as "bibi@bloxberg.de" "Aa12345_" Given the user is logged in as "bibi@bloxberg.de" "Aa12345_"
Scenario: Change password successfully Scenario: Change password successfully
Given the browser navigates to page "/profile" Given the user navigates to page "/profile"
And the user opens the change password menu And the user opens the change password menu
When the user fills the password form with: When the user fills the password form with:
| Old password | Aa12345_ | | Old password | Aa12345_ |

View File

@ -0,0 +1,18 @@
/// <reference types='cypress' />
export class ForgotPasswordPage {
// selectors
emailInput = 'input[type=email]'
submitBtn = 'button[type=submit]'
successComponent = '[data-test="forgot-password-success"]'
enterEmail(email: string) {
cy.get(this.emailInput).clear().type(email)
return this
}
submitEmail() {
cy.get(this.submitBtn).click()
return this
}
}

View File

@ -0,0 +1,35 @@
/// <reference types='cypress' />
export class LoginPage {
// selectors
emailInput = 'input[type=email]'
passwordInput = 'input[type=password]'
forgotPasswordLink = '[data-test="forgot-password-link"]'
submitBtn = '[type=submit]'
emailHint = '#vee_Email'
passwordHint = '#vee_Password'
goto() {
cy.visit('/')
return this
}
enterEmail(email: string) {
cy.get(this.emailInput).clear().type(email)
return this
}
enterPassword(password: string) {
cy.get(this.passwordInput).clear().type(password)
return this
}
submitLogin() {
cy.get(this.submitBtn).click()
return this
}
openForgotPasswordPage() {
cy.get(this.forgotPasswordLink).click()
}
}

View File

@ -0,0 +1,10 @@
/// <reference types='cypress' />
export class OverviewPage {
navbarName = '[data-test="navbar-item-username"]'
goto() {
cy.visit('/overview')
return this
}
}

View File

@ -0,0 +1,35 @@
/// <reference types='cypress' />
export class ProfilePage {
// selectors
openChangePassword = '[data-test=open-password-change-form]'
oldPasswordInput = '#password-input-field'
newPasswordInput = '#new-password-input-field'
newPasswordRepeatInput = '#repeat-new-password-input-field'
submitNewPasswordBtn = '[data-test=submit-new-password-btn]'
goto() {
cy.visit('/profile')
return this
}
enterOldPassword(password: string) {
cy.get(this.oldPasswordInput).clear().type(password)
return this
}
enterNewPassword(password: string) {
cy.get(this.newPasswordInput).find('input').clear().type(password)
return this
}
enterRepeatPassword(password: string) {
cy.get(this.newPasswordRepeatInput).find('input').clear().type(password)
return this
}
submitPasswordForm() {
cy.get(this.submitNewPasswordBtn).click()
return this
}
}

View File

@ -0,0 +1,42 @@
/// <reference types='cypress' />
export class RegistrationPage {
// selectors
firstnameInput = '#registerFirstname'
lastnameInput = '#registerLastname'
emailInput = '#Email-input-field'
checkbox = '#registerCheckbox'
submitBtn = '[type=submit]'
RegistrationThanxHeadline = '.test-message-headline'
RegistrationThanxText = '.test-message-subtitle'
goto() {
cy.visit('/register')
return this
}
enterFirstname(firstname: string) {
cy.get(this.firstnameInput).clear().type(firstname)
return this
}
enterLastname(lastname: string) {
cy.get(this.lastnameInput).clear().type(lastname)
return this
}
enterEmail(email: string) {
cy.get(this.emailInput).clear().type(email)
return this
}
checkPrivacyCheckbox() {
cy.get(this.checkbox).click({ force: true })
}
submitRegistrationPage() {
cy.get(this.submitBtn).should('be.enabled')
cy.get(this.submitBtn).click()
}
}

View File

@ -0,0 +1,32 @@
/// <reference types='cypress' />
export class ResetPasswordPage {
// selectors
newPasswordBlock = '#new-password-input-field'
newPasswordRepeatBlock = '#repeat-new-password-input-field'
resetPasswordBtn = 'button[type=submit]'
resetPasswordMessageBlock = '[data-test="reset-password-message"]'
signinBtn = '.btn.test-message-button'
enterNewPassword(password: string) {
cy.get(this.newPasswordBlock).find('input[type=password]').type(password)
return this
}
repeatNewPassword(password: string) {
cy.get(this.newPasswordRepeatBlock)
.find('input[type=password]')
.type(password)
return this
}
submitNewPassword() {
cy.get(this.resetPasswordBtn).click()
return this
}
openSigninPage() {
cy.get(this.signinBtn).click()
return this
}
}

View File

@ -0,0 +1,17 @@
/// <reference types='cypress' />
export class SideNavMenu {
// selectors
profileMenu = '[data-test=profile-menu]'
logoutMenu = '[data-test=logout-menu]'
openUserProfile() {
cy.get(this.profileMenu).click()
return this
}
logout() {
cy.get(this.logoutMenu).click()
return this
}
}

View File

@ -0,0 +1,10 @@
/// <reference types='cypress' />
export class Toasts {
// selectors
toastSlot = '.b-toaster-slot'
toastTypeSuccess = '.b-toast-success'
toastTypeError = '.b-toast-danger'
toastTitle = '.gdd-toaster-title'
toastMessage = '.gdd-toaster-body'
}

View File

@ -0,0 +1,17 @@
/// <reference types='cypress' />
export class UserEMailSite {
// selectors
emailInbox = '.sidebar-emails-container'
emailList = '.email-list'
emailMeta = '.email-meta'
emailSubject = '.subject'
openRecentPasswordResetEMail() {
cy.get(this.emailList)
.find('email-item')
.filter(':contains(asswor)')
.click()
expect(cy.get(this.emailSubject)).to('contain', 'asswor')
}
}

View File

@ -0,0 +1,40 @@
import jwtDecode from 'jwt-decode'
Cypress.Commands.add('login', (email, password) => {
cy.clearLocalStorage('vuex')
cy.request({
method: 'POST',
url: Cypress.env('backendURL'),
body: {
operationName: null,
variables: {
email: email,
password: password,
},
query: Cypress.env('loginQuery'),
},
}).then(async (response) => {
const tokens = response.headers.token
const token = Array.isArray(tokens) ? tokens[0] : tokens
let tokenTime
if (!token) return
// to avoid JWT InvalidTokenError, the decoding of the token is wrapped
// in a try-catch block (see
// https://github.com/auth0/jwt-decode/issues/65#issuecomment-395493807)
try {
tokenTime = jwtDecode(token).exp
} catch (tokenDecodingError) {
cy.log('JWT decoding error: ', tokenDecodingError)
}
const vuexToken = {
token: token,
tokenTime: tokenTime,
}
cy.visit('/')
window.localStorage.setItem('vuex', JSON.stringify(vuexToken))
})
})

View File

@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/no-namespace */ /* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/// <reference types="cypress" /> /// <reference types='cypress' />
import "./e2e"; import './e2e'
declare global { declare global {
namespace Cypress { namespace Cypress {
interface Chainable<Subject> { interface Chainable<Subject> {
login(email: string, password: string): Chainable<any>; login(email: string, password: string): Chainable<any>
} }
} }
} }

View File

@ -0,0 +1,39 @@
import { Given, Then, When } from '@badeball/cypress-cucumber-preprocessor'
import { OverviewPage } from '../../e2e/models/OverviewPage'
import { SideNavMenu } from '../../e2e/models/SideNavMenu'
import { Toasts } from '../../e2e/models/Toasts'
Given('the user navigates to page {string}', (page: string) => {
cy.visit(page)
})
// login related
Given(
'the user is logged in as {string} {string}',
(email: string, password: string) => {
cy.login(email, password)
}
)
Then('the user is logged in with username {string}', (username: string) => {
const overviewPage = new OverviewPage()
cy.url().should('include', '/overview')
cy.get(overviewPage.navbarName).should('contain', username)
})
Then('the user cannot login', () => {
const toast = new Toasts()
cy.get(toast.toastSlot).within(() => {
cy.get(toast.toastTypeError)
cy.get(toast.toastTitle).should('be.visible')
cy.get(toast.toastMessage).should('be.visible')
})
})
// logout
Then('the user logs out', () => {
const sideNavMenu = new SideNavMenu()
sideNavMenu.logout()
})

View File

@ -0,0 +1,45 @@
import { Then, When } from '@badeball/cypress-cucumber-preprocessor'
import { ResetPasswordPage } from '../../e2e/models/ResetPasswordPage'
import { UserEMailSite } from '../../e2e/models/UserEMailSite'
const userEMailSite = new UserEMailSite()
const resetPasswordPage = new ResetPasswordPage()
Then('the user receives an e-mail containing the password reset link', () => {
cy.origin(
Cypress.env('mailserverURL'),
{ args: userEMailSite },
(userEMailSite) => {
const linkPattern = /\/reset-password\/[0-9]+\d/
cy.visit('/') // navigate to user's e-maile site (on fake mail server)
cy.get(userEMailSite.emailInbox).should('be.visible')
cy.get(userEMailSite.emailList)
.find('.email-item')
.filter(':contains(asswor)')
.first()
.click()
cy.get(userEMailSite.emailMeta)
.find(userEMailSite.emailSubject)
.contains('asswor')
cy.get('.email-content')
.find('.plain-text')
.contains(linkPattern)
.invoke('text')
.then((text) => {
const resetPasswordLink = text.match(linkPattern)[0]
cy.task('setResetPasswordLink', resetPasswordLink)
})
}
)
})
When('the user opens the password reset link in the browser', () => {
cy.task('getResetPasswordLink').then((passwordResetLink) => {
cy.visit(passwordResetLink)
})
cy.get(resetPasswordPage.newPasswordRepeatBlock).should('be.visible')
})

View File

@ -0,0 +1,69 @@
import { When, And } from '@badeball/cypress-cucumber-preprocessor'
import { ForgotPasswordPage } from '../../e2e/models/ForgotPasswordPage'
import { LoginPage } from '../../e2e/models/LoginPage'
import { ResetPasswordPage } from '../../e2e/models/ResetPasswordPage'
const loginPage = new LoginPage()
const forgotPasswordPage = new ForgotPasswordPage()
const resetPasswordPage = new ResetPasswordPage()
// login related
When('the user submits no credentials', () => {
loginPage.submitLogin()
})
When(
'the user submits the credentials {string} {string}',
(email: string, password: string) => {
cy.intercept('POST', '/graphql', (req) => {
if (
req.body.hasOwnProperty('query') &&
req.body.query.includes('mutation')
) {
req.alias = 'login'
}
})
loginPage.enterEmail(email)
loginPage.enterPassword(password)
loginPage.submitLogin()
cy.wait('@login').then((interception) => {
expect(interception.response.statusCode).equals(200)
})
}
)
// password reset related
And('the user navigates to the forgot password page', () => {
loginPage.openForgotPasswordPage()
cy.url().should('include', '/forgot-password')
})
When('the user enters the e-mail address {string}', (email: string) => {
forgotPasswordPage.enterEmail(email)
})
And('the user submits the e-mail form', () => {
forgotPasswordPage.submitEmail()
cy.get(forgotPasswordPage.successComponent).should('be.visible')
})
And('the user enters the password {string}', (password: string) => {
resetPasswordPage.enterNewPassword(password)
})
And('the user repeats the password {string}', (password: string) => {
resetPasswordPage.repeatNewPassword(password)
})
And('the user submits the new password', () => {
resetPasswordPage.submitNewPassword()
cy.get(resetPasswordPage.resetPasswordMessageBlock).should('be.visible')
})
And('the user clicks the sign in button', () => {
resetPasswordPage.openSigninPage()
cy.url().should('contain', '/login')
})

View File

@ -0,0 +1,32 @@
import { And, When } from '@badeball/cypress-cucumber-preprocessor'
import { ProfilePage } from '../../e2e/models/ProfilePage'
import { Toasts } from '../../e2e/models/Toasts'
const profilePage = new ProfilePage()
And('the user opens the change password menu', () => {
cy.get(profilePage.openChangePassword).click()
cy.get(profilePage.newPasswordRepeatInput).should('be.visible')
cy.get(profilePage.submitNewPasswordBtn).should('be.disabled')
})
When('the user fills the password form with:', (table) => {
let hashedTableRows = table.rowsHash()
profilePage.enterOldPassword(hashedTableRows['Old password'])
profilePage.enterNewPassword(hashedTableRows['New password'])
profilePage.enterRepeatPassword(hashedTableRows['Repeat new password'])
cy.get(profilePage.submitNewPasswordBtn).should('be.enabled')
})
And('the user submits the password form', () => {
profilePage.submitPasswordForm()
})
When('the user is presented a {string} message', (type: string) => {
const toast = new Toasts()
cy.get(toast.toastSlot).within(() => {
cy.get(toast.toastTypeSuccess)
cy.get(toast.toastTitle).should('be.visible')
cy.get(toast.toastMessage).should('be.visible')
})
})

View File

@ -0,0 +1,24 @@
import { And, When } from '@badeball/cypress-cucumber-preprocessor'
import { RegistrationPage } from '../../e2e/models/RegistrationPage'
const registrationPage = new RegistrationPage()
When(
'the user fills name and email {string} {string} {string}',
(firstname: string, lastname: string, email: string) => {
const registrationPage = new RegistrationPage()
registrationPage.enterFirstname(firstname)
registrationPage.enterLastname(lastname)
registrationPage.enterEmail(email)
}
)
And('the user agrees to the privacy policy', () => {
registrationPage.checkPrivacyCheckbox()
})
And('the user submits the registration form', () => {
registrationPage.submitRegistrationPage()
cy.get(registrationPage.RegistrationThanxHeadline).should('be.visible')
cy.get(registrationPage.RegistrationThanxText).should('be.visible')
})

View File

@ -1,24 +0,0 @@
module.exports = {
root: true,
env: {
node: true,
},
parser: "@typescript-eslint/parser",
plugins: ["cypress", "prettier", "@typescript-eslint"],
extends: [
"standard",
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-console": ["error"],
"no-debugger": "error",
"prettier/prettier": [
"error",
{
htmlWhitespaceSensitivity: "ignore",
},
],
},
};

View File

@ -1,66 +0,0 @@
import { defineConfig } from "cypress";
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import browserify from "@badeball/cypress-cucumber-preprocessor/browserify";
async function setupNodeEvents(
on: Cypress.PluginEvents,
config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
await addCucumberPreprocessorPlugin(on, config);
on(
"file:preprocessor",
browserify(config, {
typescript: require.resolve("typescript"),
})
);
on("after:run", (results) => {
if (results) {
// results will be undefined in interactive mode
// eslint-disable-next-line no-console
console.log(results.status);
}
});
return config;
}
export default defineConfig({
e2e: {
specPattern: "**/*.feature",
excludeSpecPattern: "*.js",
baseUrl: "http://localhost:3000",
chromeWebSecurity: false,
defaultCommandTimeout: 10000,
supportFile: "cypress/support/index.ts",
viewportHeight: 720,
viewportWidth: 1280,
video: false,
retries: {
runMode: 2,
openMode: 0,
},
env: {
backendURL: "http://localhost:4000",
loginQuery: `query ($email: String!, $password: String!, $publisherId: Int) {
login(email: $email, password: $password, publisherId: $publisherId) {
email
firstName
lastName
language
klickTipp {
newsletterState
__typename
}
hasElopage
publisherId
isAdmin
creation
__typename
}
}`,
},
setupNodeEvents,
},
});

View File

@ -1,30 +0,0 @@
/// <reference types="cypress" />
export class LoginPage {
// selectors
emailInput = "input[type=email]";
passwordInput = "input[type=password]";
submitBtn = "[type=submit]";
emailHint = "#vee_Email";
passwordHint = "#vee_Password";
goto() {
cy.visit("/");
return this;
}
enterEmail(email: string) {
cy.get(this.emailInput).clear().type(email);
return this;
}
enterPassword(password: string) {
cy.get(this.passwordInput).clear().type(password);
return this;
}
submitLogin() {
cy.get(this.submitBtn).click();
return this;
}
}

View File

@ -1,10 +0,0 @@
/// <reference types="cypress" />
export class OverviewPage {
navbarName = '[data-test="navbar-item-username"]';
goto() {
cy.visit("/overview");
return this;
}
}

View File

@ -1,35 +0,0 @@
/// <reference types="cypress" />
export class ProfilePage {
// selectors
openChangePassword = "[data-test=open-password-change-form]";
oldPasswordInput = "#password-input-field";
newPasswordInput = "#new-password-input-field";
newPasswordRepeatInput = "#repeat-new-password-input-field";
submitNewPasswordBtn = "[data-test=submit-new-password-btn]";
goto() {
cy.visit("/profile");
return this;
}
enterOldPassword(password: string) {
cy.get(this.oldPasswordInput).clear().type(password);
return this;
}
enterNewPassword(password: string) {
cy.get(this.newPasswordInput).find("input").clear().type(password);
return this;
}
enterRepeatPassword(password: string) {
cy.get(this.newPasswordRepeatInput).find("input").clear().type(password);
return this;
}
submitPasswordForm() {
cy.get(this.submitNewPasswordBtn).click();
return this;
}
}

View File

@ -1,42 +0,0 @@
/// <reference types="cypress" />
export class RegistrationPage {
// selectors
firstnameInput = "#registerFirstname";
lastnameInput = "#registerLastname";
emailInput = "#Email-input-field";
checkbox = "#registerCheckbox";
submitBtn = "[type=submit]";
RegistrationThanxHeadline = ".test-message-headline";
RegistrationThanxText = ".test-message-subtitle";
goto() {
cy.visit("/register");
return this;
}
enterFirstname(firstname: string) {
cy.get(this.firstnameInput).clear().type(firstname);
return this;
}
enterLastname(lastname: string) {
cy.get(this.lastnameInput).clear().type(lastname);
return this;
}
enterEmail(email: string) {
cy.get(this.emailInput).clear().type(email);
return this;
}
checkPrivacyCheckbox() {
cy.get(this.checkbox).click({ force: true });
}
submitRegistrationPage() {
cy.get(this.submitBtn).should("be.enabled");
cy.get(this.submitBtn).click();
}
}

View File

@ -1,17 +0,0 @@
/// <reference types="cypress" />
export class SideNavMenu {
// selectors
profileMenu = "[data-test=profile-menu]";
logoutMenu = "[data-test=logout-menu]";
openUserProfile() {
cy.get(this.profileMenu).click();
return this;
}
logout() {
cy.get(this.logoutMenu).click();
return this;
}
}

View File

@ -1,10 +0,0 @@
/// <reference types="cypress" />
export class Toasts {
// selectors
toastSlot = ".b-toaster-slot";
toastTypeSuccess = ".b-toast-success";
toastTypeError = ".b-toast-danger";
toastTitle = ".gdd-toaster-title";
toastMessage = ".gdd-toaster-body";
}

View File

@ -1,38 +0,0 @@
import jwtDecode from "jwt-decode";
Cypress.Commands.add("login", (email, password) => {
cy.clearLocalStorage("vuex");
cy.request({
method: "POST",
url: Cypress.env("backendURL"),
body: {
operationName: null,
variables: {
email: email,
password: password,
},
query: Cypress.env("loginQuery"),
},
}).then(async (response) => {
const token = response.headers.token;
let tokenTime;
// to avoid JWT InvalidTokenError, the decoding of the token is wrapped
// in a try-catch block (see
// https://github.com/auth0/jwt-decode/issues/65#issuecomment-395493807)
try {
tokenTime = jwtDecode(token).exp;
} catch (tokenDecodingError) {
cy.log("JWT decoding error: ", tokenDecodingError);
}
const vuexToken = {
token: token,
tokenTime: tokenTime,
};
cy.visit("/");
window.localStorage.setItem("vuex", JSON.stringify(vuexToken));
});
});

View File

@ -1,52 +0,0 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
import { LoginPage } from "../../e2e/models/LoginPage";
import { OverviewPage } from "../../e2e/models/OverviewPage";
import { SideNavMenu } from "../../e2e/models/SideNavMenu";
import { Toasts } from "../../e2e/models/Toasts";
Given("the browser navigates to page {string}", (page: string) => {
cy.visit(page);
});
// login-related
Given(
"the user is logged in as {string} {string}",
(email: string, password: string) => {
cy.login(email, password);
}
);
Then("the user is logged in with username {string}", (username: string) => {
const overviewPage = new OverviewPage();
cy.url().should("include", "/overview");
cy.get(overviewPage.navbarName).should("contain", username);
});
Then("the user cannot login", () => {
const toast = new Toasts();
cy.get(toast.toastSlot).within(() => {
cy.get(toast.toastTypeError);
cy.get(toast.toastTitle).should("be.visible");
cy.get(toast.toastMessage).should("be.visible");
});
});
//
When(
"the user submits the credentials {string} {string}",
(email: string, password: string) => {
const loginPage = new LoginPage();
loginPage.enterEmail(email);
loginPage.enterPassword(password);
loginPage.submitLogin();
}
);
// logout
Then("the user logs out", () => {
const sideNavMenu = new SideNavMenu();
sideNavMenu.logout();
});

View File

@ -1,7 +0,0 @@
import { When } from "@badeball/cypress-cucumber-preprocessor";
import { LoginPage } from "../../e2e/models/LoginPage";
When("the user submits no credentials", () => {
const loginPage = new LoginPage();
loginPage.submitLogin();
});

View File

@ -1,32 +0,0 @@
import { And, When } from "@badeball/cypress-cucumber-preprocessor";
import { ProfilePage } from "../../e2e/models/ProfilePage";
import { Toasts } from "../../e2e/models/Toasts";
const profilePage = new ProfilePage();
And("the user opens the change password menu", () => {
cy.get(profilePage.openChangePassword).click();
cy.get(profilePage.newPasswordRepeatInput).should("be.visible");
cy.get(profilePage.submitNewPasswordBtn).should("be.disabled");
});
When("the user fills the password form with:", (table) => {
table = table.rowsHash();
profilePage.enterOldPassword(table["Old password"]);
profilePage.enterNewPassword(table["New password"]);
profilePage.enterRepeatPassword(table["Repeat new password"]);
cy.get(profilePage.submitNewPasswordBtn).should("be.enabled");
});
And("the user submits the password form", () => {
profilePage.submitPasswordForm();
});
When("the user is presented a {string} message", (type: string) => {
const toast = new Toasts();
cy.get(toast.toastSlot).within(() => {
cy.get(toast.toastTypeSuccess);
cy.get(toast.toastTitle).should("be.visible");
cy.get(toast.toastMessage).should("be.visible");
});
});

View File

@ -1,24 +0,0 @@
import { And, When } from "@badeball/cypress-cucumber-preprocessor";
import { RegistrationPage } from "../../e2e/models/RegistrationPage";
const registrationPage = new RegistrationPage();
When(
"the user fills name and email {string} {string} {string}",
(firstname: string, lastname: string, email: string) => {
const registrationPage = new RegistrationPage();
registrationPage.enterFirstname(firstname);
registrationPage.enterLastname(lastname);
registrationPage.enterEmail(email);
}
);
And("the user agrees to the privacy policy", () => {
registrationPage.checkPrivacyCheckbox();
});
And("the user submits the registration form", () => {
registrationPage.submitRegistrationPage();
cy.get(registrationPage.RegistrationThanxHeadline).should("be.visible");
cy.get(registrationPage.RegistrationThanxText).should("be.visible");
});

View File

@ -1,10 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"lib": ["es6", "dom"],
"baseUrl": "../node_modules",
"types": ["cypress", "node"],
"strict": true
},
"include": ["**/*.ts"]
}

16
e2e-tests/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["es6", "dom"],
"baseUrl": ".",
"types": ["cypress", "node"],
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"paths": {
"@/*": ["cypress/*"],
"@models/*": ["cypress/e2e/models/*"],
}
},
"include": ["**/*.ts"],
}

View File

@ -1,3 +1,4 @@
<!-- eslint-disable prettier/prettier -->
<template> <template>
<div class="forgot-password"> <div class="forgot-password">
<b-container v-if="enterData"> <b-container v-if="enterData">
@ -26,6 +27,7 @@
<message <message
:headline="success ? $t('message.title') : $t('message.errorTitle')" :headline="success ? $t('message.title') : $t('message.errorTitle')"
:subtitle="success ? $t('message.email') : $t('error.email-already-sent')" :subtitle="success ? $t('message.email') : $t('error.email-already-sent')"
:data-test="success ? 'forgot-password-success' : 'forgot-password-error'"
:buttonText="$t('login')" :buttonText="$t('login')"
linkTo="/login" linkTo="/login"
/> />

2
frontend/src/pages/Login.vue Executable file → Normal file
View File

@ -24,7 +24,7 @@
</b-row> </b-row>
<b-row> <b-row>
<b-col class="d-flex justify-content-end mb-4 mb-lg-0"> <b-col class="d-flex justify-content-end mb-4 mb-lg-0">
<router-link to="/forgot-password"> <router-link to="/forgot-password" data-test="forgot-password-link">
{{ $t('settings.password.forgot_pwd') }} {{ $t('settings.password.forgot_pwd') }}
</router-link> </router-link>
</b-col> </b-col>

View File

@ -5,7 +5,12 @@
<b-form role="form" @submit.prevent="handleSubmit(onSubmit)"> <b-form role="form" @submit.prevent="handleSubmit(onSubmit)">
<input-password-confirmation v-model="form" /> <input-password-confirmation v-model="form" />
<div class="text-center"> <div class="text-center">
<b-button type="submit" variant="gradido" class="mt-4"> <b-button
type="submit"
variant="gradido"
class="mt-4"
data-test="submit-new-password-btn"
>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys--> <!-- eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys-->
{{ $t(displaySetup.button) }} {{ $t(displaySetup.button) }}
</b-button> </b-button>