From 004805e21d1b5cb20afc4307fd1c55942c3f19c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hynek=20Mlna=C5=99=C3=ADk?= Date: Tue, 27 Feb 2024 07:15:23 +0100 Subject: [PATCH] Add tests for linked accounts (#27299) Fixes: #21248 Signed-off-by: Hynek Mlnarik --- .../src/account-security/AccountRow.tsx | 5 +- .../account-security/linked-accounts.spec.ts | 118 ++++++++++++++++++ js/apps/account-ui/test/admin-client.ts | 64 +++++++++- .../test/personal-info/personal-info.spec.ts | 40 +++--- .../account-ui/test/realms/groups-idp.json | 19 +++ .../account-ui/test/realms/groups-realm.json | 30 +++++ 6 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 js/apps/account-ui/test/account-security/linked-accounts.spec.ts create mode 100644 js/apps/account-ui/test/realms/groups-idp.json diff --git a/js/apps/account-ui/src/account-security/AccountRow.tsx b/js/apps/account-ui/src/account-security/AccountRow.tsx index 0b3a98fc97..aeac38ee4e 100644 --- a/js/apps/account-ui/src/account-security/AccountRow.tsx +++ b/js/apps/account-ui/src/account-security/AccountRow.tsx @@ -51,7 +51,10 @@ export const AccountRow = ({ account, isLinked = false }: AccountRowProps) => { key={account.providerName} aria-label={t("linkedAccounts")} > - + diff --git a/js/apps/account-ui/test/account-security/linked-accounts.spec.ts b/js/apps/account-ui/test/account-security/linked-accounts.spec.ts new file mode 100644 index 0000000000..109dddc412 --- /dev/null +++ b/js/apps/account-ui/test/account-security/linked-accounts.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from "@playwright/test"; +import { + createIdentityProvider, + deleteIdentityProvider, + createClient, + deleteClient, + inRealm, + findClientByClientId, + createRandomUserWithPassword, + deleteUser, +} from "../admin-client"; +import groupsIdPClient from "../realms/groups-idp.json" assert { type: "json" }; +import ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; +import { randomUUID } from "crypto"; + +const realm = "groups"; + +test.describe("Account linking", () => { + let groupIdPClientId: string; + let user: string; + // Tests for keycloak account console, section Account linking in Account security + test.beforeAll(async () => { + user = await createRandomUserWithPassword("user-" + randomUUID(), "pwd"); + + const kcGroupsIdpId = await findClientByClientId("groups-idp"); + if (kcGroupsIdpId) { + await deleteClient(kcGroupsIdpId); + } + groupIdPClientId = await createClient( + groupsIdPClient as ClientRepresentation, + ); + const kc = process.env.KEYCLOAK_SERVER || "http://localhost:8080"; + const idp: IdentityProviderRepresentation = { + alias: "master-idp", + providerId: "oidc", + enabled: true, + config: { + clientId: "groups-idp", + clientSecret: "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw", + validateSignature: "false", + tokenUrl: `${kc}/realms/master/protocol/openid-connect/token`, + jwksUrl: `${kc}/realms/master/protocol/openid-connect/certs`, + issuer: `${kc}/realms/master`, + authorizationUrl: `${kc}/realms/master/protocol/openid-connect/auth`, + logoutUrl: `${kc}/realms/master/protocol/openid-connect/logout`, + userInfoUrl: `${kc}/realms/master/protocol/openid-connect/userinfo`, + }, + }; + + await inRealm(realm, () => createIdentityProvider(idp)); + }); + + test.afterAll(async () => { + await deleteUser(user); + }); + test.afterAll(async () => { + await deleteClient(groupIdPClientId); + }); + test.afterAll(async () => { + await inRealm(realm, () => deleteIdentityProvider("master-idp")); + }); + + test("Linking", async ({ page }) => { + // If refactoring this, consider introduction of helper functions for individual pages - login, update profile etc. + await page.goto( + process.env.CI ? `/realms/${realm}/account` : `/?realm=${realm}`, + ); + + // Click the login via master-idp provider button + await loginWithIdp(page, "master-idp"); + + // Now the login at the master-idp should be visible + await loginWithUsernamePassword(page, "admin", "admin"); + + // Now the update-profile page should be visible + await updateProfile(page, "test", "user", "testuser@keycloak.org"); + + // Now the account console should be visible + await page.getByTestId("accountSecurity").click(); + await expect( + page.getByTestId("account-security/linked-accounts"), + ).toBeVisible(); + await page.getByTestId("account-security/linked-accounts").click(); + await expect( + page + .getByTestId("linked-accounts/master-idp") + .getByRole("button", { name: "Unlink account" }), + ).toBeVisible(); + await page + .getByTestId("linked-accounts/master-idp") + .getByRole("button", { name: "Unlink account" }) + .click(); + + // Expect an error shown that the account cannot be unlinked + await expect(page.getByLabel("Danger Alert")).toBeVisible(); + }); +}); + +async function updateProfile(page, firstName, lastName, email) { + await expect( + page.getByRole("heading", { name: "Update Account Information" }), + ).toBeVisible(); + await page.getByLabel("Email", { exact: true }).fill(email); + await page.getByLabel("First name", { exact: true }).fill(firstName); + await page.getByLabel("Last name", { exact: true }).fill(lastName); + await page.getByRole("button", { name: "Submit" }).click(); +} + +async function loginWithUsernamePassword(page, username, password) { + await page.getByLabel("Username").fill(username); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Sign In" }).click(); +} + +async function loginWithIdp(page, idpAlias: string) { + await page.getByRole("link", { name: idpAlias }).click(); +} diff --git a/js/apps/account-ui/test/admin-client.ts b/js/apps/account-ui/test/admin-client.ts index a683be9895..447dd86d69 100644 --- a/js/apps/account-ui/test/admin-client.ts +++ b/js/apps/account-ui/test/admin-client.ts @@ -1,10 +1,12 @@ import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; +import ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation"; import RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; const adminClient = new KeycloakAdminClient({ - baseUrl: process.env.KEYCLOAK_SERVER || "http://127.0.0.1:8180", + baseUrl: process.env.KEYCLOAK_SERVER || "http://127.0.0.1:8080", realmName: "master", }); @@ -23,6 +25,16 @@ export async function useTheme() { ); } +export async function inRealm(realm: string, fn: () => Promise) { + const prevRealm = adminClient.realmName; + adminClient.realmName = realm; + try { + return await fn(); + } finally { + adminClient.realmName = prevRealm; + } +} + export async function importRealm(realm: RealmRepresentation) { await adminClient.realms.create(realm); } @@ -31,6 +43,32 @@ export async function deleteRealm(realm: string) { await adminClient.realms.del({ realm }); } +export async function createClient( + client: ClientRepresentation, +): Promise { + return adminClient.clients.create(client).then((client) => client.id); +} + +export async function findClientByClientId(clientId: string) { + return adminClient.clients + .find({ clientId }) + .then((clientArray) => clientArray[0]?.["id"]); +} + +export async function deleteClient(id: string) { + await adminClient.clients.del({ id }); +} + +export async function createIdentityProvider( + idp: IdentityProviderRepresentation, +): Promise { + return adminClient.identityProviders.create(idp)["id"]; +} + +export async function deleteIdentityProvider(alias: string) { + await adminClient.identityProviders.del({ alias }); +} + export async function importUserProfile( userProfile: UserProfileConfig, realm: string, @@ -50,20 +88,38 @@ export async function enableLocalization(realm: string) { ); } -export async function createUser(user: UserRepresentation, realm: string) { +export async function createUser(user: UserRepresentation) { try { - await adminClient.users.create({ ...user, realm }); + await adminClient.users.create(user); } catch (error) { console.error(error); } } +export async function createRandomUserWithPassword( + username: string, + password: string, + props?: UserRepresentation, +): Promise { + return createUser({ + username: username, + enabled: true, + credentials: [ + { + type: "password", + value: password, + }, + ], + ...props, + }).then(() => username); +} + export async function getUserByUsername(username: string, realm: string) { const users = await adminClient.users.find({ username, realm, exact: true }); return users.length > 0 ? users[0] : undefined; } -export async function deleteUser(username: string, realm: string) { +export async function deleteUser(username: string) { try { const users = await adminClient.users.find({ username, realm }); const { id } = users[0]; diff --git a/js/apps/account-ui/test/personal-info/personal-info.spec.ts b/js/apps/account-ui/test/personal-info/personal-info.spec.ts index fe63972b28..f3706ef166 100644 --- a/js/apps/account-ui/test/personal-info/personal-info.spec.ts +++ b/js/apps/account-ui/test/personal-info/personal-info.spec.ts @@ -1,21 +1,26 @@ import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { expect, test } from "@playwright/test"; import { - createUser, + createRandomUserWithPassword, deleteUser, enableLocalization, importUserProfile, + inRealm, } from "../admin-client"; import { login } from "../login"; import userProfileConfig from "./user-profile.json" assert { type: "json" }; +import { randomUUID } from "crypto"; const realm = "user-profile"; test.describe("Personal info page", () => { + let user: string; test("sets basic information", async ({ page }) => { - await login(page, "admin", "admin", "master"); + user = await createRandomUserWithPassword("user-" + randomUUID(), "pwd"); - await page.getByTestId("email").fill("edewit@somewhere.com"); + await login(page, user, "pwd", "master"); + + await page.getByTestId("email").fill(`${user}@somewhere.com`); await page.getByTestId("firstName").fill("Erik"); await page.getByTestId("lastName").fill("de Wit"); await page.getByTestId("save").click(); @@ -26,34 +31,26 @@ test.describe("Personal info page", () => { }); test.describe("Personal info with userprofile enabled", async () => { + let user: string; test.beforeAll(async () => { await importUserProfile(userProfileConfig as UserProfileConfig, realm); - await createUser( - { - username: "jdoe", - enabled: true, + user = await inRealm(realm, () => + createRandomUserWithPassword("user-" + randomUUID(), "jdoe", { email: "jdoe@keycloak.org", firstName: "John", lastName: "Doe", - credentials: [ - { - type: "password", - value: "jdoe", - }, - ], realmRoles: [], clientRoles: { account: ["manage-account"], }, - }, - realm, + }), ); }); - test.afterAll(async () => await deleteUser("jdoe", realm)); + test.afterAll(async () => await inRealm(realm, () => deleteUser(user))); test("render user profile fields", async ({ page }) => { - await login(page, "jdoe", "jdoe", realm); + await login(page, user, "jdoe", realm); await expect(page.locator("#select")).toBeVisible(); await expect(page.getByTestId("help-label-select")).toBeVisible(); @@ -61,7 +58,7 @@ test.describe("Personal info with userprofile enabled", async () => { }); test("save user profile", async ({ page }) => { - await login(page, "jdoe", "jdoe", realm); + await login(page, user, "jdoe", realm); await page.locator("#select").click(); await page.getByRole("option", { name: "two" }).click(); @@ -90,7 +87,12 @@ test.describe.skip("Realm localization", async () => { test.beforeAll(() => enableLocalization("master")); test("change locale", async ({ page }) => { - await login(page, "admin", "admin", "master"); + const user = await createRandomUserWithPassword( + "user-" + randomUUID(), + "pwd", + ); + + await login(page, user, "pwd", "master"); await page .locator("div") .filter({ hasText: /^Deutsch$/ }) diff --git a/js/apps/account-ui/test/realms/groups-idp.json b/js/apps/account-ui/test/realms/groups-idp.json new file mode 100644 index 0000000000..5c53e08e28 --- /dev/null +++ b/js/apps/account-ui/test/realms/groups-idp.json @@ -0,0 +1,19 @@ +{ + "clientId": "groups-idp", + "name": "groups-idp", + "protocol": "openid-connect", + "rootUrl": "${authAdminUrl}", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw", + "redirectUris": [ "/realms/groups/*" ], + "webOrigins": [ "${authAdminUrl}" ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false +} \ No newline at end of file diff --git a/js/apps/account-ui/test/realms/groups-realm.json b/js/apps/account-ui/test/realms/groups-realm.json index 241f811b27..ce8b824a8d 100644 --- a/js/apps/account-ui/test/realms/groups-realm.json +++ b/js/apps/account-ui/test/realms/groups-realm.json @@ -152,5 +152,35 @@ "manage": true } } + ], + "identityProviders": [ + { + "alias": "oidc", + "displayName": "", + "providerId": "oidc", + "enabled": true, + "updateProfileFirstLoginMode": "on", + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "clientId": "groups-idp", + "clientSecret": "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw", + "userInfoUrl": "http://localhost:8180/realms/master/protocol/openid-connect/userinfo", + "validateSignature": "true", + "tokenUrl": "http://localhost:8180/realms/master/protocol/openid-connect/token", + "jwksUrl": "http://localhost:8180/realms/master/protocol/openid-connect/certs", + "issuer": "http://localhost:8180/realms/master", + "useJwksUrl": "true", + "pkceEnabled": "false", + "metadataDescriptorUrl": "http://localhost:8180/realms/master/.well-known/openid-configuration", + "authorizationUrl": "http://localhost:8180/realms/master/protocol/openid-connect/auth", + "clientAuthMethod": "client_secret_post", + "logoutUrl": "http://localhost:8180/realms/master/protocol/openid-connect/logout" + } + } ] }