ci(e2e): Run Tests in Pipelines (#3903)

* cy10 changes

* test: setup local e2e env

* test(e2e): migrate e2e setup

* add more config

* make e2e setup work

* align variables

* fix config

* skip mfa

* set user register to false

* read ids from database if not provided

* don't read ids withing env file

* fix escaping in id queries

* fix project root

* export projectRoot path

* export projectRoot

* add e2e-setup.sh

* specify GOOS and GOARCH for dockerfile compatible binary

* add org default redirect uri

* correctly initialize org policy

* await ids

* fix awaiting ids

* fix cypress configuration

* fix some tests

* initial compose setup

* fix working directory

* fix references

* make tests less flaky

* run go tests

* compose works until e2e-setup incl

* pass created e2e sa key

* make cypress run

* derive e2e orgs domain from baseurl

* use host from baseurl for setup ctx

* move defaults.yaml back to cmd pkg

* just create org owner

* Don't render element if no roles are passed

* use map instead of switchMap

* fix e2e tests

* added testdata for e3e

* zipped dump

* removed dumpDir

* cypress workflow with compose

* quote name

* cleanup vars

* eliminate need for e2e setup

* compose has no builds anymore

* use compose run and zitadel nw

* test e2e on pr (#4114)

* test e2e on pr

* install goreleaser

* install npm dev dependencies

* run cypress wf

* dynamic release version

* skip flaky user tests

* skip flaky permissions test

* cache docker layers in pipeline

* Update .github/workflows/cypress.yml

Co-authored-by: Florian Forster <florian@caos.ch>

* align goreleaser version

* get rid of install.sh

* remove cypress-terminal-report

* Revert "remove cypress-terminal-report"

This reverts commit 254b5a1f87.

* just one npm e2e:build command

* cache npm dependencies

* install node modules using docker

* dedicated e2e context

* fix syntax

* don't copy node modules from goreleaser

* add npm-copy target

* add tsconfig.json

* remove docker caching

* deleted unneeded shellscript

* naming and cleanup

Co-authored-by: Florian Forster <florian@caos.ch>
Co-authored-by: Christian Jakob <christian@caos.ch>

* cleanup

Co-authored-by: Elio Bischof <eliobischof@gmail.com>
Co-authored-by: Christian Jakob <christian@caos.ch>
Co-authored-by: Florian Forster <florian@caos.ch>
This commit is contained in:
Max Peintner
2022-08-05 20:00:46 +02:00
committed by GitHub
parent ce85397050
commit fc99ec87c5
55 changed files with 5539 additions and 3621 deletions

1
e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

12
e2e/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Run e2e Tests
```bash
docker compose run e2e
```
# Cleanup e2e Tests
```bash
docker compose down
```

41
e2e/cypress.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig } from 'cypress';
let tokensCache = new Map<string,string>()
export default defineConfig({
reporter: 'mochawesome',
reporterOptions: {
reportDir: 'cypress/results',
overwrite: false,
html: true,
json: true,
},
chromeWebSecurity: false,
trashAssetsBeforeRuns: false,
defaultCommandTimeout: 10000,
env: {
ORGANIZATION: process.env.CYPRESS_ORGANIZATION || 'zitadel'
},
e2e: {
baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost:8080',
experimentalSessionAndOrigin: true,
setupNodeEvents(on, config) {
on('task', {
safetoken({key, token}) {
tokensCache.set(key,token);
return null
}
})
on('task', {
loadtoken({key}): string | null {
return tokensCache.get(key) || null;
}
})
},
},
});

4
e2e/cypress/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
results
videos
screenshots
downloads

View File

@@ -0,0 +1,42 @@
import { Apps, ensureProjectExists, ensureProjectResourceDoesntExist } from "../../support/api/projects";
import { apiAuth } from "../../support/api/apiauth";
describe('applications', () => {
const testProjectName = 'e2eprojectapplication'
const testAppName = 'e2eappundertest'
beforeEach(`ensure it doesn't exist already`, () => {
apiAuth().then(api => {
ensureProjectExists(api, testProjectName).then(projectID => {
ensureProjectResourceDoesntExist(api, projectID, Apps, testAppName).then(() => {
cy.visit(`/ui/console/projects/${projectID}`)
})
})
})
})
it('add app', () => {
cy.get('mat-spinner')
cy.get('mat-spinner').should('not.exist')
cy.get('[data-e2e="app-card-add"]').should('be.visible').click()
// select webapp
cy.get('[formcontrolname="name"]').type(testAppName)
cy.get('[for="WEB"]').click()
cy.get('[data-e2e="continue-button-nameandtype"]').click()
//select authentication
cy.get('[for="PKCE"]').click()
cy.get('[data-e2e="continue-button-authmethod"]').click()
//enter URL
cy.get('cnsl-redirect-uris').eq(0).type("https://testurl.org")
cy.get('cnsl-redirect-uris').eq(1).type("https://testlogouturl.org")
cy.get('[data-e2e="continue-button-redirecturis"]').click()
cy.get('[data-e2e="create-button"]').click().then(() => {
cy.get('[id*=overlay]').should('exist')
})
cy.get('.data-e2e-success')
cy.wait(200)
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist')
//TODO: check client ID/Secret
})
})

View File

@@ -0,0 +1,57 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureHumanUserExists, ensureUserDoesntExist } from '../../support/api/users';
import { loginname } from '../../support/login/users';
describe.skip('humans', () => {
const humansPath = `/ui/console/users?type=human`;
const testHumanUserNameAdd = 'e2ehumanusernameadd';
const testHumanUserNameRemove = 'e2ehumanusernameremove';
describe('add', () => {
before(`ensure it doesn't exist already`, () => {
apiAuth().then((apiCallProperties) => {
ensureUserDoesntExist(apiCallProperties, testHumanUserNameAdd).then(()=>{
cy.visit(humansPath);
});
});
});
it('should add a user', () => {
cy.get('[data-e2e="action-key-add"]').parents('[data-e2e="create-user-button"]').click();
cy.url().should('contain', 'users/create');
cy.get('[formcontrolname="email"]').type(loginname('e2ehuman'));
//force needed due to the prefilled username prefix
cy.get('[formcontrolname="userName"]').type(testHumanUserNameAdd, { force: true });
cy.get('[formcontrolname="firstName"]').type('e2ehumanfirstname');
cy.get('[formcontrolname="lastName"]').type('e2ehumanlastname');
cy.get('[formcontrolname="phone"]').type('+41 123456789');
cy.get('[data-e2e="create-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('remove', () => {
before('ensure it exists', () => {
apiAuth().then((api) => {
ensureHumanUserExists(api, testHumanUserNameRemove).then(()=>{
cy.visit(humansPath);
});
});
});
it('should delete a human user', () => {
cy.contains('tr', testHumanUserNameRemove)
// doesn't work, need to force click.
// .trigger('mouseover')
.find('[e2e-data="enabled-delete-button"]')
.click({force: true});
cy.get('[e2e-data="confirm-dialog-input"]').click().type(loginname(testHumanUserNameRemove, Cypress.env('org')));
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
});

View File

@@ -0,0 +1,55 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureMachineUserExists, ensureUserDoesntExist } from '../../support/api/users';
import { loginname } from '../../support/login/users';
describe.skip('machines', () => {
const machinesPath = `/ui/console/users?type=machine`;
const testMachineUserNameAdd = 'e2emachineusernameadd';
const testMachineUserNameRemove = 'e2emachineusernameremove';
describe('add', () => {
before(`ensure it doesn't exist already`, () => {
apiAuth().then((apiCallProperties) => {
ensureUserDoesntExist(apiCallProperties, testMachineUserNameAdd).then(()=>{
cy.visit(machinesPath);
});
});
});
it('should add a machine', () => {
cy.get('[data-e2e="action-key-add"]').parents('[data-e2e="create-user-button"]').click();
cy.url().should('contain', 'users/create-machine');
//force needed due to the prefilled username prefix
cy.get('[formcontrolname="userName"]').type(testMachineUserNameAdd, { force: true });
cy.get('[formcontrolname="name"]').type('e2emachinename');
cy.get('[formcontrolname="description"]').type('e2emachinedescription');
cy.get('[data-e2e="create-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('remove', () => {
before('ensure it exists', () => {
apiAuth().then((api) => {
ensureMachineUserExists(api, testMachineUserNameRemove).then(()=>{
cy.visit(machinesPath);
});
});
});
it('should delete a machine', () => {
cy.contains('tr', testMachineUserNameRemove, { timeout: 1000 })
// doesn't work, need to force click.
// .trigger('mouseover')
.find('[e2e-data="enabled-delete-button"]')
.click({force: true});
cy.get('[e2e-data="confirm-dialog-input"]').click().type(loginname(testMachineUserNameRemove, Cypress.env('org')));
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
});

View File

@@ -0,0 +1,111 @@
import { apiAuth } from "../../support/api/apiauth";
import { ensureProjectExists, ensureProjectResourceDoesntExist, Roles } from "../../support/api/projects";
describe.skip('permissions', () => {
const testProjectName = 'e2eprojectpermission'
const testAppName = 'e2eapppermission'
const testRoleName = 'e2eroleundertestname'
const testRoleDisplay = 'e2eroleundertestdisplay'
const testRoleGroup = 'e2eroleundertestgroup'
const testGrantName = 'e2egrantundertest'
var projectId: number
beforeEach(() => {
apiAuth().then(apiCalls => {
ensureProjectExists(apiCalls, testProjectName).then(projId => {
projectId = projId
})
})
})
describe('add role', () => {
beforeEach(()=> {
apiAuth().then((api)=> {
ensureProjectResourceDoesntExist(api, projectId, Roles, testRoleName)
cy.visit(`/ui/console/projects/${projectId}?id=roles`)
})
})
it('should add a role', () => {
cy.get('[data-e2e="add-new-role"]').click()
cy.get('[formcontrolname="key"]').type(testRoleName)
cy.get('[formcontrolname="displayName"]').type(testRoleDisplay)
cy.get('[formcontrolname="group"]').type(testRoleGroup)
cy.get('[data-e2e="save-button"]').click()
cy.get('.data-e2e-success')
cy.wait(200)
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist')
})
})
})
/*
describe('permissions', () => {
before(()=> {
// cy.consolelogin(Cypress.env('username'), Cypress.env('password'), Cypress.config('baseUrl')/ui/console)
})
it('should show projects ', () => {
cy.visit(Cypress.config('baseUrl')/ui/console + '/projects')
cy.url().should('contain', '/projects')
})
it('should add a role', () => {
cy.visit(Cypress.config('baseUrl')/ui/console + '/org').then(() => {
cy.url().should('contain', '/org');
})
cy.visit(Cypress.config('baseUrl')/ui/console + '/projects').then(() => {
cy.url().should('contain', '/projects');
cy.get('.card').should('contain.text', "newProjectToTest")
})
cy.get('.card').filter(':contains("newProjectToTest")').click()
cy.get('.app-container').filter(':contains("newAppToTest")').should('be.visible').click()
let projectID
cy.url().then(url => {
cy.log(url.split('/')[4])
projectID = url.split('/')[4]
});
cy.then(() => cy.visit(Cypress.config('baseUrl')/ui/console + '/projects/' + projectID +'/roles/create'))
cy.get('[formcontrolname^=key]').type("newdemorole")
cy.get('[formcontrolname^=displayName]').type("newdemodisplayname")
cy.get('[formcontrolname^=group]').type("newdemogroupname")
cy.get('button').filter(':contains("Save")').should('be.visible').click()
//let the Role get processed
cy.wait(5000)
})
it('should add a grant', () => {
cy.visit(Cypress.config('baseUrl')/ui/console + '/org').then(() => {
cy.url().should('contain', '/org');
})
cy.visit(Cypress.config('baseUrl')/ui/console + '/projects').then(() => {
cy.url().should('contain', '/projects');
cy.get('.card').should('contain.text', "newProjectToTest")
})
cy.get('.card').filter(':contains("newProjectToTest")').click()
cy.get('.app-container').filter(':contains("newAppToTest")').should('be.visible').click()
let projectID
cy.url().then(url => {
cy.log(url.split('/')[4])
projectID = url.split('/')[4]
});
cy.then(() => cy.visit(Cypress.config('baseUrl')/ui/console + '/grant-create/project/' + projectID ))
cy.get('input').type("demo")
cy.get('[role^=listbox]').filter(`:contains("${Cypress.env("fullUserName")}")`).should('be.visible').click()
cy.wait(5000)
//cy.get('.button').contains('Continue').click()
cy.get('button').filter(':contains("Continue")').click()
cy.wait(5000)
cy.get('tr').filter(':contains("demo")').find('label').click()
cy.get('button').filter(':contains("Save")').should('be.visible').click()
//let the grant get processed
cy.wait(5000)
})
})
*/

View File

@@ -0,0 +1,71 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureProjectDoesntExist, ensureProjectExists } from '../../support/api/projects';
describe('projects', () => {
const testProjectNameCreate = 'e2eprojectcreate';
const testProjectNameDeleteList = 'e2eprojectdeletelist';
const testProjectNameDeleteGrid = 'e2eprojectdeletegrid';
describe('add project', () => {
beforeEach(`ensure it doesn't exist already`, () => {
apiAuth().then((api) => {
ensureProjectDoesntExist(api, testProjectNameCreate);
});
cy.visit(`/ui/console/projects`);
});
it('should add a project', () => {
cy.get('.add-project-button').click({ force: true });
cy.get('input').type(testProjectNameCreate);
cy.get('[data-e2e="continue-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('remove project', () => {
describe('list view', () => {
beforeEach('ensure it exists', () => {
apiAuth().then((api) => {
ensureProjectExists(api, testProjectNameDeleteList);
});
cy.visit(`/ui/console/projects`);
});
it('removes the project', () => {
cy.get('[data-e2e=toggle-grid]').click();
cy.get('[data-e2e=timestamp]');
cy.contains('tr', testProjectNameDeleteList, { timeout: 1000 })
.find('[data-e2e=delete-project-button]')
.click({force: true});
cy.get('[e2e-data="confirm-dialog-input"]').type(testProjectNameDeleteList);
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
describe('grid view', () => {
beforeEach('ensure it exists', () => {
apiAuth().then((api) => {
ensureProjectExists(api, testProjectNameDeleteGrid);
});
cy.visit(`/ui/console/projects`);
});
it('removes the project', () => {
cy.contains('[data-e2e=grid-card]', testProjectNameDeleteGrid)
.find('[data-e2e=delete-project-button]')
.trigger('mouseover')
.click();
cy.get('[e2e-data="confirm-dialog-input"]').type(testProjectNameDeleteGrid);
cy.get('[e2e-data="confirm-dialog-button"]').click();
cy.get('.data-e2e-success');
cy.wait(200);
cy.get('.data-e2e-failure', { timeout: 0 }).should('not.exist');
});
});
});
});

View File

View File

@@ -0,0 +1,45 @@
import { apiAuth } from "../../support/api/apiauth";
import { ensureHumanUserExists } from "../../support/api/users";
import { login, User } from "../../support/login/users";
describe("login policy", ()=> {
const orgPath = `/ui/console/org`
;[User.OrgOwner].forEach(user => {
describe(`as user "${user}"`, () => {
beforeEach(()=> {
login(user)
cy.visit(orgPath)
// TODO: Why force?
cy.contains('[data-e2e=policy-card]', 'Login Policy').contains('button', 'Modify').click({force: true}) // TODO: select data-e2e
apiAuth().then(api => {
ensureHumanUserExists(api, User.LoginPolicyUser)
})
})
// TODO: verify email
it.skip(`username and password disallowed`, () => {
login(User.LoginPolicyUser, "123abcABC?&*")
})
it(`registering is allowed`)
it(`registering is disallowed`)
it(`login by an external IDP is allowed`)
it(`login by an external IDP is disallowed`)
it(`MFA is forced`)
it(`MFA is not forced`)
it(`the password reset option is hidden`)
it(`the password reset option is shown`)
it(`passwordless login is allowed`)
it(`passwordless login is disallowed`)
describe('identity providers', () => {
})
})
})
})

View File

@@ -0,0 +1,34 @@
import { login, User } from "../../support/login/users";
describe("password complexity", ()=> {
const orgPath = `/ui/console/org`
const testProjectName = 'e2eproject'
;[User.OrgOwner].forEach(user => {
describe(`as user "${user}"`, () => {
beforeEach(()=> {
login(user)
cy.visit(orgPath)
// TODO: Why force?
cy.contains('[data-e2e=policy-card]', 'Password Complexity').contains('button', 'Modify').click({force: true}) // TODO: select data-e2e
})
// TODO: fix saving password complexity policy bug
it(`should restrict passwords that don't have the minimal length`)
it(`should require passwords to contain a number if option is switched on`)
it(`should not require passwords to contain a number if option is switched off`)
it(`should require passwords to contain a symbol if option is switched on`)
it(`should not require passwords to contain a symbol if option is switched off`)
it(`should require passwords to contain a lowercase letter if option is switched on`)
it(`should not require passwords to contain a lowercase letter if option is switched off`)
it(`should require passwords to contain an uppercase letter if option is switched on`)
it(`should not require passwords to contain an uppercase letter if option is switched off`)
})
})
})

View File

@@ -0,0 +1,79 @@
import { apiAuth, apiCallProperties } from '../../support/api/apiauth';
import { Policy, resetPolicy } from '../../support/api/policies';
import { login, User } from '../../support/login/users';
describe('private labeling', () => {
const orgPath = `/ui/console/org`;
[User.OrgOwner].forEach((user) => {
describe(`as user "${user}"`, () => {
let api: apiCallProperties;
beforeEach(() => {
login(user);
cy.visit(orgPath);
// TODO: Why force?
cy.contains('[data-e2e=policy-card]', 'Private Labeling').contains('button', 'Modify').click({ force: true }); // TODO: select data-e2e
});
customize('white', user);
customize('dark', user);
});
});
});
function customize(theme: string, user: User) {
describe(`${theme} theme`, () => {
beforeEach(() => {
apiAuth().then((api) => {
resetPolicy(api, Policy.Label);
});
});
describe.skip('logo', () => {
beforeEach('expand logo category', () => {
cy.contains('[data-e2e=policy-category]', 'Logo').click(); // TODO: select data-e2e
cy.fixture('logo.png').as('logo');
});
it('should update a logo', () => {
cy.get('[data-e2e=image-part-logo]')
.find('input')
.then(function (el) {
const blob = Cypress.Blob.base64StringToBlob(this.logo, 'image/png');
const file = new File([blob], 'images/logo.png', { type: 'image/png' });
const list = new DataTransfer();
list.items.add(file);
const myFileList = list.files;
el[0].files = myFileList;
el[0].dispatchEvent(new Event('change', { bubbles: true }));
});
});
it('should delete a logo');
});
it('should update an icon');
it('should delete an icon');
it.skip('should update the background color', () => {
cy.contains('[data-e2e=color]', 'Background Color').find('button').click(); // TODO: select data-e2e
cy.get('color-editable-input').find('input').clear().type('#ae44dc');
cy.get('[data-e2e=save-colors-button]').click();
cy.get('[data-e2e=header-user-avatar]').click();
cy.contains('Logout All Users').click(); // TODO: select data-e2e
login(User.LoginPolicyUser, undefined, true, null, () => {
cy.pause();
});
});
it('should update the primary color');
it('should update the warning color');
it('should update the font color');
it('should update the font style');
it('should hide the loginname suffix');
it('should show the loginname suffix');
it('should hide the watermark');
it('should show the watermark');
it('should show the current configuration');
it('should reset the policy');
});
}

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,15 @@
import { login, User } from 'support/login/users'
export interface apiCallProperties {
authHeader: string
mgntBaseURL: string
}
export function apiAuth(): Cypress.Chainable<apiCallProperties> {
return login(User.IAMAdminUser, 'Password1!', false, true).then(token => {
return <apiCallProperties>{
authHeader: `Bearer ${token}`,
mgntBaseURL: `/management/v1/`,
}
})
}

View File

@@ -0,0 +1,86 @@
import { apiCallProperties } from "./apiauth"
export function ensureSomethingExists(api: apiCallProperties, searchPath: string, find: (entity: any) => boolean, createPath: string, body: any): Cypress.Chainable<number> {
return searchSomething(api, searchPath, find).then(sRes => {
if (sRes.entity) {
return cy.wrap({
id: sRes.entity.id,
initialSequence: 0
})
}
return cy.request({
method: 'POST',
url: `${api.mgntBaseURL}${createPath}`,
headers: {
Authorization: api.authHeader
},
body: body,
failOnStatusCode: false,
followRedirect: false,
}).then(cRes => {
expect(cRes.status).to.equal(200)
return {
id: cRes.body.id,
initialSequence: sRes.sequence
}
})
}).then((data) => {
awaitDesired(30, (entity) => !!entity, data.initialSequence, api, searchPath, find)
return cy.wrap<number>(data.id)
})
}
export function ensureSomethingDoesntExist(api: apiCallProperties, searchPath: string, find: (entity: any) => boolean, deletePath: (entity: any) => string): Cypress.Chainable<null> {
return searchSomething(api, searchPath, find).then(sRes => {
if (!sRes.entity) {
return cy.wrap(0)
}
return cy.request({
method: 'DELETE',
url: `${api.mgntBaseURL}${deletePath(sRes.entity)}`,
headers: {
Authorization: api.authHeader
},
failOnStatusCode: false
}).then((dRes) => {
expect(dRes.status).to.equal(200)
return sRes.sequence
})
}).then((initialSequence) => {
awaitDesired(30, (entity) => !entity , initialSequence, api, searchPath, find)
return null
})
}
type SearchResult = {
entity: any
sequence: number
}
function searchSomething(api: apiCallProperties, searchPath: string, find: (entity: any) => boolean): Cypress.Chainable<SearchResult> {
return cy.request({
method: 'POST',
url: `${api.mgntBaseURL}${searchPath}`,
headers: {
Authorization: api.authHeader
},
}).then(res => {
return {
entity: res.body.result?.find(find) || null,
sequence: res.body.details.processedSequence
}
})
}
function awaitDesired(trials: number, expectEntity: (entity: any) => boolean, initialSequence: number, api: apiCallProperties, searchPath: string, find: (entity: any) => boolean) {
searchSomething(api, searchPath, find).then(resp => {
if (!expectEntity(resp.entity) || resp.sequence <= initialSequence) {
expect(trials, `trying ${trials} more times`).to.be.greaterThan(0);
cy.wait(1000)
awaitDesired(trials - 1, expectEntity, initialSequence, api, searchPath, find)
}
})
}

View File

@@ -0,0 +1,19 @@
import { apiCallProperties } from "./apiauth"
export enum Policy {
Label = "label"
}
export function resetPolicy(api: apiCallProperties, policy: Policy) {
cy.request({
method: 'DELETE',
url: `${api.mgntBaseURL}/policies/${policy}`,
headers: {
Authorization: api.authHeader
},
}).then(res => {
expect(res.status).to.equal(200)
return null
})
}

View File

@@ -0,0 +1,80 @@
import { apiCallProperties } from "./apiauth"
import { ensureSomethingDoesntExist, ensureSomethingExists } from "./ensure"
export function ensureProjectExists(api: apiCallProperties, projectName: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
`projects/_search`,
(project: any) => project.name === projectName,
'projects',
{ name: projectName },
)
}
export function ensureProjectDoesntExist(api: apiCallProperties, projectName: string): Cypress.Chainable<null> {
return ensureSomethingDoesntExist(
api,
`projects/_search`,
(project: any) => project.name === projectName,
(project) => `projects/${project.id}`,
)
}
class ResourceType {
constructor(
public resourcePath: string,
public compareProperty: string,
public identifierProperty: string,
){}
}
export const Apps = new ResourceType('apps', 'name', 'id')
export const Roles = new ResourceType('roles', 'key', 'key')
//export const Grants = new ResourceType('apps', 'name')
export function ensureProjectResourceDoesntExist(api: apiCallProperties, projectId: number, resourceType: ResourceType, resourceName: string): Cypress.Chainable<null> {
return ensureSomethingDoesntExist(
api,
`projects/${projectId}/${resourceType.resourcePath}/_search`,
(resource: any) => {
return resource[resourceType.compareProperty] === resourceName
},
(resource) => {
return `projects/${projectId}/${resourceType.resourcePath}/${resource[resourceType.identifierProperty]}`
}
)
}
export function ensureApplicationExists(api: apiCallProperties, projectId: number, appName: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
`projects/${projectId}/${Apps.resourcePath}/_search`,
(resource: any) => resource.name === appName,
`projects/${projectId}/${Apps.resourcePath}/oidc`,
{
name: appName,
redirectUris: [
'https://e2eredirecturl.org'
],
responseTypes: [
"OIDC_RESPONSE_TYPE_CODE"
],
grantTypes: [
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"
],
authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE",
postLogoutRedirectUris: [
'https://e2elogoutredirecturl.org'
],
/* "clientId": "129383004379407963@e2eprojectpermission",
"clockSkew": "0s",
"allowedOrigins": [
"https://testurl.org"
]*/
},
)
}

View File

@@ -0,0 +1,49 @@
import { apiCallProperties } from "./apiauth"
import { ensureSomethingDoesntExist, ensureSomethingExists } from "./ensure"
export function ensureHumanUserExists(api: apiCallProperties, username: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
'users/_search',
(user: any) => user.userName === username,
'users/human',
{
user_name: username,
profile: {
first_name: 'e2efirstName',
last_name: 'e2elastName',
},
email: {
email: 'e2e@email.ch',
},
phone: {
phone: '+41 123456789',
},
})
}
export function ensureMachineUserExists(api: apiCallProperties, username: string): Cypress.Chainable<number> {
return ensureSomethingExists(
api,
'users/_search',
(user: any) => user.userName === username,
'users/machine',
{
user_name: username,
name: 'e2emachinename',
description: 'e2emachinedescription',
},
)
}
export function ensureUserDoesntExist(api: apiCallProperties, username: string): Cypress.Chainable<null> {
return ensureSomethingDoesntExist(
api,
'users/_search',
(user: any) => user.userName === username,
(user) => `users/${user.id}`
)
}

View File

@@ -0,0 +1,26 @@
/*
namespace Cypress {
interface Chainable {
*/
/**
* Custom command that authenticates a user.
*
* @example cy.consolelogin('hodor', 'hodor1234')
*/
/* consolelogin(username: string, password: string): void
}
}
Cypress.Commands.add('consolelogin', { prevSubject: false }, (username: string, password: string) => {
window.sessionStorage.removeItem("zitadel:access_token")
cy.visit(Cypress.config('baseUrl')/ui/console).then(() => {
// fill the fields and push button
cy.get('#loginName').type(username, { log: false })
cy.get('#submit-button').click()
cy.get('#password').type(password, { log: false })
cy.get('#submit-button').click()
cy.location('pathname', {timeout: 5 * 1000}).should('eq', '/');
})
})
*/

View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@@ -0,0 +1,12 @@
require('cypress-terminal-report/src/installLogsCollector')();
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}
//import './commands'

View File

@@ -0,0 +1,219 @@
import { debug } from "console";
export enum User {
OrgOwner = 'org_owner',
OrgOwnerViewer = 'org_owner_viewer',
OrgProjectCreator = 'org_project_creator',
LoginPolicyUser = 'login_policy_user',
PasswordComplexityUser = 'password_complexity_user',
IAMAdminUser = 'zitadel-admin',
}
export function login(
user: User,
pw?: string,
force?: boolean,
skipMFAChangePW?: boolean,
onUsernameScreen?: () => void,
onPasswordScreen?: () => void,
onAuthenticated?: () => void,
): Cypress.Chainable<string> {
let creds = credentials(user, pw);
const loginUrl: string = '/ui/login';
const issuerUrl: string = '/oauth/v2';
const otherZitadelIdpInstance: boolean = Cypress.env('otherZitadelIdpInstance');
return cy.session(
creds.username,
() => {
const cookies = new Map<string, string>();
cy.intercept(
{
method: 'GET',
url: `${loginUrl}*`,
times: 1,
},
(req) => {
req.headers['cookie'] = requestCookies(cookies);
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies);
});
},
).as('login');
cy.intercept(
{
method: 'POST',
url: `${loginUrl}/loginname*`,
times: 1,
},
(req) => {
req.headers['cookie'] = requestCookies(cookies);
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies);
});
},
).as('loginName');
cy.intercept(
{
method: 'POST',
url: `${loginUrl}/password*`,
times: 1,
},
(req) => {
req.headers['cookie'] = requestCookies(cookies);
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies);
});
},
).as('password');
cy.intercept(
{
method: 'GET',
url: `${loginUrl}/success*`,
times: 1,
},
(req) => {
req.headers['cookie'] = requestCookies(cookies);
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies);
});
},
).as('success');
cy.intercept(
{
method: 'GET',
url: `${issuerUrl}/authorize/callback*`,
times: 1,
},
(req) => {
req.headers['cookie'] = requestCookies(cookies);
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies);
});
},
).as('callback');
cy.intercept(
{
method: 'GET',
url: `${issuerUrl}/authorize*`,
times: 1,
},
(req) => {
req.continue((res) => {
updateCookies(res.headers['set-cookie'] as string[], cookies);
});
},
);
let userToken: string
cy.intercept({
method: 'POST',
url: `${issuerUrl}/token`,
}, req => {
req.continue(res => {
userToken = res.body["access_token"]}
)
}).as('token')
cy.visit(loginUrl, { retryOnNetworkFailure: true });
otherZitadelIdpInstance && cy.wait('@login');
onUsernameScreen ? onUsernameScreen() : null;
cy.get('#loginName').type(creds.username);
cy.get('#submit-button').click();
otherZitadelIdpInstance && cy.wait('@loginName');
onPasswordScreen ? onPasswordScreen() : null;
cy.get('#password').type(creds.password);
cy.get('#submit-button').click();
cy.wait('@password').then((interception) => {
if (interception.response.body.indexOf('Multifactor Setup') === -1){
return
}
cy.contains('button', 'skip').click()
cy.get('#change-old-password').type(creds.password)
cy.get('#change-new-password').type(creds.password)
cy.get('#change-password-confirmation').type(creds.password)
cy.contains('button', 'next').click()
cy.contains('button', 'next').click()
})
cy.wait('@token').then(() => {
cy.task('safetoken', {key: creds.username, token: userToken})
})
onAuthenticated ? onAuthenticated() : null;
otherZitadelIdpInstance && cy.wait('@callback');
cy.location('pathname', { timeout: 5 * 1000 }).should('eq', '/ui/console/');
},
{
validate: () => {
if (force) {
throw new Error('clear session');
}
},
},
).then(() => {
return cy.task('loadtoken', {key: creds.username})
});
}
export function loginname(withoutDomain: string, org?: string): string {
return `${withoutDomain}@${org}.${host(Cypress.config('baseUrl'))}`;
}
function credentials(user: User, pw?: string) {
// TODO: ugly
const woDomain = user == User.IAMAdminUser ? User.IAMAdminUser : `${user}_user_name`
const org = Cypress.env('ORGANIZATION') ? Cypress.env('ORGANIZATION') : 'zitadel'
return {
username: loginname(woDomain, org),
password: pw ? pw : Cypress.env(`${user}_password`),
};
}
function updateCookies(newCookies: string[] | undefined, currentCookies: Map<string, string>) {
if (newCookies === undefined) {
return;
}
newCookies.forEach((cs) => {
cs.split('; ').forEach((cookie) => {
const idx = cookie.indexOf('=');
currentCookies.set(cookie.substring(0, idx), cookie.substring(idx + 1));
});
});
}
function requestCookies(currentCookies: Map<string, string>): string[] {
let list: Array<string> = [];
currentCookies.forEach((val, key) => {
list.push(key + '=' + val);
});
return list;
}
export function host(url: string): string {
return stripPort(stripProtocol(url));
}
function stripPort(s: string): string {
const idx = s.indexOf(':');
return idx === -1 ? s : s.substring(0, idx);
}
function stripProtocol(url: string): string {
return url.replace('http://', '').replace('https://', '');
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": ["**/*.ts"]
}

63
e2e/docker-compose.yaml Normal file
View File

@@ -0,0 +1,63 @@
version: '3.8'
services:
zitadel:
restart: 'always'
networks:
- 'zitadel'
image: '${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}'
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled'
environment:
ZITADEL_DATABASE_COCKROACH_HOST: db
ZITADEL_EXTERNALSECURE: false
ZITADEL_EXTERNALDOMAIN: zitadel
ZITADEL_FIRSTINSTANCE_CUSTOMDOMAIN: zitadel
depends_on:
db:
condition: 'service_healthy'
ports:
- '8080:8080'
db:
restart: 'always'
networks:
- 'zitadel'
image: 'cockroachdb/cockroach:v22.1.0'
command: 'start-single-node --insecure'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"]
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
ports:
- '9090:8080'
- '26257:26257'
npm-install:
image: node:18-alpine3.15
working_dir: /e2e
volumes:
- .:/e2e
command: "npm ci"
e2e:
image: cypress/included:10.3.0
environment:
CYPRESS_BASE_URL: http://zitadel:8080
depends_on:
zitadel:
condition: 'service_started'
db:
condition: 'service_healthy'
npm-install:
condition: 'service_completed_successfully'
working_dir: /e2e
volumes:
- .:/e2e
networks:
- zitadel
networks:
zitadel:

4704
e2e/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
e2e/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "zitadel-e2e",
"version": "0.0.0",
"scripts": {
"start": "npx cypress open",
"run": "npx cypress run"
},
"private": true,
"dependencies": {
"debug": "^4.3.4",
"jsonwebtoken": "^8.5.1",
"mochawesome": "^7.1.3",
"typescript": "^4.7.4"
}
}

9
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": ["**/*.ts"]
}