From d11f233be6277dfa773560be694346da663773ed Mon Sep 17 00:00:00 2001 From: PK Jacob Date: Mon, 1 Jun 2026 16:54:23 -0400 Subject: [PATCH 1/3] Keycloak upgrade test - POC --- keycloak-upgrade/README.md | 46 +++ keycloak-upgrade/pom.xml | 47 +++ .../features/destroy-upgrade-data.feature | 12 + .../tenant-and-local-admin-setup.feature | 283 ++++++++++++++++++ .../features/seed-before-upgrade.feature | 110 +++++++ .../seed-consortia-before-upgrade.feature | 54 ++++ .../features/verify-after-upgrade.feature | 121 ++++++++ .../verify-consortia-after-upgrade.feature | 56 ++++ .../src/main/resources/karate-config.js | 117 ++++++++ .../org/folio/KeycloakUpgradeSeedTests.java | 28 ++ .../org/folio/KeycloakUpgradeTenantState.java | 147 +++++++++ .../org/folio/KeycloakUpgradeVerifyTests.java | 38 +++ 12 files changed, 1059 insertions(+) create mode 100644 keycloak-upgrade/README.md create mode 100644 keycloak-upgrade/pom.xml create mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature create mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature create mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature create mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature create mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature create mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature create mode 100644 keycloak-upgrade/src/main/resources/karate-config.js create mode 100644 keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeSeedTests.java create mode 100644 keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java create mode 100644 keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeVerifyTests.java diff --git a/keycloak-upgrade/README.md b/keycloak-upgrade/README.md new file mode 100644 index 0000000000..a9b4895ec1 --- /dev/null +++ b/keycloak-upgrade/README.md @@ -0,0 +1,46 @@ +# Keycloak Upgrade Integration Tests + +This module verifies that data created with the previous Keycloak image still works after restarting the same environment with a newer `folio-keycloak` image. + +The test is intentionally split into two runners because the Keycloak container must be replaced between phases while preserving the Keycloak database. + +## Run + +When no state file exists, the seed phase generates random central/member tenant names and ids, then writes them to `target/keycloak-upgrade-tenant.properties`. The verify phase reads the same file. + +If the seed phase fails after tenant creation, remove `target/keycloak-upgrade-tenant.properties` to generate a new tenant, or clean up the failed tenant before rerunning the seed phase. + +Start the environment with the old Keycloak image, then seed upgrade data: + +```shell +mvn clean test -pl common,common-consortia,testrail-integration,keycloak-upgrade \ + -DfailIfNoTests=false \ + -Dsurefire.failIfNoSpecifiedTests=false \ + -Dtest=KeycloakUpgradeSeedTests \ + -DargLine="-Dkarate.env=eureka1" +``` + +Restart Keycloak with the upgraded image against the same database, wait for Keycloak and Kong to become ready, then verify: + +```shell +mvn test -pl common,common-consortia,testrail-integration,keycloak-upgrade \ + -DfailIfNoTests=false \ + -Dsurefire.failIfNoSpecifiedTests=false \ + -Dtest=KeycloakUpgradeVerifyTests \ + -DargLine="-Dkarate.env=eureka1" +``` + +To use an explicit state file location, add `-Dkeycloak.upgrade.stateFile=/path/to/keycloak-upgrade-tenant.properties` to both `argLine` values. + +To use an explicit central tenant instead of the generated one, add both `-DtestTenant=` and `-DtestTenantId=` to the seed command. The seed phase still writes all fixture ids to the state file for the verify command. + +## What It Covers + +- Tenant authentication state survives the Keycloak restart. +- Existing FOLIO user and credentials still work. +- Login, refresh, user lookup, and effective permission resolution still work through FOLIO APIs. +- FOLIO-issued tokens still contain the expected `sub`, `user_id`, and client claims. +- Tenant metadata can still be updated through the FOLIO tenant manager API. +- Role/capability assignments created before the upgrade still resolve after the upgrade. +- Consortia non-primary affiliation deletion/re-addition still drives shadow user deactivation/reactivation through Keycloak-aware user updates. +- The verify phase cleans up the generated central and member tenants after a successful run. diff --git a/keycloak-upgrade/pom.xml b/keycloak-upgrade/pom.xml new file mode 100644 index 0000000000..8e48645a0f --- /dev/null +++ b/keycloak-upgrade/pom.xml @@ -0,0 +1,47 @@ + + + + + folio-integration-tests + org.folio + 1.0-SNAPSHOT + + 4.0.0 + + keycloak-upgrade + 1.0-SNAPSHOT + jar + + + + org.folio + common + 1.0-SNAPSHOT + + + org.folio + common-consortia + 1.0-SNAPSHOT + + + org.folio + testrail-integration + 1.0-SNAPSHOT + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + + + diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature new file mode 100644 index 0000000000..5d11d2bf97 --- /dev/null +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature @@ -0,0 +1,12 @@ +Feature: destroy Keycloak upgrade test data + + Background: + * url baseUrl + * configure readTimeout = 3000000 + + Scenario: delete member and central tenants + # Delete the member tenant first because it depends on the central consortium tenant. + * call read('classpath:common/eureka/destroy-data.feature') { testTenantId: '#(memberTenantId)' } + + # Delete the central tenant last. + * call read('classpath:common/eureka/destroy-data.feature') { testTenantId: '#(centralTenantId)' } diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature new file mode 100644 index 0000000000..e60966cde7 --- /dev/null +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature @@ -0,0 +1,283 @@ +Feature: Keycloak upgrade consortia tenant setup + + Background: + * url baseUrl + * configure readTimeout = 3000000 + * configure retry = { count: 40, interval: 15000 } + * def queryParam = function(field, values) { return '(' + field + '==(' + values.map(x => '"' + x + '"').join(' or ') + '))' } + + * table requiredModulesForConsortia + | name | + | 'mod-tags' | + | 'mod-users-bl' | + | 'mod-password-validator' | + | 'folio_users' | + | 'mod-consortia-keycloak' | + + * table requiredCapabilitiesForConsortia + | name | + | 'consortia.consortia-configuration.item.post' | + | 'consortia.consortia-configuration.item.delete' | + | 'consortia.consortium.item.post' | + | 'consortia.consortium.item.put' | + | 'consortia.consortium.item.get' | + | 'consortia.create-primary-affiliations.item.post' | + | 'consortia.custom-login.item.post' | + | 'consortia.identity-provider.item.post' | + | 'consortia.identity-provider.item.delete' | + | 'consortia.inventory.local.sharing-instances.execute' | + | 'consortia.inventory.update-ownership.item.post' | + | 'consortia.publications.item.post' | + | 'consortia.publications.item.delete' | + | 'consortia.publications.item.get' | + | 'consortia.publications-results.item.get' | + | 'consortia.sharing-instances.collection.get' | + | 'consortia.sharing-instances.item.post' | + | 'consortia.sharing-instances.item.get' | + | 'consortia.sharing-policies.item.post' | + | 'consortia.sharing-policies.item.delete' | + | 'consortia.sharing-roles-all.item.post' | + | 'consortia.sharing-roles-all.item.delete' | + | 'consortia.sharing-roles-capabilities.item.post' | + | 'consortia.sharing-roles-capabilities.item.delete' | + | 'consortia.sharing-roles-capability-sets.item.post' | + | 'consortia.sharing-roles-capability-sets.item.delete' | + | 'consortia.sharing-roles.item.post' | + | 'consortia.sharing-roles.item.delete' | + | 'consortia.sharing-settings.item.post' | + | 'consortia.sharing-settings.item.delete' | + | 'consortia.sync-primary-affiliations.item.post' | + | 'consortia.tenants.item.post' | + | 'consortia.tenants.item.delete' | + | 'consortia.tenants.item.put' | + | 'consortia.tenants.item.get' | + | 'consortia.user-tenants.collection.get' | + | 'consortia.user-tenants.item.post' | + | 'consortia.user-tenants.item.delete' | + | 'consortia.user-tenants.item.get' | + | 'tags.collection.get' | + | 'tags.item.post' | + | 'tags.item.delete' | + | 'tags.item.put' | + | 'tags.item.get' | + + @SetupTenant + Scenario: create tenant, enable applications, create admin, and assign capabilities + * def description = 'tenant_description' + * def oldModules = modules + * def modules = modules.concat(requiredModulesForConsortia) + * def oldPermissions = (typeof userPermissions !== 'undefined') ? userPermissions : [] + * def userPermissions = requiredCapabilitiesForConsortia.concat(oldPermissions) + + # Create the tenant and enable required applications. + * call read('classpath:common/eureka/tenant.feature@create') { tenantId: '#(tenantId)', tenantName: '#(tenant)' } + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@InstallApplications') { tenantId: '#(tenantId)', modules: '#(modules)' } + + # Create a local admin and grant the capabilities needed by consortia setup and verification. + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@PostAdmin') { tenant: '#(tenant)', user: '#(user)' } + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@PutCaps') { tenant: '#(tenant)', user: '#(user)', userPermissions: '#(userPermissions)' } + + * def userPermissions = oldPermissions + * def modules = oldModules + + @InstallApplications + Scenario: enable applications in tenant + * def testTenantId = tenantId + * def keycloakResponse = call read('classpath:common/eureka/keycloak.feature@getKeycloakMasterToken') + * def keycloakMasterToken = keycloakResponse.response.access_token + * call read('classpath:common/eureka/application.feature@applicationSearch') { modules: '#(modules)' } + * def entitlementTemplate = read('classpath:common/eureka/samples/entitlement-entity.json') + * def loadReferenceRecords = karate.get('tenantParams', { loadReferenceData: false }).loadReferenceData + * def centralTenantIdValue = karate.get('centralTenantId') + * def tenantParameters = 'loadSample=false,loadReference=' + loadReferenceRecords + (centralTenantIdValue ? ',centralTenantId=' + centralTenantIdValue : '') + + Given path 'entitlements' + And param tenantParameters = tenantParameters + And param async = true + And param purgeOnRollback = false + And request entitlementTemplate + And header Authorization = 'Bearer ' + keycloakMasterToken + And header x-okapi-token = keycloakMasterToken + When method POST + * def flowId = response.flowId + + * configure retry = { count: 40, interval: 30000 } + Given path 'entitlement-flows', flowId + And header Authorization = 'Bearer ' + keycloakMasterToken + And retry until response.status == 'finished' || response.status == 'cancelled' || response.status == 'cancellation_failed' || response.status == 'failed' + When method GET + * def failCondition = response.status + * if (failCondition == 'cancelled' || failCondition == 'cancellation_failed' || failCondition == 'failed') karate.fail('Entitlement creation failed.') + + @SetupConsortia + Scenario: create consortium record + * def consortiumName = tenant + 'name for test' + + Given path 'consortia' + And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } + And request { id: '#(consortiumId)', name: '#(consortiumName)' } + When method POST + * def createStatus = responseStatus + * if (createStatus != 201 && createStatus != 409) karate.fail('Failed to create consortium. Status: ' + createStatus) + * def consortiumRecord = createStatus == 201 ? response : { id: consortiumId, name: consortiumName } + And match consortiumRecord == { id: '#(consortiumId)', name: '#(consortiumName)' } + + @SetupTenantForConsortia + Scenario: create tenant for consortium + * def name = tenant + ' tenants name' + + Given path 'consortia', consortiumId, 'tenants' + And param adminUserId = consortiaAdmin.id + And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } + And request { id: '#(tenant)', code: '#(code)', name: '#(name)', isCentral: '#(isCentral)' } + When method POST + * def createStatus = responseStatus + * if (createStatus != 201 && createStatus != 409) karate.fail('Failed to create consortium tenant ' + tenant + '. Status: ' + createStatus) + * def tenantRecord = createStatus == 201 ? response : karate.call('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@GetTenantForConsortia', { tenant: tenant }).response + And match tenantRecord contains { id: '#(tenant)', code: '#(code)', name: '#(name)', isCentral: '#(isCentral)', isDeleted: false } + + # Wait until mod-consortia finishes setup for both newly-created and already-existing tenants. + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@WaitTenantConsortiaSetup') { tenant: '#(tenant)' } + + @GetTenantForConsortia + Scenario: get consortium tenant record + Given path 'consortia', consortiumId, 'tenants', tenant + And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } + When method GET + Then status 200 + + @WaitTenantConsortiaSetup + Scenario: wait for consortium tenant setup to complete + Given path 'consortia', consortiumId, 'tenants', tenant + And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } + And retry until response.setupStatus == 'COMPLETED' + When method GET + Then status 200 + And match response.id == tenant + + @getAuthorizationToken + Scenario: get module token for tenant + * configure headers = null + * def keycloakResponse = call read('classpath:common/eureka/keycloak.feature@getKeycloakMasterToken') + * def keycloakMasterToken = keycloakResponse.response.access_token + * def m2mClientId = karate.get('m2mClientId', 'sidecar-module-access-client') + + Given url baseKeycloakUrl + And path 'admin', 'realms', tenant, 'clients' + And header Authorization = 'Bearer ' + keycloakMasterToken + When method GET + Then status 200 + * def client = response.filter(x => x.clientId == m2mClientId)[0] + * if (client == null) karate.fail('Missing Keycloak client ' + m2mClientId + ' in tenant ' + tenant) + * def clientId = client.id + + Given url baseKeycloakUrl + And path 'admin', 'realms', tenant, 'clients', clientId, 'client-secret' + And header Authorization = 'Bearer ' + keycloakMasterToken + When method GET + Then status 200 + * def sidecarSecret = response.value + + * configure headers = null + Given url baseKeycloakUrl + And path 'realms', tenant, 'protocol', 'openid-connect', 'token' + And header Content-Type = 'application/x-www-form-urlencoded' + And form field grant_type = 'client_credentials' + And form field client_id = m2mClientId + And form field client_secret = sidecarSecret + And form field scope = 'email openid' + When method post + Then status 200 + * def okapitoken = response.access_token + * configure headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authtoken-Refresh-Cache': 'true' } + + @PostAdmin + Scenario: create an admin with credentials + * def tokenResult = call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@getAuthorizationToken') { tenant: '#(tenant)' } + * def okapitoken = tokenResult.okapitoken + + Given path 'users' + And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } + And request + """ + { + id: '#(user.id)', + username: '#(user.username)', + active: true, + barcode: '#(uuid())', + externalSystemId: '#(uuid())', + personal: { + email: 'admin@gmail.com', + firstName: 'admin first name', + lastName: 'admin last name', + preferredContactTypeId: '002', + phone: '#(user.phone)', + mobilePhone: '#(user.mobilePhone)' + } + } + """ + When method POST + Then status 201 + + Given path 'authn', 'credentials' + And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } + And request { username: '#(user.username)', password: '#(user.password)', userId: '#(user.id)' } + When method POST + Then status 201 + + @PostUser + Scenario: create a user with credentials + * def tokenResult = call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@getAuthorizationToken') { tenant: '#(tenant)' } + * def okapitoken = tokenResult.okapitoken + + Given path 'users' + And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } + And request + """ + { + id: '#(user.id)', + username: '#(user.username)', + active: true, + barcode: '#(uuid())', + externalSystemId: '#(uuid())', + type: '#(user.type)', + personal: { + email: 'user@gmail.com', + firstName: 'user first name', + lastName: 'user last name', + preferredContactTypeId: '002', + phone: '#(user.phone)', + mobilePhone: '#(user.mobilePhone)' + } + } + """ + When method POST + Then status 201 + + Given path 'authn', 'credentials' + And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } + And request { username: '#(user.username)', password: '#(user.password)', userId: '#(user.id)' } + When method POST + Then status 201 + + @PutCaps + Scenario: assign capabilities to user + * def tokenResult = call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@getAuthorizationToken') { tenant: '#(tenant)' } + * def okapitoken = tokenResult.okapitoken + * def permissions = $userPermissions[*].name + + Given path 'capabilities' + And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } + And param query = queryParam('permission', permissions) + And param limit = permissions.length + And retry until response.capabilities && response.capabilities.length == permissions.length + When method GET + Then status 200 + * def capabilityIds = karate.map(response.capabilities, x => x.id) + * if (capabilityIds.length != permissions.length) karate.fail('Not all capabilities found. Expected ' + permissions.length + ', found ' + capabilityIds.length) + + Given path 'users', 'capabilities' + And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } + And request { userId: '#(user.id)', capabilityIds: '#(capabilityIds)' } + When method POST + Then status 201 diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature new file mode 100644 index 0000000000..c2b1fa6b99 --- /dev/null +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature @@ -0,0 +1,110 @@ +Feature: seed data before Keycloak upgrade + + Background: + * url baseUrl + * configure cookies = null + * configure retry = { count: 20, interval: 5000 } + + * table modules + | name | + | 'mod-users-keycloak' | + | 'mod-login-keycloak' | + | 'mod-roles-keycloak' | + + * table userPermissions + | name | + | 'login.item.post' | + | 'auth.token.post' | + | 'auth.refreshtoken.post' | + | 'auth.token.sign.post' | + | 'auth.token.refresh.post' | + | 'users.item.get' | + | 'users-keycloak.item.get' | + | 'roles.item.get' | + | 'roles.item.delete' | + | 'roles.item.post' | + | 'roles.collection.get' | + | 'roles.users.item.get' | + | 'roles.users.item.post' | + | 'roles.users.collection.get' | + | 'capabilities.item.get' | + | 'capabilities.collection.get' | + | 'role-capabilities.collection.post' | + | 'role-capabilities.collection.get' | + | 'permissions.users.item.get' | + + Scenario: create tenant, user credentials, consortia affiliation, and role data before upgrade + # Seed central/member tenants and the consortia affiliation fixture used by post-upgrade verification. + * call read('classpath:eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature') + + # Create the test user, assign credentials, and grant the permissions needed for verification. + * configure headers = null + * call read('classpath:common/eureka/setup-users.feature@getAuthorizationToken') + * call read('classpath:common/eureka/setup-users.feature@createTestUser') + * call read('classpath:common/eureka/setup-users.feature@specifyUserCredentials') + * call read('classpath:common/eureka/setup-users.feature@addUserCapabilities') + * def testUserId = karate.get('userId') + + # Verify the user can authenticate before the Keycloak upgrade. + Given path 'authn', 'login' + And headers { 'Content-Type': 'application/json', 'x-okapi-tenant': '#(testTenant)' } + And request { username: '#(testUser.name)', password: '#(testUser.password)' } + When method post + Then status 201 + And match response.okapiToken == '#string' + And match response.refreshToken == '#string' + And match responseCookies contains { folioAccessToken: '#present', folioRefreshToken: '#present' } + * def userToken = response.okapiToken + * def jwt = decodeJwtPayload(userToken) + And match jwt.sub == testUser.name + And match jwt.user_id == testUserId + + # Resolve a known capability that will be attached to a role before the upgrade. + * configure headers = { 'Content-Type': 'application/json', 'x-okapi-token': '#(userToken)', 'Accept': '*/*', 'x-okapi-tenant': '#(testTenant)' } + Given path 'capabilities' + And param query = 'permission=="' + upgradeRolePermission + '"' + When method get + Then status 200 + And match response.capabilities == '#[1]' + * def upgradeCapability = response.capabilities[0] + + # Create a durable role fixture that should survive the Keycloak upgrade. + Given path 'roles' + And request { name: '#(upgradeRoleName)', description: '#(upgradeRoleDescription)', type: 'REGULAR' } + When method post + Then status 201 + And match response.name == upgradeRoleName + * def upgradeRoleId = response.id + + # Attach the capability to the role so post-upgrade access resolution can be verified. + Given path 'roles', 'capabilities' + And request { roleId: '#(upgradeRoleId)', capabilityIds: ['#(upgradeCapability.id)'] } + When method post + Then status 201 + And match response.roleCapabilities[*].capabilityId contains upgradeCapability.id + + # Assign the role to the test user before the upgrade. + Given path 'roles', 'users' + And request { userId: '#(testUserId)', roleIds: ['#(upgradeRoleId)'] } + When method post + Then status 201 + And match response.userRoles[*].roleId contains upgradeRoleId + + # Confirm effective permissions include the role capability before the upgrade. + Given path 'permissions', 'users', testUserId + When method get + Then status 200 + And match response.permissions contains upgradeRolePermission + + # Verify module-to-module authentication can read the seeded user before the upgrade. + * configure headers = null + * configure cookies = null + * call read('classpath:common/eureka/setup-users.feature@getAuthorizationToken') + * def m2mToken = karate.get('accessToken') + Given url baseUrl + And path 'users-keycloak', 'users', testUserId + And headers { 'x-okapi-tenant': '#(testTenant)', 'x-okapi-token': '#(m2mToken)' } + When method get + Then status 200 + And match response.id == testUserId + And match response.username == testUser.name diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature new file mode 100644 index 0000000000..423de03d58 --- /dev/null +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature @@ -0,0 +1,54 @@ +Feature: seed consortia data before Keycloak upgrade + + Background: + * url baseUrl + * configure cookies = null + * configure retry = { count: 20, interval: 5000 } + + Scenario: create central and member tenant with non-primary affiliation before upgrade + # Create the central tenant and enable applications required by the upgrade smoke and consortia flows. + * def setupTenant = read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupTenant') + * call setupTenant { tenantId: '#(centralTenantId)', tenant: '#(centralTenant)', user: '#(consortiaAdmin)' } + + # Create a member tenant in the same consortium test fixture. + * call setupTenant { tenantId: '#(memberTenantId)', tenant: '#(memberTenant)', user: '#(memberAdmin)' } + + # Log in as the central consortia admin to create consortium records. + * call read('classpath:common-consortia/eureka/initData.feature@Login') consortiaAdmin + + # Create the consortium record before the upgrade. + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupConsortia') { tenant: '#(centralTenant)' } + + # Register the central and member tenants in the consortium before the upgrade. + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupTenantForConsortia') { tenant: '#(centralTenant)', code: 'cntrl', isCentral: true } + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupTenantForConsortia') { tenant: '#(memberTenant)', code: 'mbr', isCentral: false } + + # Create a central user whose affiliation and shadow user will be verified after the upgrade. + * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@PostUser') { tenant: '#(centralTenant)', user: '#(consortiaUser)' } + + # Verify the central user's primary affiliation was created before the upgrade. + * def primaryAffiliationQuery = { userId: '#(consortiaUser.id)', tenantId: '#(centralTenant)' } + Given path 'consortia', consortiumId, 'user-tenants' + And params query = primaryAffiliationQuery + And headers { 'x-okapi-tenant': '#(centralTenant)', 'x-okapi-token': '#(okapitoken)' } + And retry until response.totalRecords == 1 + When method get + Then status 200 + And match response.userTenants[0].userId == consortiaUser.id + And match response.userTenants[0].tenantId == centralTenant + And match response.userTenants[0].isPrimary == true + + # Create the member non-primary affiliation before the upgrade. + * call read('classpath:common-consortia/eureka/affiliation.feature@AddAffiliation') { tenant: '#(memberTenant)', user: '#(consortiaUser)' } + + # Verify the member shadow user exists before the upgrade. + * call read('classpath:common-consortia/eureka/initData.feature@Login') memberAdmin + Given path 'users', consortiaUser.id + And headers { 'x-okapi-tenant': '#(memberTenant)', 'x-okapi-token': '#(okapitoken)' } + And retry until response.active == true + When method get + Then status 200 + And match response.id == consortiaUser.id + And match response.username contains consortiaUser.username + And match response.type == 'shadow' + And match response.customFields.originaltenantid == centralTenant diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature new file mode 100644 index 0000000000..e32a0d5ebc --- /dev/null +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature @@ -0,0 +1,121 @@ +Feature: verify data after Keycloak upgrade + + Background: + * url baseUrl + * configure cookies = null + * configure retry = { count: 20, interval: 5000 } + + Scenario: verify pre-upgrade tenant data through FOLIO APIs after restart + # Verify the pre-upgrade user can still authenticate through the FOLIO login API. + * configure cookies = null + Given path 'authn', 'login' + And headers { 'Content-Type': 'application/json', 'x-okapi-tenant': '#(testTenant)' } + And request { username: '#(testUser.name)', password: '#(testUser.password)' } + When method post + Then status 201 + And match response contains { okapiToken: '#string', refreshToken: '#string' } + And match responseCookies contains { folioAccessToken: '#present', folioRefreshToken: '#present' } + * def loginToken = response.okapiToken + * def refreshToken = responseCookies.folioRefreshToken.value + * def jwt = decodeJwtPayload(loginToken) + And match jwt.sub == testUser.name + And match jwt.user_id == '#uuid' + And match jwt.azp == '#string' + * def testUserId = jwt.user_id + + # Verify cross-module access resolution through sidecars: + # users-keycloak returns user data and effective permissions resolved from roles-keycloak. + * configure headers = { 'Content-Type': 'application/json', 'x-okapi-token': '#(loginToken)', 'Accept': '*/*', 'x-okapi-tenant': '#(testTenant)' } + Given path 'users-keycloak', '_self' + When method get + Then status 200 + And match response.user.id == testUserId + And match response.user.username == testUser.name + And match response.permissions.permissions contains upgradeRolePermission + + # Verify the pre-upgrade user can still be read by id. + Given path 'users-keycloak', 'users', testUserId + When method get + Then status 200 + And match response.id == testUserId + And match response.username == testUser.name + + # Verify the pre-upgrade role fixture still exists. + Given path 'roles' + And param query = 'name=="' + upgradeRoleName + '"' + When method get + Then status 200 + And match response.roles == '#[1]' + * def upgradeRoleId = response.roles[0].id + + # Verify the role still has the capability assigned before the upgrade. + Given path 'roles', upgradeRoleId, 'capabilities' + When method get + Then status 200 + And match response.capabilities[*].permission contains upgradeRolePermission + + # Verify effective permissions still include access contributed by the pre-upgrade role. + Given path 'permissions', 'users', testUserId + When method get + Then status 200 + And match response.permissions contains upgradeRolePermission + + # Verify consortia affiliation and shadow-user fixtures through FOLIO APIs. + * call read('classpath:eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature') + + # Verify refresh-token rotation still works after the upgrade. + * configure headers = null + * configure cookies = null + Given url baseUrl + And path 'authn', 'refresh' + And header x-okapi-tenant = testTenant + And cookie folioRefreshToken = refreshToken + When method post + Then status 201 + And match responseCookies.folioAccessToken == '#present' + And match responseCookies.folioRefreshToken == '#present' + * def refreshedToken = responseCookies.folioAccessToken.value + * def refreshedJwt = decodeJwtPayload(refreshedToken) + And match refreshedJwt.sub == testUser.name + And match refreshedJwt.user_id == testUserId + + # Verify the refreshed access token can be used against FOLIO APIs. + * configure headers = { 'Content-Type': 'application/json', 'x-okapi-token': '#(refreshedToken)', 'Accept': '*/*', 'x-okapi-tenant': '#(testTenant)' } + Given path 'users-keycloak', 'users', testUserId + When method get + Then status 200 + And match response.username == testUser.name + + # Verify tenant metadata can still be updated through the FOLIO tenant manager API. + * configure headers = null + * configure cookies = null + # Use the manager token only to authorize the FOLIO tenant manager request. + * def managerTokenResponse = call read('classpath:common/eureka/keycloak.feature@getKeycloakMasterToken') + * def managerToken = managerTokenResponse.response.access_token + + # Read the existing tenant before updating it. + Given path 'tenants', testTenantId + And header Authorization = 'Bearer ' + managerToken + When method get + Then status 200 + And match response.id == testTenantId + And match response.name == testTenant + * def tenant = response + * def updatedDescription = 'Updated by Keycloak upgrade IT ' + uuid() + * set tenant.description = updatedDescription + + # Update only the tenant description and verify the update response. + Given path 'tenants', testTenantId + And header Authorization = 'Bearer ' + managerToken + And header Content-Type = 'application/json' + And request tenant + When method put + Then status 200 + And match response.description == updatedDescription + + # Read the tenant again to confirm the description was persisted. + Given path 'tenants', testTenantId + And header Authorization = 'Bearer ' + managerToken + When method get + Then status 200 + And match response.description == updatedDescription diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature new file mode 100644 index 0000000000..b070f5cc75 --- /dev/null +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature @@ -0,0 +1,56 @@ +Feature: verify consortia Keycloak-relevant behavior after Keycloak upgrade + + Background: + * url baseUrl + * configure cookies = null + * configure retry = { count: 20, interval: 5000 } + + Scenario: verify non-primary affiliation can deactivate and reactivate a shadow user after upgrade + # Log in through Keycloak as the central consortia admin. + * call read('classpath:common-consortia/eureka/initData.feature@Login') consortiaAdmin + * def centralToken = okapitoken + * def memberAffiliationQuery = { userId: '#(consortiaUser.id)', tenantId: '#(memberTenant)' } + + # Delete the member affiliation after the upgrade; this drives mod-consortia-keycloak -> users-keycloak -> Keycloak user update. + Given path 'consortia', consortiumId, 'user-tenants' + And params query = memberAffiliationQuery + And headers { 'x-okapi-tenant': '#(centralTenant)', 'x-okapi-token': '#(centralToken)' } + When method delete + Then status 204 + + # Verify the shadow user was deactivated in the member tenant as a result of the Keycloak-aware update path. + * call read('classpath:common-consortia/eureka/initData.feature@Login') memberAdmin + * def memberToken = okapitoken + Given path 'users', consortiaUser.id + And headers { 'x-okapi-tenant': '#(memberTenant)', 'x-okapi-token': '#(memberToken)' } + And retry until response.active == false + When method get + Then status 200 + And match response.id == consortiaUser.id + And match response.username contains consortiaUser.username + And match response.active == false + And match response.type == 'shadow' + And match response.customFields.originaltenantid == centralTenant + + # Re-add the member affiliation after the upgrade; this drives mod-consortia-keycloak -> users-keycloak -> Keycloak user update again. + Given path 'consortia', consortiumId, 'user-tenants' + And headers { 'x-okapi-tenant': '#(centralTenant)', 'x-okapi-token': '#(centralToken)' } + And request { userId: '#(consortiaUser.id)', tenantId :'#(memberTenant)' } + When method post + Then status 200 + And match response.userId == consortiaUser.id + And match response.username contains consortiaUser.username + And match response.tenantId == memberTenant + And match response.isPrimary == false + + # Verify the shadow user was reactivated in the member tenant as a result of the Keycloak-aware update path. + Given path 'users', consortiaUser.id + And headers { 'x-okapi-tenant': '#(memberTenant)', 'x-okapi-token': '#(memberToken)' } + And retry until response.active == true + When method get + Then status 200 + And match response.id == consortiaUser.id + And match response.username contains consortiaUser.username + And match response.active == true + And match response.type == 'shadow' + And match response.customFields.originaltenantid == centralTenant diff --git a/keycloak-upgrade/src/main/resources/karate-config.js b/keycloak-upgrade/src/main/resources/karate-config.js new file mode 100644 index 0000000000..6fb876ea08 --- /dev/null +++ b/keycloak-upgrade/src/main/resources/karate-config.js @@ -0,0 +1,117 @@ +function fn() { + + karate.configure('logPrettyRequest', true); + karate.configure('logPrettyResponse', true); + karate.configure('retry', { count: 20, interval: 15000 }); + + var env = karate.env; + var testTenant = karate.properties['testTenant']; + var testTenantId = karate.properties['testTenantId']; + var baseUrlOverride = karate.properties['baseUrl']; + var baseKeycloakUrlOverride = karate.properties['baseKeycloakUrl']; + var kcClientIdOverride = karate.properties['kcClientId']; + var kcClientSecretOverride = karate.properties['clientSecret']; + var m2mClientIdOverride = karate.properties['m2mClientId']; + var adminTenantOverride = karate.properties['admin.tenant']; + var adminNameOverride = karate.properties['admin.name']; + var adminPasswordOverride = karate.properties['admin.password']; + var effectiveTestTenant = testTenant ? testTenant : 'testtenant'; + var effectiveTestTenantId = testTenantId ? testTenantId : (function() { return java.util.UUID.randomUUID() + '' })(); + var memberTenant = karate.properties['memberTenant'] || 'kcmembertenant'; + var memberTenantId = karate.properties['memberTenantId'] || (function() { return java.util.UUID.randomUUID() + '' })(); + var consortiumId = karate.properties['consortiumId'] || (function() { return java.util.UUID.randomUUID() + '' })(); + var consortiaAdminId = karate.properties['consortiaAdminId'] || (function() { return java.util.UUID.randomUUID() + '' })(); + var memberAdminId = karate.properties['memberAdminId'] || (function() { return java.util.UUID.randomUUID() + '' })(); + var consortiaUserId = karate.properties['consortiaUserId'] || (function() { return java.util.UUID.randomUUID() + '' })(); + + var config = { + baseUrl: baseUrlOverride || 'http://localhost:8000', + baseKeycloakUrl: baseKeycloakUrlOverride || 'http://localhost:8080', + admin: { + tenant: adminTenantOverride || 'diku', + name: adminNameOverride || 'diku_admin', + password: adminPasswordOverride || 'admin' + }, + prototypeTenant: 'diku', + kcClientId: kcClientIdOverride || 'folio-backend-admin-client', + kcClientSecret: kcClientSecretOverride || 'SecretPassword', + m2mClientId: m2mClientIdOverride || 'sidecar-module-access-client', + tenantParams: { loadReferenceData: true }, + testTenant: effectiveTestTenant, + testTenantId: effectiveTestTenantId, + centralTenant: effectiveTestTenant, + centralTenantId: effectiveTestTenantId, + memberTenant: memberTenant, + memberTenantId: memberTenantId, + consortiumId: consortiumId, + testUser: { tenant: effectiveTestTenant, name: 'kc-upgrade-user', password: 'test' }, + consortiaAdmin: { id: consortiaAdminId, username: 'kc-upgrade-consortia-admin', password: 'kc-upgrade-consortia-admin-password', tenant: effectiveTestTenant, phone: '111-111', mobilePhone: '111-222' }, + memberAdmin: { id: memberAdminId, username: 'kc-upgrade-member-admin', password: 'kc-upgrade-member-admin-password', tenant: memberTenant, phone: '222-111', mobilePhone: '222-222' }, + consortiaUser: { id: consortiaUserId, username: 'kc-upgrade-consortia-user', password: 'kc-upgrade-consortia-user-password', type: 'staff', tenant: effectiveTestTenant, phone: '333-111', mobilePhone: '333-222' }, + upgradeRoleName: 'kc-upgrade-role-' + effectiveTestTenant, + upgradeRoleDescription: 'Role created before a Keycloak upgrade and verified after restart', + upgradeRolePermission: 'roles.collection.get', + uuid: function () { + return java.util.UUID.randomUUID() + ''; + }, + decodeJwtPayload: function(token) { + var Base64 = Java.type('java.util.Base64'); + var String = Java.type('java.lang.String'); + var payload = token.split('.')[1]; + return karate.fromString(new String(Base64.getUrlDecoder().decode(payload))); + } + }; + + if (env == 'snapshot') { + config.baseUrl = 'https://folio-etesting-snapshot-kong.ci.folio.org'; + config.baseKeycloakUrl = 'https://folio-etesting-snapshot-keycloak.ci.folio.org'; + } else if (env == 'snapshot-2') { + config.baseUrl = 'https://folio-etesting-snapshot2-kong.ci.folio.org'; + config.baseKeycloakUrl = 'https://folio-etesting-snapshot2-keycloak.ci.folio.org'; + } else if (env == 'folio-testing-karate') { + config.baseUrl = '${baseUrl}'; + config.admin = { + tenant: '${admin.tenant}', + name: '${admin.name}', + password: '${admin.password}' + }; + config.kcClientId = '${clientId}'; + config.kcClientSecret = '${clientSecret}'; + config.prototypeTenant = '${prototypeTenant}'; + karate.configure('ssl', true); + config.baseKeycloakUrl = '${baseKeycloakUrl}'; + } else if (env == 'eureka1') { + config.baseUrl = 'https://folio-edev-eureka-kong.ci.folio.org'; + config.baseKeycloakUrl = 'https://folio-edev-eureka-keycloak.ci.folio.org'; + } else if (env == 'eureka2') { + config.baseUrl = 'https://folio-edev-eureka-2nd-kong.ci.folio.org'; + config.baseKeycloakUrl = 'https://folio-edev-eureka-2nd-keycloak.ci.folio.org'; + } else if (env == 'local') { + config.baseUrl = 'http://localhost:8000'; + config.baseKeycloakUrl = 'http://localhost:8080'; + config.kcClientSecret = 'folio-backend-admin-client-secret'; + config.m2mClientId = m2mClientIdOverride || 'm2m-client'; + } else if (env == 'dev') { + config.baseKeycloakUrl = 'http://keycloak.eureka:8080'; + config.kcClientId = 'supersecret'; + config.kcClientSecret = karate.properties['clientSecret'] || 'supersecret'; + } + + if (baseUrlOverride) { + config.baseUrl = baseUrlOverride; + } + if (baseKeycloakUrlOverride) { + config.baseKeycloakUrl = baseKeycloakUrlOverride; + } + if (kcClientIdOverride) { + config.kcClientId = kcClientIdOverride; + } + if (kcClientSecretOverride) { + config.kcClientSecret = kcClientSecretOverride; + } + if (m2mClientIdOverride) { + config.m2mClientId = m2mClientIdOverride; + } + + return config; +} diff --git a/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeSeedTests.java b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeSeedTests.java new file mode 100644 index 0000000000..3c4ff721f4 --- /dev/null +++ b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeSeedTests.java @@ -0,0 +1,28 @@ +package org.folio; + +import org.folio.test.TestBaseEureka; +import org.folio.test.annotation.FolioTest; +import org.folio.test.config.TestModuleConfiguration; +import org.folio.test.services.TestIntegrationService; +import org.junit.jupiter.api.Test; + +@FolioTest(team = "Eureka", module = "keycloak-upgrade") +class KeycloakUpgradeSeedTests extends TestBaseEureka { + + private static final String TEST_BASE_PATH = "classpath:eureka/keycloak-upgrade/features/"; + + KeycloakUpgradeSeedTests() { + super(new TestIntegrationService(new TestModuleConfiguration(TEST_BASE_PATH))); + } + + @Override + public void runHook() { + KeycloakUpgradeTenantState.prepareSeedTenant(); + super.runHook(); + } + + @Test + void seedBeforeUpgrade() { + runFeatureTest("seed-before-upgrade"); + } +} diff --git a/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java new file mode 100644 index 0000000000..3e5e6bf118 --- /dev/null +++ b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java @@ -0,0 +1,147 @@ +package org.folio; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Properties; +import java.util.UUID; + +final class KeycloakUpgradeTenantState { + + private static final String TEST_TENANT = "testTenant"; + private static final String TEST_TENANT_ID = "testTenantId"; + private static final String MEMBER_TENANT = "memberTenant"; + private static final String MEMBER_TENANT_ID = "memberTenantId"; + private static final String CONSORTIUM_ID = "consortiumId"; + private static final String CONSORTIA_ADMIN_ID = "consortiaAdminId"; + private static final String MEMBER_ADMIN_ID = "memberAdminId"; + private static final String CONSORTIA_USER_ID = "consortiaUserId"; + private static final String STATE_FILE = "keycloak.upgrade.stateFile"; + private static final Path DEFAULT_STATE_FILE = Path.of("target", "keycloak-upgrade-tenant.properties"); + private static final String[] REQUIRED_STATE_KEYS = { + TEST_TENANT, TEST_TENANT_ID, MEMBER_TENANT, MEMBER_TENANT_ID, CONSORTIUM_ID, + CONSORTIA_ADMIN_ID, MEMBER_ADMIN_ID, CONSORTIA_USER_ID + }; + + private KeycloakUpgradeTenantState() { + } + + static void prepareSeedTenant() { + var testTenant = System.getProperty(TEST_TENANT); + var testTenantId = System.getProperty(TEST_TENANT_ID); + Properties state; + + if (isBlank(testTenant) && isBlank(testTenantId)) { + state = Files.exists(stateFile()) ? load() : new Properties(); + testTenant = state.getProperty(TEST_TENANT, randomTenantName("kcupgrade")); + testTenantId = state.getProperty(TEST_TENANT_ID, UUID.randomUUID().toString()); + } else if (isBlank(testTenant) || isBlank(testTenantId)) { + throw new IllegalStateException("Provide both -DtestTenant and -DtestTenantId, or omit both to auto-generate them"); + } else { + state = Files.exists(stateFile()) ? load() : new Properties(); + } + + state.setProperty(TEST_TENANT, testTenant); + state.setProperty(TEST_TENANT_ID, testTenantId); + addMissingConsortiaState(state); + setTenantProperties(state); + save(state); + } + + static void prepareVerifyTenant() { + var state = load(); + var testTenant = System.getProperty(TEST_TENANT); + var testTenantId = System.getProperty(TEST_TENANT_ID); + + if (isBlank(testTenant) != isBlank(testTenantId)) { + throw new IllegalStateException("Provide both -DtestTenant and -DtestTenantId, or omit both to load them from " + + stateFile().toAbsolutePath()); + } + + if (!isBlank(testTenant) && !testTenant.equals(state.getProperty(TEST_TENANT))) { + throw new IllegalStateException("Provided testTenant does not match seed state file: " + + stateFile().toAbsolutePath()); + } + if (!isBlank(testTenantId) && !testTenantId.equals(state.getProperty(TEST_TENANT_ID))) { + throw new IllegalStateException("Provided testTenantId does not match seed state file: " + + stateFile().toAbsolutePath()); + } + + validateState(state); + setTenantProperties(state); + } + + private static void save(Properties properties) { + var stateFile = stateFile(); + try { + var parent = stateFile.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + properties.setProperty("createdAt", Instant.now().toString()); + + try (OutputStream outputStream = Files.newOutputStream(stateFile)) { + properties.store(outputStream, "Keycloak upgrade integration test tenant state"); + } + } catch (IOException exception) { + throw new IllegalStateException("Failed to write Keycloak upgrade tenant state to " + + stateFile.toAbsolutePath(), exception); + } + } + + private static Properties load() { + var stateFile = stateFile(); + if (!Files.exists(stateFile)) { + throw new IllegalStateException("Keycloak upgrade tenant state file does not exist: " + + stateFile.toAbsolutePath() + ". Run KeycloakUpgradeSeedTests first."); + } + + try (InputStream inputStream = Files.newInputStream(stateFile)) { + var properties = new Properties(); + properties.load(inputStream); + return properties; + } catch (IOException exception) { + throw new IllegalStateException("Failed to read Keycloak upgrade tenant state from " + + stateFile.toAbsolutePath(), exception); + } + } + + private static Path stateFile() { + var override = System.getProperty(STATE_FILE); + return isBlank(override) ? DEFAULT_STATE_FILE : Path.of(override); + } + + private static void addMissingConsortiaState(Properties properties) { + properties.computeIfAbsent(MEMBER_TENANT, key -> randomTenantName("kcmember")); + properties.computeIfAbsent(MEMBER_TENANT_ID, key -> UUID.randomUUID().toString()); + properties.computeIfAbsent(CONSORTIUM_ID, key -> UUID.randomUUID().toString()); + properties.computeIfAbsent(CONSORTIA_ADMIN_ID, key -> UUID.randomUUID().toString()); + properties.computeIfAbsent(MEMBER_ADMIN_ID, key -> UUID.randomUUID().toString()); + properties.computeIfAbsent(CONSORTIA_USER_ID, key -> UUID.randomUUID().toString()); + } + + private static void validateState(Properties properties) { + for (String key : REQUIRED_STATE_KEYS) { + if (isBlank(properties.getProperty(key))) { + throw new IllegalStateException("Keycloak upgrade tenant state is missing " + key + " in " + + stateFile().toAbsolutePath() + ". Delete the state file and rerun KeycloakUpgradeSeedTests."); + } + } + } + + private static void setTenantProperties(Properties properties) { + properties.stringPropertyNames().forEach(name -> System.setProperty(name, properties.getProperty(name))); + } + + private static String randomTenantName(String prefix) { + return prefix + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeVerifyTests.java b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeVerifyTests.java new file mode 100644 index 0000000000..d1741ad6f9 --- /dev/null +++ b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeVerifyTests.java @@ -0,0 +1,38 @@ +package org.folio; + +import org.folio.test.TestBaseEureka; +import org.folio.test.annotation.FolioTest; +import org.folio.test.config.TestModuleConfiguration; +import org.folio.test.services.TestIntegrationService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +@FolioTest(team = "Eureka", module = "keycloak-upgrade") +class KeycloakUpgradeVerifyTests extends TestBaseEureka { + + private static final String TEST_BASE_PATH = "classpath:eureka/keycloak-upgrade/features/"; + private boolean verified; + + KeycloakUpgradeVerifyTests() { + super(new TestIntegrationService(new TestModuleConfiguration(TEST_BASE_PATH))); + } + + @Override + public void runHook() { + KeycloakUpgradeTenantState.prepareVerifyTenant(); + super.runHook(); + } + + @Test + void verifyAfterUpgrade() { + runFeatureTest("verify-after-upgrade"); + verified = true; + } + + @AfterAll + void tearDown() { + if (verified) { + runFeature("classpath:eureka/keycloak-upgrade/features/destroy-upgrade-data.feature"); + } + } +} From a1d0c1ca6e8a994057db0f6d27f7e339ad09082f Mon Sep 17 00:00:00 2001 From: PK Jacob Date: Mon, 1 Jun 2026 16:56:03 -0400 Subject: [PATCH 2/3] .. --- keycloak-upgrade/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keycloak-upgrade/README.md b/keycloak-upgrade/README.md index a9b4895ec1..d7e7a0573e 100644 --- a/keycloak-upgrade/README.md +++ b/keycloak-upgrade/README.md @@ -1,8 +1,8 @@ # Keycloak Upgrade Integration Tests -This module verifies that data created with the previous Keycloak image still works after restarting the same environment with a newer `folio-keycloak` image. +This test verifies that data created with the previous Keycloak image still works after installing a newer `folio-keycloak` image. -The test is intentionally split into two runners because the Keycloak container must be replaced between phases while preserving the Keycloak database. +The test is split into two runners because the Keycloak container must be replaced between phases while preserving the Keycloak database. ## Run From ddae375711dd7369427f6f48bd36a900508d155e Mon Sep 17 00:00:00 2001 From: PK Jacob Date: Thu, 11 Jun 2026 11:12:42 -0400 Subject: [PATCH 3/3] remove consortia tests --- keycloak-upgrade/README.md | 15 +- keycloak-upgrade/pom.xml | 5 - .../features/destroy-upgrade-data.feature | 8 +- .../tenant-and-local-admin-setup.feature | 283 ------------------ .../features/seed-before-upgrade.feature | 20 +- .../seed-consortia-before-upgrade.feature | 54 ---- .../features/verify-after-upgrade.feature | 3 - .../verify-consortia-after-upgrade.feature | 56 ---- .../src/main/resources/karate-config.js | 14 - .../org/folio/KeycloakUpgradeTenantState.java | 19 +- pom.xml | 1 + 11 files changed, 28 insertions(+), 450 deletions(-) delete mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature delete mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature delete mode 100644 keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature diff --git a/keycloak-upgrade/README.md b/keycloak-upgrade/README.md index d7e7a0573e..33522f8a58 100644 --- a/keycloak-upgrade/README.md +++ b/keycloak-upgrade/README.md @@ -6,33 +6,33 @@ The test is split into two runners because the Keycloak container must be replac ## Run -When no state file exists, the seed phase generates random central/member tenant names and ids, then writes them to `target/keycloak-upgrade-tenant.properties`. The verify phase reads the same file. +When no state file exists, the seed phase generates a random tenant name and id, then writes them to `target/keycloak-upgrade-tenant.properties`. The verify phase reads the same file. If the seed phase fails after tenant creation, remove `target/keycloak-upgrade-tenant.properties` to generate a new tenant, or clean up the failed tenant before rerunning the seed phase. Start the environment with the old Keycloak image, then seed upgrade data: ```shell -mvn clean test -pl common,common-consortia,testrail-integration,keycloak-upgrade \ +mvn clean test -pl common,testrail-integration,keycloak-upgrade \ -DfailIfNoTests=false \ -Dsurefire.failIfNoSpecifiedTests=false \ -Dtest=KeycloakUpgradeSeedTests \ - -DargLine="-Dkarate.env=eureka1" + -DargLine="-Dkarate.env=eureka2" ``` Restart Keycloak with the upgraded image against the same database, wait for Keycloak and Kong to become ready, then verify: ```shell -mvn test -pl common,common-consortia,testrail-integration,keycloak-upgrade \ +mvn test -pl common,testrail-integration,keycloak-upgrade \ -DfailIfNoTests=false \ -Dsurefire.failIfNoSpecifiedTests=false \ -Dtest=KeycloakUpgradeVerifyTests \ - -DargLine="-Dkarate.env=eureka1" + -DargLine="-Dkarate.env=eureka2" ``` To use an explicit state file location, add `-Dkeycloak.upgrade.stateFile=/path/to/keycloak-upgrade-tenant.properties` to both `argLine` values. -To use an explicit central tenant instead of the generated one, add both `-DtestTenant=` and `-DtestTenantId=` to the seed command. The seed phase still writes all fixture ids to the state file for the verify command. +To use an explicit tenant instead of the generated one, add both `-DtestTenant=` and `-DtestTenantId=` to the seed command. The seed phase still writes them to the state file for the verify command. ## What It Covers @@ -42,5 +42,4 @@ To use an explicit central tenant instead of the generated one, add both `-Dtest - FOLIO-issued tokens still contain the expected `sub`, `user_id`, and client claims. - Tenant metadata can still be updated through the FOLIO tenant manager API. - Role/capability assignments created before the upgrade still resolve after the upgrade. -- Consortia non-primary affiliation deletion/re-addition still drives shadow user deactivation/reactivation through Keycloak-aware user updates. -- The verify phase cleans up the generated central and member tenants after a successful run. +- The verify phase cleans up the generated tenant after a successful run. diff --git a/keycloak-upgrade/pom.xml b/keycloak-upgrade/pom.xml index 8e48645a0f..d58333a43b 100644 --- a/keycloak-upgrade/pom.xml +++ b/keycloak-upgrade/pom.xml @@ -20,11 +20,6 @@ common 1.0-SNAPSHOT - - org.folio - common-consortia - 1.0-SNAPSHOT - org.folio testrail-integration diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature index 5d11d2bf97..b453520fab 100644 --- a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/destroy-upgrade-data.feature @@ -4,9 +4,5 @@ Feature: destroy Keycloak upgrade test data * url baseUrl * configure readTimeout = 3000000 - Scenario: delete member and central tenants - # Delete the member tenant first because it depends on the central consortium tenant. - * call read('classpath:common/eureka/destroy-data.feature') { testTenantId: '#(memberTenantId)' } - - # Delete the central tenant last. - * call read('classpath:common/eureka/destroy-data.feature') { testTenantId: '#(centralTenantId)' } + Scenario: delete upgrade test tenant + * call read('classpath:common/eureka/destroy-data.feature') { testTenantId: '#(testTenantId)' } diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature deleted file mode 100644 index e60966cde7..0000000000 --- a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature +++ /dev/null @@ -1,283 +0,0 @@ -Feature: Keycloak upgrade consortia tenant setup - - Background: - * url baseUrl - * configure readTimeout = 3000000 - * configure retry = { count: 40, interval: 15000 } - * def queryParam = function(field, values) { return '(' + field + '==(' + values.map(x => '"' + x + '"').join(' or ') + '))' } - - * table requiredModulesForConsortia - | name | - | 'mod-tags' | - | 'mod-users-bl' | - | 'mod-password-validator' | - | 'folio_users' | - | 'mod-consortia-keycloak' | - - * table requiredCapabilitiesForConsortia - | name | - | 'consortia.consortia-configuration.item.post' | - | 'consortia.consortia-configuration.item.delete' | - | 'consortia.consortium.item.post' | - | 'consortia.consortium.item.put' | - | 'consortia.consortium.item.get' | - | 'consortia.create-primary-affiliations.item.post' | - | 'consortia.custom-login.item.post' | - | 'consortia.identity-provider.item.post' | - | 'consortia.identity-provider.item.delete' | - | 'consortia.inventory.local.sharing-instances.execute' | - | 'consortia.inventory.update-ownership.item.post' | - | 'consortia.publications.item.post' | - | 'consortia.publications.item.delete' | - | 'consortia.publications.item.get' | - | 'consortia.publications-results.item.get' | - | 'consortia.sharing-instances.collection.get' | - | 'consortia.sharing-instances.item.post' | - | 'consortia.sharing-instances.item.get' | - | 'consortia.sharing-policies.item.post' | - | 'consortia.sharing-policies.item.delete' | - | 'consortia.sharing-roles-all.item.post' | - | 'consortia.sharing-roles-all.item.delete' | - | 'consortia.sharing-roles-capabilities.item.post' | - | 'consortia.sharing-roles-capabilities.item.delete' | - | 'consortia.sharing-roles-capability-sets.item.post' | - | 'consortia.sharing-roles-capability-sets.item.delete' | - | 'consortia.sharing-roles.item.post' | - | 'consortia.sharing-roles.item.delete' | - | 'consortia.sharing-settings.item.post' | - | 'consortia.sharing-settings.item.delete' | - | 'consortia.sync-primary-affiliations.item.post' | - | 'consortia.tenants.item.post' | - | 'consortia.tenants.item.delete' | - | 'consortia.tenants.item.put' | - | 'consortia.tenants.item.get' | - | 'consortia.user-tenants.collection.get' | - | 'consortia.user-tenants.item.post' | - | 'consortia.user-tenants.item.delete' | - | 'consortia.user-tenants.item.get' | - | 'tags.collection.get' | - | 'tags.item.post' | - | 'tags.item.delete' | - | 'tags.item.put' | - | 'tags.item.get' | - - @SetupTenant - Scenario: create tenant, enable applications, create admin, and assign capabilities - * def description = 'tenant_description' - * def oldModules = modules - * def modules = modules.concat(requiredModulesForConsortia) - * def oldPermissions = (typeof userPermissions !== 'undefined') ? userPermissions : [] - * def userPermissions = requiredCapabilitiesForConsortia.concat(oldPermissions) - - # Create the tenant and enable required applications. - * call read('classpath:common/eureka/tenant.feature@create') { tenantId: '#(tenantId)', tenantName: '#(tenant)' } - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@InstallApplications') { tenantId: '#(tenantId)', modules: '#(modules)' } - - # Create a local admin and grant the capabilities needed by consortia setup and verification. - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@PostAdmin') { tenant: '#(tenant)', user: '#(user)' } - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@PutCaps') { tenant: '#(tenant)', user: '#(user)', userPermissions: '#(userPermissions)' } - - * def userPermissions = oldPermissions - * def modules = oldModules - - @InstallApplications - Scenario: enable applications in tenant - * def testTenantId = tenantId - * def keycloakResponse = call read('classpath:common/eureka/keycloak.feature@getKeycloakMasterToken') - * def keycloakMasterToken = keycloakResponse.response.access_token - * call read('classpath:common/eureka/application.feature@applicationSearch') { modules: '#(modules)' } - * def entitlementTemplate = read('classpath:common/eureka/samples/entitlement-entity.json') - * def loadReferenceRecords = karate.get('tenantParams', { loadReferenceData: false }).loadReferenceData - * def centralTenantIdValue = karate.get('centralTenantId') - * def tenantParameters = 'loadSample=false,loadReference=' + loadReferenceRecords + (centralTenantIdValue ? ',centralTenantId=' + centralTenantIdValue : '') - - Given path 'entitlements' - And param tenantParameters = tenantParameters - And param async = true - And param purgeOnRollback = false - And request entitlementTemplate - And header Authorization = 'Bearer ' + keycloakMasterToken - And header x-okapi-token = keycloakMasterToken - When method POST - * def flowId = response.flowId - - * configure retry = { count: 40, interval: 30000 } - Given path 'entitlement-flows', flowId - And header Authorization = 'Bearer ' + keycloakMasterToken - And retry until response.status == 'finished' || response.status == 'cancelled' || response.status == 'cancellation_failed' || response.status == 'failed' - When method GET - * def failCondition = response.status - * if (failCondition == 'cancelled' || failCondition == 'cancellation_failed' || failCondition == 'failed') karate.fail('Entitlement creation failed.') - - @SetupConsortia - Scenario: create consortium record - * def consortiumName = tenant + 'name for test' - - Given path 'consortia' - And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } - And request { id: '#(consortiumId)', name: '#(consortiumName)' } - When method POST - * def createStatus = responseStatus - * if (createStatus != 201 && createStatus != 409) karate.fail('Failed to create consortium. Status: ' + createStatus) - * def consortiumRecord = createStatus == 201 ? response : { id: consortiumId, name: consortiumName } - And match consortiumRecord == { id: '#(consortiumId)', name: '#(consortiumName)' } - - @SetupTenantForConsortia - Scenario: create tenant for consortium - * def name = tenant + ' tenants name' - - Given path 'consortia', consortiumId, 'tenants' - And param adminUserId = consortiaAdmin.id - And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } - And request { id: '#(tenant)', code: '#(code)', name: '#(name)', isCentral: '#(isCentral)' } - When method POST - * def createStatus = responseStatus - * if (createStatus != 201 && createStatus != 409) karate.fail('Failed to create consortium tenant ' + tenant + '. Status: ' + createStatus) - * def tenantRecord = createStatus == 201 ? response : karate.call('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@GetTenantForConsortia', { tenant: tenant }).response - And match tenantRecord contains { id: '#(tenant)', code: '#(code)', name: '#(name)', isCentral: '#(isCentral)', isDeleted: false } - - # Wait until mod-consortia finishes setup for both newly-created and already-existing tenants. - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@WaitTenantConsortiaSetup') { tenant: '#(tenant)' } - - @GetTenantForConsortia - Scenario: get consortium tenant record - Given path 'consortia', consortiumId, 'tenants', tenant - And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } - When method GET - Then status 200 - - @WaitTenantConsortiaSetup - Scenario: wait for consortium tenant setup to complete - Given path 'consortia', consortiumId, 'tenants', tenant - And headers { 'x-okapi-token': '#(okapitoken)', 'x-okapi-tenant': '#(centralTenant)' } - And retry until response.setupStatus == 'COMPLETED' - When method GET - Then status 200 - And match response.id == tenant - - @getAuthorizationToken - Scenario: get module token for tenant - * configure headers = null - * def keycloakResponse = call read('classpath:common/eureka/keycloak.feature@getKeycloakMasterToken') - * def keycloakMasterToken = keycloakResponse.response.access_token - * def m2mClientId = karate.get('m2mClientId', 'sidecar-module-access-client') - - Given url baseKeycloakUrl - And path 'admin', 'realms', tenant, 'clients' - And header Authorization = 'Bearer ' + keycloakMasterToken - When method GET - Then status 200 - * def client = response.filter(x => x.clientId == m2mClientId)[0] - * if (client == null) karate.fail('Missing Keycloak client ' + m2mClientId + ' in tenant ' + tenant) - * def clientId = client.id - - Given url baseKeycloakUrl - And path 'admin', 'realms', tenant, 'clients', clientId, 'client-secret' - And header Authorization = 'Bearer ' + keycloakMasterToken - When method GET - Then status 200 - * def sidecarSecret = response.value - - * configure headers = null - Given url baseKeycloakUrl - And path 'realms', tenant, 'protocol', 'openid-connect', 'token' - And header Content-Type = 'application/x-www-form-urlencoded' - And form field grant_type = 'client_credentials' - And form field client_id = m2mClientId - And form field client_secret = sidecarSecret - And form field scope = 'email openid' - When method post - Then status 200 - * def okapitoken = response.access_token - * configure headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authtoken-Refresh-Cache': 'true' } - - @PostAdmin - Scenario: create an admin with credentials - * def tokenResult = call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@getAuthorizationToken') { tenant: '#(tenant)' } - * def okapitoken = tokenResult.okapitoken - - Given path 'users' - And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } - And request - """ - { - id: '#(user.id)', - username: '#(user.username)', - active: true, - barcode: '#(uuid())', - externalSystemId: '#(uuid())', - personal: { - email: 'admin@gmail.com', - firstName: 'admin first name', - lastName: 'admin last name', - preferredContactTypeId: '002', - phone: '#(user.phone)', - mobilePhone: '#(user.mobilePhone)' - } - } - """ - When method POST - Then status 201 - - Given path 'authn', 'credentials' - And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } - And request { username: '#(user.username)', password: '#(user.password)', userId: '#(user.id)' } - When method POST - Then status 201 - - @PostUser - Scenario: create a user with credentials - * def tokenResult = call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@getAuthorizationToken') { tenant: '#(tenant)' } - * def okapitoken = tokenResult.okapitoken - - Given path 'users' - And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } - And request - """ - { - id: '#(user.id)', - username: '#(user.username)', - active: true, - barcode: '#(uuid())', - externalSystemId: '#(uuid())', - type: '#(user.type)', - personal: { - email: 'user@gmail.com', - firstName: 'user first name', - lastName: 'user last name', - preferredContactTypeId: '002', - phone: '#(user.phone)', - mobilePhone: '#(user.mobilePhone)' - } - } - """ - When method POST - Then status 201 - - Given path 'authn', 'credentials' - And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } - And request { username: '#(user.username)', password: '#(user.password)', userId: '#(user.id)' } - When method POST - Then status 201 - - @PutCaps - Scenario: assign capabilities to user - * def tokenResult = call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@getAuthorizationToken') { tenant: '#(tenant)' } - * def okapitoken = tokenResult.okapitoken - * def permissions = $userPermissions[*].name - - Given path 'capabilities' - And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } - And param query = queryParam('permission', permissions) - And param limit = permissions.length - And retry until response.capabilities && response.capabilities.length == permissions.length - When method GET - Then status 200 - * def capabilityIds = karate.map(response.capabilities, x => x.id) - * if (capabilityIds.length != permissions.length) karate.fail('Not all capabilities found. Expected ' + permissions.length + ', found ' + capabilityIds.length) - - Given path 'users', 'capabilities' - And headers { 'x-okapi-tenant': '#(tenant)', 'x-okapi-token': '#(okapitoken)' } - And request { userId: '#(user.id)', capabilityIds: '#(capabilityIds)' } - When method POST - Then status 201 diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature index c2b1fa6b99..db764d7102 100644 --- a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-before-upgrade.feature @@ -33,12 +33,24 @@ Feature: seed data before Keycloak upgrade | 'role-capabilities.collection.get' | | 'permissions.users.item.get' | - Scenario: create tenant, user credentials, consortia affiliation, and role data before upgrade - # Seed central/member tenants and the consortia affiliation fixture used by post-upgrade verification. - * call read('classpath:eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature') + Scenario: create tenant, user credentials, and role data before upgrade + # Create the tenant and enable required applications before requesting tenant-scoped Keycloak tokens. + * call read('classpath:common/eureka/setup-users.feature@createTenant') + * call read('classpath:common/eureka/setup-users.feature@createEntitlement') + + # Wait until the tenant realm is visible before retrieving the tenant-scoped module token. + * def keycloakResponse = call read('classpath:common/eureka/keycloak.feature@getKeycloakMasterToken') + * def keycloakMasterToken = keycloakResponse.response.access_token + Given url baseKeycloakUrl + And path 'admin', 'realms', testTenant + And header Authorization = 'Bearer ' + keycloakMasterToken + And retry until responseStatus == 200 + When method get + Then status 200 # Create the test user, assign credentials, and grant the permissions needed for verification. * configure headers = null + * configure cookies = null * call read('classpath:common/eureka/setup-users.feature@getAuthorizationToken') * call read('classpath:common/eureka/setup-users.feature@createTestUser') * call read('classpath:common/eureka/setup-users.feature@specifyUserCredentials') @@ -46,6 +58,8 @@ Feature: seed data before Keycloak upgrade * def testUserId = karate.get('userId') # Verify the user can authenticate before the Keycloak upgrade. + Given url baseUrl + * configure cookies = null Given path 'authn', 'login' And headers { 'Content-Type': 'application/json', 'x-okapi-tenant': '#(testTenant)' } And request { username: '#(testUser.name)', password: '#(testUser.password)' } diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature deleted file mode 100644 index 423de03d58..0000000000 --- a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/seed-consortia-before-upgrade.feature +++ /dev/null @@ -1,54 +0,0 @@ -Feature: seed consortia data before Keycloak upgrade - - Background: - * url baseUrl - * configure cookies = null - * configure retry = { count: 20, interval: 5000 } - - Scenario: create central and member tenant with non-primary affiliation before upgrade - # Create the central tenant and enable applications required by the upgrade smoke and consortia flows. - * def setupTenant = read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupTenant') - * call setupTenant { tenantId: '#(centralTenantId)', tenant: '#(centralTenant)', user: '#(consortiaAdmin)' } - - # Create a member tenant in the same consortium test fixture. - * call setupTenant { tenantId: '#(memberTenantId)', tenant: '#(memberTenant)', user: '#(memberAdmin)' } - - # Log in as the central consortia admin to create consortium records. - * call read('classpath:common-consortia/eureka/initData.feature@Login') consortiaAdmin - - # Create the consortium record before the upgrade. - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupConsortia') { tenant: '#(centralTenant)' } - - # Register the central and member tenants in the consortium before the upgrade. - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupTenantForConsortia') { tenant: '#(centralTenant)', code: 'cntrl', isCentral: true } - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@SetupTenantForConsortia') { tenant: '#(memberTenant)', code: 'mbr', isCentral: false } - - # Create a central user whose affiliation and shadow user will be verified after the upgrade. - * call read('classpath:eureka/keycloak-upgrade/features/helpers/tenant-and-local-admin-setup.feature@PostUser') { tenant: '#(centralTenant)', user: '#(consortiaUser)' } - - # Verify the central user's primary affiliation was created before the upgrade. - * def primaryAffiliationQuery = { userId: '#(consortiaUser.id)', tenantId: '#(centralTenant)' } - Given path 'consortia', consortiumId, 'user-tenants' - And params query = primaryAffiliationQuery - And headers { 'x-okapi-tenant': '#(centralTenant)', 'x-okapi-token': '#(okapitoken)' } - And retry until response.totalRecords == 1 - When method get - Then status 200 - And match response.userTenants[0].userId == consortiaUser.id - And match response.userTenants[0].tenantId == centralTenant - And match response.userTenants[0].isPrimary == true - - # Create the member non-primary affiliation before the upgrade. - * call read('classpath:common-consortia/eureka/affiliation.feature@AddAffiliation') { tenant: '#(memberTenant)', user: '#(consortiaUser)' } - - # Verify the member shadow user exists before the upgrade. - * call read('classpath:common-consortia/eureka/initData.feature@Login') memberAdmin - Given path 'users', consortiaUser.id - And headers { 'x-okapi-tenant': '#(memberTenant)', 'x-okapi-token': '#(okapitoken)' } - And retry until response.active == true - When method get - Then status 200 - And match response.id == consortiaUser.id - And match response.username contains consortiaUser.username - And match response.type == 'shadow' - And match response.customFields.originaltenantid == centralTenant diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature index e32a0d5ebc..25593ff88e 100644 --- a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature +++ b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-after-upgrade.feature @@ -60,9 +60,6 @@ Feature: verify data after Keycloak upgrade Then status 200 And match response.permissions contains upgradeRolePermission - # Verify consortia affiliation and shadow-user fixtures through FOLIO APIs. - * call read('classpath:eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature') - # Verify refresh-token rotation still works after the upgrade. * configure headers = null * configure cookies = null diff --git a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature b/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature deleted file mode 100644 index b070f5cc75..0000000000 --- a/keycloak-upgrade/src/main/resources/eureka/keycloak-upgrade/features/verify-consortia-after-upgrade.feature +++ /dev/null @@ -1,56 +0,0 @@ -Feature: verify consortia Keycloak-relevant behavior after Keycloak upgrade - - Background: - * url baseUrl - * configure cookies = null - * configure retry = { count: 20, interval: 5000 } - - Scenario: verify non-primary affiliation can deactivate and reactivate a shadow user after upgrade - # Log in through Keycloak as the central consortia admin. - * call read('classpath:common-consortia/eureka/initData.feature@Login') consortiaAdmin - * def centralToken = okapitoken - * def memberAffiliationQuery = { userId: '#(consortiaUser.id)', tenantId: '#(memberTenant)' } - - # Delete the member affiliation after the upgrade; this drives mod-consortia-keycloak -> users-keycloak -> Keycloak user update. - Given path 'consortia', consortiumId, 'user-tenants' - And params query = memberAffiliationQuery - And headers { 'x-okapi-tenant': '#(centralTenant)', 'x-okapi-token': '#(centralToken)' } - When method delete - Then status 204 - - # Verify the shadow user was deactivated in the member tenant as a result of the Keycloak-aware update path. - * call read('classpath:common-consortia/eureka/initData.feature@Login') memberAdmin - * def memberToken = okapitoken - Given path 'users', consortiaUser.id - And headers { 'x-okapi-tenant': '#(memberTenant)', 'x-okapi-token': '#(memberToken)' } - And retry until response.active == false - When method get - Then status 200 - And match response.id == consortiaUser.id - And match response.username contains consortiaUser.username - And match response.active == false - And match response.type == 'shadow' - And match response.customFields.originaltenantid == centralTenant - - # Re-add the member affiliation after the upgrade; this drives mod-consortia-keycloak -> users-keycloak -> Keycloak user update again. - Given path 'consortia', consortiumId, 'user-tenants' - And headers { 'x-okapi-tenant': '#(centralTenant)', 'x-okapi-token': '#(centralToken)' } - And request { userId: '#(consortiaUser.id)', tenantId :'#(memberTenant)' } - When method post - Then status 200 - And match response.userId == consortiaUser.id - And match response.username contains consortiaUser.username - And match response.tenantId == memberTenant - And match response.isPrimary == false - - # Verify the shadow user was reactivated in the member tenant as a result of the Keycloak-aware update path. - Given path 'users', consortiaUser.id - And headers { 'x-okapi-tenant': '#(memberTenant)', 'x-okapi-token': '#(memberToken)' } - And retry until response.active == true - When method get - Then status 200 - And match response.id == consortiaUser.id - And match response.username contains consortiaUser.username - And match response.active == true - And match response.type == 'shadow' - And match response.customFields.originaltenantid == centralTenant diff --git a/keycloak-upgrade/src/main/resources/karate-config.js b/keycloak-upgrade/src/main/resources/karate-config.js index 6fb876ea08..6eb146a0d3 100644 --- a/keycloak-upgrade/src/main/resources/karate-config.js +++ b/keycloak-upgrade/src/main/resources/karate-config.js @@ -17,12 +17,6 @@ function fn() { var adminPasswordOverride = karate.properties['admin.password']; var effectiveTestTenant = testTenant ? testTenant : 'testtenant'; var effectiveTestTenantId = testTenantId ? testTenantId : (function() { return java.util.UUID.randomUUID() + '' })(); - var memberTenant = karate.properties['memberTenant'] || 'kcmembertenant'; - var memberTenantId = karate.properties['memberTenantId'] || (function() { return java.util.UUID.randomUUID() + '' })(); - var consortiumId = karate.properties['consortiumId'] || (function() { return java.util.UUID.randomUUID() + '' })(); - var consortiaAdminId = karate.properties['consortiaAdminId'] || (function() { return java.util.UUID.randomUUID() + '' })(); - var memberAdminId = karate.properties['memberAdminId'] || (function() { return java.util.UUID.randomUUID() + '' })(); - var consortiaUserId = karate.properties['consortiaUserId'] || (function() { return java.util.UUID.randomUUID() + '' })(); var config = { baseUrl: baseUrlOverride || 'http://localhost:8000', @@ -39,15 +33,7 @@ function fn() { tenantParams: { loadReferenceData: true }, testTenant: effectiveTestTenant, testTenantId: effectiveTestTenantId, - centralTenant: effectiveTestTenant, - centralTenantId: effectiveTestTenantId, - memberTenant: memberTenant, - memberTenantId: memberTenantId, - consortiumId: consortiumId, testUser: { tenant: effectiveTestTenant, name: 'kc-upgrade-user', password: 'test' }, - consortiaAdmin: { id: consortiaAdminId, username: 'kc-upgrade-consortia-admin', password: 'kc-upgrade-consortia-admin-password', tenant: effectiveTestTenant, phone: '111-111', mobilePhone: '111-222' }, - memberAdmin: { id: memberAdminId, username: 'kc-upgrade-member-admin', password: 'kc-upgrade-member-admin-password', tenant: memberTenant, phone: '222-111', mobilePhone: '222-222' }, - consortiaUser: { id: consortiaUserId, username: 'kc-upgrade-consortia-user', password: 'kc-upgrade-consortia-user-password', type: 'staff', tenant: effectiveTestTenant, phone: '333-111', mobilePhone: '333-222' }, upgradeRoleName: 'kc-upgrade-role-' + effectiveTestTenant, upgradeRoleDescription: 'Role created before a Keycloak upgrade and verified after restart', upgradeRolePermission: 'roles.collection.get', diff --git a/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java index 3e5e6bf118..5d5ccebf73 100644 --- a/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java +++ b/keycloak-upgrade/src/test/java/org/folio/KeycloakUpgradeTenantState.java @@ -13,17 +13,10 @@ final class KeycloakUpgradeTenantState { private static final String TEST_TENANT = "testTenant"; private static final String TEST_TENANT_ID = "testTenantId"; - private static final String MEMBER_TENANT = "memberTenant"; - private static final String MEMBER_TENANT_ID = "memberTenantId"; - private static final String CONSORTIUM_ID = "consortiumId"; - private static final String CONSORTIA_ADMIN_ID = "consortiaAdminId"; - private static final String MEMBER_ADMIN_ID = "memberAdminId"; - private static final String CONSORTIA_USER_ID = "consortiaUserId"; private static final String STATE_FILE = "keycloak.upgrade.stateFile"; private static final Path DEFAULT_STATE_FILE = Path.of("target", "keycloak-upgrade-tenant.properties"); private static final String[] REQUIRED_STATE_KEYS = { - TEST_TENANT, TEST_TENANT_ID, MEMBER_TENANT, MEMBER_TENANT_ID, CONSORTIUM_ID, - CONSORTIA_ADMIN_ID, MEMBER_ADMIN_ID, CONSORTIA_USER_ID + TEST_TENANT, TEST_TENANT_ID }; private KeycloakUpgradeTenantState() { @@ -46,7 +39,6 @@ static void prepareSeedTenant() { state.setProperty(TEST_TENANT, testTenant); state.setProperty(TEST_TENANT_ID, testTenantId); - addMissingConsortiaState(state); setTenantProperties(state); save(state); } @@ -115,15 +107,6 @@ private static Path stateFile() { return isBlank(override) ? DEFAULT_STATE_FILE : Path.of(override); } - private static void addMissingConsortiaState(Properties properties) { - properties.computeIfAbsent(MEMBER_TENANT, key -> randomTenantName("kcmember")); - properties.computeIfAbsent(MEMBER_TENANT_ID, key -> UUID.randomUUID().toString()); - properties.computeIfAbsent(CONSORTIUM_ID, key -> UUID.randomUUID().toString()); - properties.computeIfAbsent(CONSORTIA_ADMIN_ID, key -> UUID.randomUUID().toString()); - properties.computeIfAbsent(MEMBER_ADMIN_ID, key -> UUID.randomUUID().toString()); - properties.computeIfAbsent(CONSORTIA_USER_ID, key -> UUID.randomUUID().toString()); - } - private static void validateState(Properties properties) { for (String key : REQUIRED_STATE_KEYS) { if (isBlank(properties.getProperty(key))) { diff --git a/pom.xml b/pom.xml index 91086b0689..c4b0046bbe 100644 --- a/pom.xml +++ b/pom.xml @@ -103,6 +103,7 @@ mod-users-keycloak mod-login-keycloak mod-roles-keycloak + keycloak-upgrade mod-scheduler