Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default defineConfig({
'./cypress/tests/21-*/**/*.ts',
'./cypress/tests/22-*/*.ts',
'./cypress/tests/23-*/*.ts',
'./cypress/tests/24-*/*.cy.ts',
]
return config
},
Expand Down
116 changes: 96 additions & 20 deletions e2e/cypress/pageObjects/keycloakGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,34 @@ class keycloakGroupPage {
attributeValue: string = '[data-testid="attributes-value"]'
saveBtn: string = '[data-testid="attributes-save"]'

private consoleUrl(): string {
private consoleRoot(): string {
const base = Cypress.env('KEYCLOAK_URL')
return `${base}/auth/admin/master/console/`
}

private groupsHash(): string {
const realm = Cypress.env('KEYCLOAK_REALM') || 'master'
return `${base}/auth/admin/master/console/#/${realm}/groups`
return `#/${realm}/groups`
}

private consoleUrl(): string {
return `${this.consoleRoot()}${this.groupsHash()}`
}

visitGroups() {
cy.visit(this.consoleUrl())
cy.get(this.groupSearchInput, { timeout: 20000 }).should('be.visible')
// Cypress skips reload when only the hash changes. Load console root first,
// then set the groups hash so the SPA always remounts the Groups view.
cy.visit(this.consoleRoot())
cy.get('body', { timeout: 20000 }).should('be.visible')
cy.window().then((win) => {
if (win.location.hash !== this.groupsHash()) {
win.location.hash = this.groupsHash()
}
})
cy.location('hash', { timeout: 20000 }).should('include', '/groups')
cy.get(this.groupSearchInput, { timeout: 20000 })
.first()
.should('be.visible')
}

selectTab(tabName: string) {
Expand All @@ -38,25 +57,66 @@ class keycloakGroupPage {
cy.get(this.userGroupsTab, { timeout: 15000 })
.should('be.visible')
.click()
cy.contains('Join Group', { timeout: 15000 }).should('be.visible')
cy.contains('button', 'Join Group', { timeout: 20000 }).should(
'be.visible'
)
}

private dismissOpenDialog() {
cy.get('body').then(($body) => {
if ($body.find('[role="dialog"]').length === 0) {
return
}
cy.get('body').type('{esc}')
cy.get('[role="dialog"]', { timeout: 10000 }).should('not.exist')
})
}

setUserToOrganization(orgName: string) {
cy.contains('Join Group', { timeout: 15000 })
.should('be.visible')
.click()
cy.get(this.joinGroupSearchInput, { timeout: 15000 })
.should('be.visible')
.clear()
.type(orgName)
.type('{enter}')
cy.get(`input[data-testid="${orgName}-check"]`, { timeout: 15000 })
.first()
.should('exist')
.click({ force: true })
cy.get(this.joinButton, { timeout: 10000 })
.should('be.visible')
.click()
const leaveSel = `[data-testid="leave-${orgName}"]`

// Retries can leave the Join Groups modal open, which hides "Join Group".
this.dismissOpenDialog()

cy.get('body').then(($body) => {
if ($body.find(leaveSel).length > 0) {
cy.log(`User already belongs to ${orgName}; skipping join`)
return
}

cy.contains('button', 'Join Group', { timeout: 15000 })
.should('be.visible')
.click()

// Scope to the modal — Keycloak also keeps a hidden/duplicate search input in the page.
cy.get('[role="dialog"]', { timeout: 15000 }).should('be.visible')
cy.get('[role="dialog"] input[placeholder="Search group"]', {
timeout: 15000,
})
.filter(':visible')
.first()
.should('be.visible')
.click()
.type('{selectall}{backspace}')
.type(orgName)
.type('{enter}')

cy.get(
`[role="dialog"] input[data-testid="${orgName}-check"]`,
{ timeout: 15000 }
)
.first()
.should('exist')
.click({ force: true })

cy.get(`[role="dialog"] ${this.joinButton}`, { timeout: 10000 })
.should('be.visible')
.and('not.be.disabled')
.click()

cy.get('[role="dialog"]', { timeout: 10000 }).should('not.exist')
cy.get(leaveSel, { timeout: 15000 }).should('be.visible')
})
}

leaveGroup(orgName: string) {
Expand All @@ -67,6 +127,22 @@ class keycloakGroupPage {
.should('be.visible')
.click()
}

leaveGroupIfPresent(orgName: string) {
this.dismissOpenDialog()
cy.get('body').then(($body) => {
const sel = `[data-testid="leave-${orgName}"]`
if ($body.find(sel).length === 0) {
cy.log(`User is not in ${orgName}; skipping leave`)
return
}
cy.get(sel).should('be.visible').click()
cy.get(this.confirmButton, { timeout: 10000 })
.should('be.visible')
.click()
cy.get(sel).should('not.exist')
})
}
}

export default keycloakGroupPage
32 changes: 27 additions & 5 deletions e2e/cypress/pageObjects/keycloakUsers.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
class keycloakUsersPage {
path: string = '/'

userSearchInput: string = '[data-testid="table-search-input"] input'
// Match Keycloak admin search inputs across Users/Groups table variants
userSearchInput: string =
'[data-testid="table-search-input"] input[type="search"], [data-testid="table-search-input"] input[type="text"], [data-testid="table-search-input"] input'
userTab: string = '[data-ng-controller="UserTabCtrl"]'

private consoleUrl(): string {
private consoleRoot(): string {
const base = Cypress.env('KEYCLOAK_URL')
return `${base}/auth/admin/master/console/`
}

private usersHash(): string {
const realm = Cypress.env('KEYCLOAK_REALM') || 'master'
return `${base}/auth/admin/master/console/#/${realm}/users`
return `#/${realm}/users`
}

private consoleUrl(): string {
return `${this.consoleRoot()}${this.usersHash()}`
}

visitUsers() {
cy.visit(this.consoleUrl())
cy.get(this.userSearchInput, { timeout: 20000 }).should('be.visible')
// Cypress skips reload when only the hash changes (e.g. Groups -> Users).
// Load the console root first, then set the users hash so the SPA navigates.
cy.visit(this.consoleRoot())
cy.get('body', { timeout: 20000 }).should('be.visible')
cy.window().then((win) => {
if (win.location.hash !== this.usersHash()) {
win.location.hash = this.usersHash()
}
})
cy.location('hash', { timeout: 20000 }).should('include', '/users')
cy.get(this.userSearchInput, { timeout: 20000 })
.first()
.should('be.visible')
}

selectTab(tabName: string) {
Expand All @@ -21,6 +42,7 @@ class keycloakUsersPage {

editUser(userName: string) {
cy.get(this.userSearchInput, { timeout: 20000 })
.first()
.should('be.visible')
.clear()
.type(userName)
Expand Down
5 changes: 3 additions & 2 deletions e2e/cypress/support/auth-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,9 @@ Cypress.Commands.add('logout', () => {

Cypress.Commands.add('keycloakLogout', () => {
cy.log('< Logging out')
cy.get('[data-testid=options-toggle]').click()
cy.contains('Sign out').click()
// Success/error toasts often cover the kebab menu after group membership changes
cy.get('[data-testid=options-toggle]').click({ force: true })
cy.contains('Sign out').click({ force: true })
cy.log('> Logging out')
})

Expand Down
116 changes: 55 additions & 61 deletions e2e/cypress/tests/14-org-assignment/04-multiple-org-admin-org-unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,83 +31,75 @@ describe('Give a user org admin access at organization unit level', () => {
})

it('Add another org unit', () => {
const parentGroupName = 'ministry-of-health'
const parentPath = 'organization-admin/ca.bc.gov/ministry-of-health'
const newGroupName = 'health-protection'

let authToken: string = ''
let parentGroupId: string = ''
let baseUrl: string = ''

// Intercept API calls to capture Bearer token from request headers
cy.intercept('GET', '**/groups/**', (req) => {
if (req.headers['authorization']) {
const authHeader = req.headers['authorization'] as string
if (authHeader.startsWith('Bearer ')) {
authToken = authHeader.replace('Bearer ', '')
}
let authToken = ''
let baseUrl = ''

// Capture admin bearer token from any admin API call
cy.intercept('GET', '**/admin/realms/**', (req) => {
const authHeader = req.headers['authorization']
if (
typeof authHeader === 'string' &&
authHeader.startsWith('Bearer ')
) {
authToken = authHeader.replace('Bearer ', '')
}
const baseUrlMatch = req.url.match(/^(https?:\/\/[^/]+)/)
if (baseUrlMatch) {
baseUrl = baseUrlMatch[1]
}
req.continue()
}).as('groupsApi')
}).as('adminApi')

// Navigate to groups and click on parent group to trigger API call
// Trigger an authenticated admin call from the Groups UI
cy.get(groups.groupSearchInput, { timeout: 20000 })
.first()
.should('be.visible')
.clear()
.type(parentGroupName)
.type('ministry-of-health')
.type('{enter}')
cy.get('button', { timeout: 15000 })
.contains(parentGroupName)
.should('be.visible')
.click()

// Wait for API call and extract group ID and base URL from intercepted request
cy.wait('@groupsApi', { timeout: 10000 }).then((interception: any) => {
const url = interception.request.url
// Extract group ID from URL: /groups/{id}/children
const groupIdMatch = url.match(/\/groups\/([a-f0-9-]+)/)
if (groupIdMatch && groupIdMatch[1]) {
parentGroupId = groupIdMatch[1]
}

// Extract base URL from intercepted request (e.g., http://keycloak.localtest.me:9081)
const baseUrlMatch = url.match(/^(https?:\/\/[^\/]+)/)
if (baseUrlMatch && baseUrlMatch[1]) {
baseUrl = baseUrlMatch[1]
}
})
cy.wait('@adminApi', { timeout: 15000 })

// Create the child group via API
cy.then(() => {
if (!authToken) {
throw new Error('Could not retrieve Bearer token')
}

if (!parentGroupId) {
throw new Error(`Could not find parent group ID for ${parentGroupName}`)
}

if (!baseUrl) {
throw new Error('Could not extract base URL from intercepted request')
}

// Construct API URL using base URL from intercepted request
const apiUrl = `${baseUrl}/auth/admin/realms/master/groups/${parentGroupId}/children`
expect(authToken, 'Keycloak admin bearer token').to.be.a('string').and
.not.be.empty
expect(baseUrl, 'Keycloak base URL').to.be.a('string').and.not.be.empty

// Resolve parent by path so we never create health-protection under the wrong group
cy.request({
method: 'POST',
url: apiUrl,
method: 'GET',
url: `${baseUrl}/auth/admin/realms/master/group-by-path/${parentPath}`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: {
name: newGroupName,
description: '',
},
}).then((createResponse) => {
expect(createResponse.status).to.be.oneOf([200, 201])
cy.log(`Successfully created group ${newGroupName} via API`)
}).then((parentRes) => {
expect(parentRes.status).to.eq(200)
const parentGroupId = parentRes.body.id
expect(parentGroupId, 'ministry-of-health group id').to.be.a('string')

cy.request({
method: 'POST',
url: `${baseUrl}/auth/admin/realms/master/groups/${parentGroupId}/children`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: {
name: newGroupName,
description: '',
},
failOnStatusCode: false,
}).then((createResponse) => {
// 409 when the org unit already exists from a previous run
expect(createResponse.status).to.be.oneOf([200, 201, 409])
cy.log(
`Create group ${newGroupName} under ${parentPath} -> ${createResponse.status}`
)
})
})
})
})
Expand All @@ -127,7 +119,9 @@ describe('Give a user org admin access at organization unit level', () => {
})

it('Leave existing org unit', () => {
groups.leaveGroup('ministry-of-health')
// From 02 Wendy is in ministry-of-health; on re-runs she may already be in health-protection
groups.leaveGroupIfPresent('ministry-of-health')
groups.leaveGroupIfPresent('health-protection')
})

it('Set the user(Wendy) to the Organization Unit', () => {
Expand Down
Loading
Loading