Isolate account console tests on a per-realm basis (#41608)

Closes #41606

Signed-off-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Jon Koops 2025-08-12 13:43:21 +02:00 committed by GitHub
parent a99149b83a
commit a3591f670f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 366 additions and 864 deletions

View File

@ -11,7 +11,6 @@ on:
env:
MAVEN_ARGS: "-B -nsu -Daether.connector.http.connectionMaxTtl=25"
RETRY_COUNT: 3
concurrency:
# Only cancel jobs for PR updates
@ -207,6 +206,7 @@ jobs:
runs-on: ubuntu-latest
env:
WORKSPACE: "@keycloak/keycloak-admin-ui"
RETRY_COUNT: 3
strategy:
matrix:
browser: [chromium, firefox]

View File

@ -1,12 +1,5 @@
import { type ViewportSize, defineConfig, devices } from "@playwright/test";
import { getAccountUrl } from "./test/utils";
const retryCount = parseInt(process.env.RETRY_COUNT || "0");
console.log("----------------------------");
console.log("Playwright retries = " + retryCount);
console.log("----------------------------");
const viewport: ViewportSize = { width: 1920, height: 1080 };
/**
@ -14,33 +7,21 @@ const viewport: ViewportSize = { width: 1920, height: 1080 };
*/
export default defineConfig({
testDir: "./test",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: retryCount,
reporter: process.env.CI ? [["github"], ["html"]] : "list",
use: {
baseURL: getAccountUrl(),
trace: "retain-on-failure",
},
/* Configure projects for major browsers */
projects: [
{
name: "import realms",
testMatch: /realm\.setup\.ts/,
teardown: "del realms",
},
{
name: "del realms",
testMatch: /realm\.teardown\.ts/,
},
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
viewport,
},
dependencies: ["import realms"],
},
{
name: "firefox",
@ -48,7 +29,6 @@ export default defineConfig({
...devices["Desktop Firefox"],
viewport,
},
dependencies: ["import realms"],
},
],
});

View File

@ -1,15 +0,0 @@
import { test, expect } from "@playwright/test";
import { login } from "./login.ts";
test("Check page heading", async ({ page }) => {
await login(page, "alice", "alice", "user-profile");
await page.getByTestId("accountSecurity").click();
const linkedAccountsNavItem = page.getByTestId(
"account-security/linked-accounts",
);
await expect(linkedAccountsNavItem).toBeVisible();
await linkedAccountsNavItem.click();
await expect(page.getByTestId("page-heading")).toHaveText("Linked accounts");
});

View File

@ -1,50 +1,43 @@
import { expect, test } from "@playwright/test";
import { login } from "../login.ts";
import { login } from "../support/actions.ts";
import { createTestBed } from "../support/testbed.ts";
test.describe("Sign out test", () => {
test("Sign out one device", async ({ browser }) => {
const context1 = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
});
test.describe("Device activity", () => {
test("signs out of a single device session", async ({ browser }) => {
const realm = await createTestBed();
const context1 = await browser.newContext();
const context2 = await browser.newContext();
try {
const page1 = await context1.newPage();
const page2 = await context2.newPage();
await login(page1, "jdoe", "jdoe", "groups");
// Log in the first session, and verify it is active.
await login(page1, realm);
await page1.getByTestId("accountSecurity").click();
await expect(
page1.getByTestId("account-security/device-activity"),
).toBeVisible();
await page1.getByTestId("account-security/device-activity").click();
await expect(page1.getByTestId("row-0")).toContainText("Current session");
await login(page2, "jdoe", "jdoe", "groups");
// Log in the second session, and verify it is active.
await login(page2, realm);
await page2.getByTestId("accountSecurity").click();
await expect(
page2.getByTestId("account-security/device-activity"),
).toBeVisible();
await page2.getByTestId("account-security/device-activity").click();
await expect(page2.getByTestId("row-0")).toContainText("Current session");
const count = await page2
.locator('[aria-label="device-sessions-content"]')
.count();
for (let i = 0; i < count - 1; ++i) {
await page2
.getByRole("button", { name: "Sign out", exact: true })
.first()
.click();
await page2.getByRole("button", { name: "Confirm" }).click();
await page2.getByText("Signed out").isVisible();
await page2.getByTestId("global-alerts").locator("button").click();
}
// Sign out the first session from the second session.
await page2
.getByRole("button", { name: "Sign out", exact: true })
.click();
await page2.getByRole("button", { name: "Confirm", exact: true }).click();
// reload pages in browsers, one should stay logged in, the other should be logged out
// Reload pages and verify the first session is logged out, while the second session remains active.
await page1.reload();
await page2.reload();
await expect(
page1.getByRole("heading", { name: "Sign in to your account" }),
page1.getByRole("heading", {
name: "Sign in to your account",
exact: true,
}),
).toBeVisible();
await expect(page2.getByTestId("accountSecurity")).toBeVisible();
} finally {
@ -53,37 +46,38 @@ test.describe("Sign out test", () => {
}
});
test("Sign out all devices", async ({ browser }) => {
const context1 = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
});
test("signs out of all device sessions", async ({ browser }) => {
const realm = await createTestBed();
const context1 = await browser.newContext();
const context2 = await browser.newContext();
try {
const page1 = await context1.newPage();
const page2 = await context2.newPage();
await login(page1, "jdoe", "jdoe", "groups");
await login(page2, "jdoe", "jdoe", "groups");
// Log in both sessions, then sign out of all devices from the second session.
await login(page1, realm);
await login(page2, realm);
await page2.getByTestId("accountSecurity").click();
await page2.getByTestId("account-security/device-activity").click();
await page2
.getByRole("button", { name: "Sign out all devices", exact: true })
.click();
await page2.getByRole("button", { name: "Confirm" }).click();
await expect(
page2.getByRole("heading", { name: "Sign in to your account" }),
).toBeVisible();
await page2.getByRole("button", { name: "Confirm", exact: true }).click();
// reload pages in browsers, one should stay logged in, the other should be logged out
// Reload only the first page (second page is already logged out), and verify both sessions are logged out.
await page1.reload();
// Reload in page2 should not be needed, as it should be logged out after clicking the button
await expect(
page1.getByRole("heading", { name: "Sign in to your account" }),
page1.getByRole("heading", {
name: "Sign in to your account",
exact: true,
}),
).toBeVisible();
await expect(
page2.getByRole("heading", { name: "Sign in to your account" }),
page2.getByRole("heading", {
name: "Sign in to your account",
exact: true,
}),
).toBeVisible();
} finally {
await context1.close();

View File

@ -1,40 +1,56 @@
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js";
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation.js";
import { type Page, expect, test } from "@playwright/test";
import {
createClient,
createIdentityProvider,
createRandomUserWithPassword,
deleteClient,
deleteIdentityProvider,
deleteUser,
findClientByClientId,
} from "../admin-client.ts";
import { SERVER_URL } from "../constants.ts";
import groupsIdPClient from "../realms/groups-idp.json" with { type: "json" };
import userProfileRealm from "../realms/user-profile-realm.json" with { type: "json" };
import { login } from "../support/actions.ts";
import { adminClient } from "../support/admin-client.ts";
import { DEFAULT_USER, getAccountUrl, SERVER_URL } from "../support/common.ts";
import { createTestBed } from "../support/testbed.ts";
const realm = "groups";
const EXTERNAL_USERNAME = "external-user";
const EXTERNAL_PASSWORD = "external-user";
const EXTERNAL_EMAIL = "external-user@keycloak.org";
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-" + crypto.randomUUID(),
"pwd",
test.describe("Linked accounts", () => {
test("shows linked accounts", async ({ page }) => {
const realm = await createTestBed(userProfileRealm);
// Log in and navigate to the linked accounts section.
await login(page, realm);
await page.getByTestId("accountSecurity").click();
await page.getByTestId("account-security/linked-accounts").click();
await expect(page.getByTestId("page-heading")).toHaveText(
"Linked accounts",
);
});
test("cannot remove the last federated identity", async ({ page }) => {
// Create an 'external' realm with a user that will be used for linking.
const externalRealm = await createTestBed({
users: [
{
...DEFAULT_USER,
username: EXTERNAL_USERNAME,
email: EXTERNAL_EMAIL,
credentials: [
{
type: "password",
value: EXTERNAL_PASSWORD,
},
],
},
],
});
await adminClient.clients.create({
realm: externalRealm,
...groupsIdPClient,
});
// Create a realm that links to the external realm as an identity provider.
const realm = await createTestBed();
await adminClient.identityProviders.create({
realm,
);
const kcGroupsIdpId = await findClientByClientId("groups-idp");
if (kcGroupsIdpId) {
await deleteClient(kcGroupsIdpId);
}
groupIdPClientId = await createClient(
groupsIdPClient as ClientRepresentation,
);
const idp: IdentityProviderRepresentation = {
alias: "master-idp",
providerId: "oidc",
enabled: true,
@ -42,40 +58,22 @@ test.describe("Account linking", () => {
clientId: "groups-idp",
clientSecret: "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw",
validateSignature: "false",
tokenUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/token`,
jwksUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/certs`,
issuer: `${SERVER_URL}/realms/master`,
authorizationUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/auth`,
logoutUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/logout`,
userInfoUrl: `${SERVER_URL}/realms/master/protocol/openid-connect/userinfo`,
tokenUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/token`,
jwksUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/certs`,
issuer: `${SERVER_URL}realms/${externalRealm}`,
authorizationUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/auth`,
logoutUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/logout`,
userInfoUrl: `${SERVER_URL}realms/${externalRealm}/protocol/openid-connect/userinfo`,
},
};
});
await createIdentityProvider(idp, realm);
});
test.afterAll(async () => {
await deleteUser(user, realm);
});
test.afterAll(async () => {
await deleteClient(groupIdPClientId);
});
test.afterAll(async () => {
await deleteIdentityProvider("master-idp", realm);
});
test("Linking", async ({ page }) => {
// If refactoring this, consider introduction of helper functions for individual pages - login, update profile etc.
await page.goto(`/realms/${realm}/account`);
await page.goto(getAccountUrl(realm).toString());
// 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");
await loginWithUsernamePassword(page, EXTERNAL_USERNAME, EXTERNAL_PASSWORD);
// Now the account console should be visible
await page.getByTestId("accountSecurity").click();
@ -100,21 +98,6 @@ test.describe("Account linking", () => {
});
});
async function updateProfile(
page: Page,
firstName: string,
lastName: string,
email: string,
) {
await expect(
page.getByRole("heading", { name: "Update Account Information" }),
).toBeVisible();
await page.getByLabel("Email").fill(email);
await page.getByLabel("First name").fill(firstName);
await page.getByLabel("Last name").fill(lastName);
await page.getByRole("button", { name: "Submit" }).click();
}
async function loginWithUsernamePassword(
page: Page,
username: string,

View File

@ -1,25 +1,19 @@
import { expect, test } from "@playwright/test";
import {
getUserByUsername,
getCredentials,
deleteCredential,
deleteRealm,
importRealm,
} from "../admin-client.ts";
import { login } from "../login.ts";
import groupsRealm from "../realms/groups-realm.json" with { type: "json" };
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation.js";
import { login } from "../support/actions.ts";
import { adminClient, findUserByUsername } from "../support/admin-client.ts";
import { DEFAULT_USER } from "../support/common.ts";
import { createTestBed } from "../support/testbed.ts";
const realm = "groups";
test.describe("Signing in", () => {
// Tests for keycloak account console, section Signing in in Account security
test("Should see only password", async ({ page }) => {
await login(page, "jdoe", "jdoe", "groups");
test("shows password and OTP credentials", async ({ page }) => {
const realm = await createTestBed();
// Log in and navigate to the signing in section.
await login(page, realm);
await page.getByTestId("accountSecurity").click();
await expect(page.getByTestId("account-security/signing-in")).toBeVisible();
await page.getByTestId("account-security/signing-in").click();
// Verify the password credential is configured, and it is not possible to create a new one.
await expect(
page.getByTestId("password/credential-list").getByRole("listitem"),
).toHaveCount(1);
@ -28,54 +22,48 @@ test.describe("Signing in", () => {
).toContainText("My password");
await expect(page.getByTestId("password/create")).toBeHidden();
// Verify the OTP credential not configured, and it is possible to create a new one.
await expect(
page.getByTestId("otp/credential-list").getByRole("listitem"),
).toHaveCount(1);
await expect(
page.getByTestId("otp/credential-list").getByRole("listitem"),
).toContainText("not set up");
await expect(page.getByTestId("otp/create")).toBeVisible();
).toContainText("Authenticator application is not set up.");
await page.getByTestId("otp/create").click();
await expect(page.locator("#kc-page-title")).toContainText(
"Mobile Authenticator Setup",
);
});
});
test.describe("Signing in 2", () => {
test.afterAll(async () => {
await deleteRealm(realm);
await importRealm(groupsRealm as RealmRepresentation);
});
test("Password removal", async ({ page }) => {
const jdoeUser = await getUserByUsername("jdoe", realm);
test("allows setting a password credential if none exists", async ({
page,
}) => {
const realm = await createTestBed();
const user = await findUserByUsername(realm, DEFAULT_USER.username);
await login(page, "jdoe", "jdoe", "groups");
const credentials = await getCredentials(jdoeUser!.id!, realm);
await deleteCredential(jdoeUser!.id!, credentials![0].id!, realm);
// Log in and delete the password credential of the user.
await login(page, realm);
const credentials = await adminClient.users.getCredentials({
realm,
id: user.id as string,
});
await adminClient.users.deleteCredential({
realm,
id: user.id as string,
credentialId: credentials[0].id as string,
});
// Navigate to the signing in section.
await page.getByTestId("accountSecurity").click();
await expect(page.getByTestId("account-security/signing-in")).toBeVisible();
await page.getByTestId("account-security/signing-in").click();
// Verify the password credential is not configured, and it is possible to create a new one.
await expect(
page.getByTestId("password/credential-list").getByRole("listitem"),
).toHaveCount(1);
await expect(
page.getByTestId("password/credential-list").getByRole("listitem"),
).toContainText("not set up");
await expect(page.getByTestId("password/create")).toBeVisible();
await expect(
page.getByTestId("otp/credential-list").getByRole("listitem"),
).toHaveCount(1);
await expect(
page.getByTestId("otp/credential-list").getByRole("listitem"),
).toContainText("not set up");
await expect(page.getByTestId("otp/create")).toBeVisible();
await page.getByTestId("password/create").click();
await expect(page.locator("#kc-page-title")).toContainText(
"Update password",

View File

@ -1,174 +0,0 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js";
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation.js";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation.js";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata.js";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js";
import { DEFAULT_REALM, SERVER_URL } from "./constants.ts";
const adminClient = new KeycloakAdminClient({
baseUrl: SERVER_URL,
realmName: DEFAULT_REALM,
});
await adminClient.auth({
username: "admin",
password: "admin",
grantType: "password",
clientId: "admin-cli",
});
export async function useTheme() {
const masterRealm = await adminClient.realms.findOne({
realm: DEFAULT_REALM,
});
await adminClient.realms.update(
{ realm: DEFAULT_REALM },
{ ...masterRealm, accountTheme: "keycloak.v3" },
);
}
export async function importRealm(realm: RealmRepresentation) {
await adminClient.realms.create(realm);
}
export async function deleteRealm(realm: string) {
await adminClient.realms.del({ realm });
}
export async function createClient(
client: ClientRepresentation,
): Promise<string> {
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,
realm = DEFAULT_REALM,
): Promise<string> {
return (await adminClient.identityProviders.create({ ...idp, realm }))["id"];
}
export async function deleteIdentityProvider(
alias: string,
realm = DEFAULT_REALM,
) {
await adminClient.identityProviders.del({ alias, realm });
}
export async function importUserProfile(
userProfile: UserProfileConfig,
realm: string,
) {
await adminClient.users.updateProfile({ ...userProfile, realm });
}
export async function enableLocalization(realm = DEFAULT_REALM) {
const realmRepresentation = await adminClient.realms.findOne({ realm });
await adminClient.realms.update(
{ realm },
{
...realmRepresentation,
internationalizationEnabled: true,
supportedLocales: ["en", "nl", "de"],
},
);
}
export async function createUser(
user: UserRepresentation,
realm = DEFAULT_REALM,
) {
try {
await adminClient.users.create({ ...user, realm });
} catch (error) {
console.error(error);
}
}
export async function createRandomUserWithPassword(
username: string,
password: string,
realm: string,
props?: UserRepresentation,
): Promise<string> {
await adminClient.auth({
username: "admin",
password: "admin",
grantType: "password",
clientId: "admin-cli",
});
return createUser(
{
username: username,
enabled: true,
credentials: [
{
type: "password",
value: password,
},
],
...props,
},
realm,
).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 = DEFAULT_REALM) {
try {
const users = await adminClient.users.find({ username, realm });
if (users.length === 0) {
console.warn(`User ${username} not found in realm ${realm}`);
return;
}
const { id } = users[0];
await adminClient.users.del({ id: id!, realm });
} catch (error) {
console.error(error);
}
}
export async function updateUser(user: UserRepresentation, realm: string) {
try {
await adminClient.users.update({ id: user.id!, realm }, user);
} catch (error) {
console.error(error);
}
}
export async function getCredentials(id: string, realm: string) {
try {
return await adminClient.users.getCredentials({ id, realm });
} catch (error) {
console.error(error);
}
}
export async function deleteCredential(
id: string,
credentialId: string,
realm: string,
) {
try {
await adminClient.users.deleteCredential({ id, credentialId, realm });
} catch (error) {
console.error(error);
}
}

View File

@ -1,84 +1,21 @@
import { expect, test } from "@playwright/test";
import { login } from "./login.ts";
import { getAccountUrl, getAdminUrl, getRootPath } from "./utils.ts";
import { login } from "./support/actions.ts";
import { createTestBed } from "./support/testbed.ts";
test.describe("Applications test", () => {
test.beforeEach(async ({ page }) => {
// Sign out all devices before each test
await login(page);
await page.getByTestId("accountSecurity").click();
await page.getByTestId("account-security/device-activity").click();
await page
.getByRole("button", { name: "Sign out all devices", exact: true })
.click();
await page.getByRole("button", { name: "Confirm" }).click();
await expect(
page.getByRole("heading", { name: "Sign in to your account" }),
).toBeVisible();
});
test("Single application", async ({ page }) => {
await login(page);
test.describe("Applications", () => {
test("shows a list of applications the user has access to", async ({
page,
}) => {
const realm = await createTestBed();
// Log in and navigate to the applications page.
await login(page, realm);
await page.getByTestId("applications").click();
// Assert that the applications list is displayed and contains the expected application.
await expect(page.getByTestId("applications-list-item")).toHaveCount(1);
await expect(page.getByTestId("applications-list-item")).toContainText(
"Account Console",
);
});
test("Single application twice", async ({ browser }) => {
const context1 = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)",
});
const context2 = await browser.newContext();
try {
const page1 = await context1.newPage();
const page2 = await context2.newPage();
await login(page1);
await login(page2);
await page1.getByTestId("applications").click();
await expect(page1.getByTestId("applications-list-item")).toHaveCount(1);
await expect(
page1.getByTestId("applications-list-item").nth(0),
).toContainText("Account Console");
} finally {
await context1.close();
await context2.close();
}
});
test("Two applications", async ({ page }) => {
await login(page);
// go to admin console
await page.goto("/");
await expect(page).toHaveURL(getAdminUrl());
await page.waitForURL(getAdminUrl());
await expect(page.getByTestId("options-toggle")).toBeVisible();
await page.goto(getRootPath());
await page.waitForURL(getAccountUrl());
await page.getByTestId("applications").click();
await expect(page.getByTestId("applications-list-item")).toHaveCount(2);
await expect(
page
.getByTestId("applications-list-item")
.filter({ hasText: "Account Console" }),
).toBeVisible();
await expect(
page
.getByTestId("applications-list-item")
.filter({ hasText: "security-admin-console" }),
).toBeVisible();
});
});

View File

@ -1,5 +0,0 @@
export const SERVER_URL = "http://localhost:8080";
export const ROOT_PATH = "/realms/:realm/account";
export const DEFAULT_REALM = "master";
export const ADMIN_USER = "admin";
export const ADMIN_PASSWORD = "admin";

View File

@ -1,15 +1,21 @@
import { test, expect } from "@playwright/test";
import { login } from "./login.ts";
import { expect, test } from "@playwright/test";
import groupsRealm from "./realms/groups-realm.json" with { type: "json" };
import { login } from "./support/actions.ts";
import { createTestBed } from "./support/testbed.ts";
test.describe("Groups page", () => {
test("List my groups", async ({ page }) => {
await login(page, "jdoe", "jdoe", "groups");
test.describe("Groups", () => {
test("lists groups", async ({ page }) => {
const realm = await createTestBed(groupsRealm);
await login(page, realm);
await page.getByTestId("groups").click();
await expect(page.getByTestId("group[1].name")).toHaveText("three");
});
test("List direct and indirect groups", async ({ page }) => {
await login(page, "alice", "alice", "groups");
test("lists direct and indirect groups", async ({ page }) => {
const realm = await createTestBed(groupsRealm);
await login(page, realm, "alice", "alice");
await page.getByTestId("groups").click();
await expect(

View File

@ -1,21 +0,0 @@
import type { Page } from "@playwright/test";
import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "./constants.ts";
import { getRootPath } from "./utils.ts";
export const login = async (
page: Page,
username = ADMIN_USER,
password = ADMIN_PASSWORD,
realm = DEFAULT_REALM,
queryParams?: Record<string, string>,
) => {
const rootPath =
getRootPath(realm) +
(queryParams ? "?" + new URLSearchParams(queryParams) : "");
await page.goto(rootPath);
await page.getByLabel("Username").fill(username);
await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByRole("button", { name: "Sign In" }).click();
};

View File

@ -1,20 +0,0 @@
import { expect, test } from "@playwright/test";
import { login } from "../login.ts";
const realm = "verifiable-credentials";
test.describe("Verifiable Credentials page", () => {
test.skip("Get offer for test-credential.", async ({ page }) => {
await login(page, "test-user", "test", realm);
await expect(page.getByTestId("qr-code")).toBeHidden();
await page.getByTestId("oid4vci").click();
await page.getByTestId("menu-toggle").click();
await expect(page.getByTestId("verifiable-credential")).toBeVisible();
await expect(page.getByTestId("natural-person")).toBeVisible();
await page.getByTestId("natural-person").click();
await expect(page.getByTestId("qr-code")).toBeVisible();
});
});

View File

@ -1,26 +1,17 @@
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata.js";
import { expect, test } from "@playwright/test";
import {
createRandomUserWithPassword,
deleteUser,
enableLocalization,
importUserProfile,
} from "../admin-client.ts";
import { login } from "../login.ts";
import userProfileConfig from "./user-profile.json" with { type: "json" };
const realm = "user-profile";
test.describe("Personal info page", () => {
const user = "user-" + crypto.randomUUID();
test.beforeAll(() => createRandomUserWithPassword(user, "pwd", realm));
test.afterAll(async () => deleteUser(user, realm));
import { login } from "../support/actions.ts";
import { createTestBed } from "../support/testbed.ts";
import userProfile from "./user-profile.json" with { type: "json" };
import { adminClient } from "../support/admin-client.ts";
import userProfileRealm from "../realms/user-profile-realm.json" with { type: "json" };
test.describe("Personal info", () => {
test("sets basic information", async ({ page }) => {
await login(page, user, "pwd", realm);
const realm = await createTestBed();
await page.getByTestId("email").fill(`${user}@somewhere.com`);
await login(page, realm);
await page.getByTestId("email").fill("edewit@somewhere.com");
await page.getByTestId("firstName").fill("Erik");
await page.getByTestId("lastName").fill("de Wit");
await page.getByTestId("save").click();
@ -30,30 +21,12 @@ test.describe("Personal info page", () => {
});
});
test.describe("Personal info with userprofile enabled", () => {
let user: string;
test.beforeAll(async () => {
await importUserProfile(userProfileConfig as UserProfileConfig, realm);
user = await createRandomUserWithPassword(
"user-" + crypto.randomUUID(),
"jdoe",
realm,
{
email: "jdoe@keycloak.org",
firstName: "John",
lastName: "Doe",
realmRoles: [],
clientRoles: {
account: ["manage-account"],
},
},
);
});
test.describe("Personal info (user profile enabled)", () => {
test("renders user profile fields", async ({ page }) => {
const realm = await createTestBed(userProfileRealm);
test.afterAll(() => deleteUser(user, realm));
test("render user profile fields", async ({ page }) => {
await login(page, user, "jdoe", realm);
await adminClient.users.updateProfile({ ...userProfile, realm });
await login(page, realm);
await expect(page.locator("#select")).toBeVisible();
await expect(page.getByTestId("help-label-select")).toBeVisible();
@ -63,8 +36,11 @@ test.describe("Personal info with userprofile enabled", () => {
await expect(page.getByText("Español")).toHaveCount(1);
});
test("render long select options as typeahead", async ({ page }) => {
await login(page, user, "jdoe", realm);
test("renders long select options as typeahead", async ({ page }) => {
const realm = await createTestBed(userProfileRealm);
await adminClient.users.updateProfile({ ...userProfile, realm });
await login(page, realm);
await page.locator("#alternatelang").click();
await page.waitForSelector("text=Italiano");
@ -77,8 +53,11 @@ test.describe("Personal info with userprofile enabled", () => {
await expect(page.getByText('Create "S"')).toBeHidden();
});
test("render long list of locales as typeahead", async ({ page }) => {
await login(page, user, "jdoe", realm);
test("renders long list of locales as typeahead", async ({ page }) => {
const realm = await createTestBed(userProfileRealm);
await adminClient.users.updateProfile({ ...userProfile, realm });
await login(page, realm);
await page.locator("#attributes\\.locale").click();
await page.waitForSelector("text=Italiano");
@ -91,8 +70,11 @@ test.describe("Personal info with userprofile enabled", () => {
await expect(page.getByText('Create "S"')).toBeHidden();
});
test("save user profile", async ({ page }) => {
await login(page, user, "jdoe", realm);
test("saves user profile", async ({ page }) => {
const realm = await createTestBed(userProfileRealm);
await adminClient.users.updateProfile({ ...userProfile, realm });
await login(page, realm);
await page.locator("#select").click();
await page.getByRole("option", { name: "two" }).click();
@ -117,15 +99,13 @@ test.describe("Personal info with userprofile enabled", () => {
});
test.describe("Realm localization", () => {
test.beforeAll(() => enableLocalization());
test("change locale", async ({ page }) => {
const user = await createRandomUserWithPassword(
"user-" + crypto.randomUUID(),
"pwd",
realm,
);
test("changes locale", async ({ page }) => {
const realm = await createTestBed({
internationalizationEnabled: true,
supportedLocales: ["en", "nl", "de"],
});
await login(page, user, "pwd", realm);
await login(page, realm);
await page.locator("#attributes\\.locale").click();
page.getByRole("option").filter({ hasText: "Deutsch" });
await page.getByRole("option", { name: "English" }).click();

View File

@ -1,23 +0,0 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation.js";
import { test as setup } from "@playwright/test";
import { deleteRealm, importRealm } from "./admin-client.ts";
import groupsRealm from "./realms/groups-realm.json" with { type: "json" };
import resourcesRealm from "./realms/resources-realm.json" with { type: "json" };
import userProfileRealm from "./realms/user-profile-realm.json" with { type: "json" };
import verifiableCredentialsRealm from "./realms/verifiable-credentials-realm.json" with { type: "json" };
setup("import realm", async () => {
await Promise.allSettled([
deleteRealm(groupsRealm.realm),
deleteRealm(resourcesRealm.realm),
deleteRealm(userProfileRealm.realm),
deleteRealm(verifiableCredentialsRealm.realm),
]);
await Promise.all([
importRealm(groupsRealm),
importRealm(resourcesRealm as RealmRepresentation),
importRealm(userProfileRealm),
importRealm(verifiableCredentialsRealm as RealmRepresentation),
]);
});

View File

@ -1,15 +0,0 @@
import { test as setup } from "@playwright/test";
import { deleteRealm } from "./admin-client.ts";
import groupsRealm from "./realms/groups-realm.json" with { type: "json" };
import resourcesRealm from "./realms/resources-realm.json" with { type: "json" };
import userProfileRealm from "./realms/user-profile-realm.json" with { type: "json" };
import verifiableCredentialsRealm from "./realms/verifiable-credentials-realm.json" with { type: "json" };
setup("delete realm", async () => {
await Promise.all([
deleteRealm(groupsRealm.realm),
deleteRealm(resourcesRealm.realm),
deleteRealm(userProfileRealm.realm),
deleteRealm(verifiableCredentialsRealm.realm),
]);
});

View File

@ -6,7 +6,7 @@
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "H0JaTc7VBu3HJR26vrzMxgidfJmgI5Dw",
"redirectUris": [ "/realms/groups/*" ],
"redirectUris": [ "/realms/*" ],
"webOrigins": [ "${authAdminUrl}" ],
"notBefore": 0,
"bearerOnly": false,

View File

@ -49,7 +49,6 @@
],
"groups": [
{
"id": "2a81d08d-9720-46c3-b66f-b566bcc7bce7",
"name": "five",
"path": "/five",
"attributes": {},
@ -58,7 +57,6 @@
"subGroups": []
},
{
"id": "68582f84-e8a3-4e74-b4ab-6a75d85c690d",
"name": "four",
"path": "/four",
"attributes": {},
@ -67,7 +65,6 @@
"subGroups": []
},
{
"id": "2b96c84d-cf09-4bc7-8292-d84a346bcc77",
"name": "one",
"path": "/one",
"attributes": {},
@ -75,7 +72,6 @@
"clientRoles": {},
"subGroups": [
{
"id": "7722ed31-0d9a-438a-895d-1cb7c1691c38",
"name": "subgroup",
"path": "/one/subgroup",
"attributes": {},
@ -86,7 +82,6 @@
]
},
{
"id": "fd9ec69b-aad3-426a-9412-a9c6819f72ff",
"name": "three",
"path": "/three",
"attributes": {},
@ -95,7 +90,6 @@
"subGroups": []
},
{
"id": "c2153a9f-b300-4843-9663-fdfb42a973ad",
"name": "two",
"path": "/two",
"attributes": {},

View File

@ -143,7 +143,6 @@
},
"ownerManagedAccess": true,
"attributes": {},
"_id": "3fba8c0d-c388-4177-8808-18f2b1917ec9",
"uris": [
"/album/20277be2-548b-49dd-9dbe-95fe1fe80830"
],
@ -164,7 +163,6 @@
},
"ownerManagedAccess": true,
"attributes": {},
"_id": "575da73f-cc0c-482f-ac2a-47c9dd70c390",
"uris": [
"/album/5fc8c73d-40e0-4682-b555-7b9f56ede273"
],
@ -181,11 +179,9 @@
"policies": [],
"scopes": [
{
"id": "0b4de4d2-b173-415f-9071-20f866e879ab",
"name": "album:view"
},
{
"id": "3b7cf7ed-46c7-4133-b15b-66d05b1f2afe",
"name": "album:delete"
}
],

View File

@ -106,32 +106,27 @@
],
"users": [
{
"username": "alice",
"username": "jdoe",
"firstName": "John",
"lastName": "Doe",
"email": "jdoe@keycloak.org",
"enabled": true,
"email": "alice@keycloak.org",
"firstName": "Alice",
"lastName": "In Chains",
"realmRoles": [],
"clientRoles": {
"account": ["manage-account"]
},
"credentials": [
{
"type": "password",
"value": "alice"
"value": "jdoe"
}
],
"realmRoles": [
"user", "uma_authorization"
],
"clientRoles": {
"account": [
"manage-account"
]
}
]
}
],
"identityProviders": [
{
"alias": "keycloak-oidc",
"displayName": "",
"internalId": "566b8743-6b80-4165-9675-ed10a2e9af9c",
"providerId": "keycloak-oidc",
"enabled": true,
"updateProfileFirstLoginMode": "on",

View File

@ -1,195 +0,0 @@
{
"id": "verifiable-credentials",
"realm": "verifiable-credentials",
"displayName": "Keycloak",
"displayNameHtml": "<div class=\"kc-logo-text\"><span>Keycloak</span></div>",
"enabled": true,
"attributes": {
"frontendUrl": "http://localhost:8080/",
"issuerDid": "did:web:test.org"
},
"sslRequired": "none",
"roles": {
"realm": [
{
"name": "user",
"description": "User privileges",
"composite": false,
"clientRole": false,
"containerId": "dome",
"attributes": {}
}
],
"client": {
"did:web:test-marketplace.org": [
{
"name": "LEGAL_REPRESENTATIVE",
"clientRole": true
},
{
"name": "EMPLOYEE",
"clientRole": true
}
]
}
},
"groups": [
],
"users": [
{
"username": "test-user",
"enabled": true,
"email": "test@user.org",
"firstName": "Test",
"lastName": "Employee",
"credentials": [
{
"type": "password",
"value": "test"
}
],
"clientRoles": {
"did:web:test-marketplace.org": [
"EMPLOYEE"
],
"account": [
"view-profile",
"manage-account"
]
},
"groups": [
]
}
],
"clients": [
{
"clientId": "did:web:test-marketplace.org",
"enabled": true,
"description": "Client to connect the marketplace",
"surrogateAuthRequired": false,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"defaultRoles": [],
"redirectUris": [],
"webOrigins": [],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false,
"frontchannelLogout": false,
"protocol": "oid4vc",
"attributes": {
"client.secret.creation.time": "1675260539",
"vc.natural-person.format": "jwt_vc",
"vc.natural-person.scope": "NaturalPersonCredential",
"vc.verifiable-credential.format": "jwt_vc",
"vc.verifiabel-credential.scope": "VerifiableCredential"
},
"protocolMappers": [
{
"name": "target-role-mapper",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-target-role-mapper",
"config": {
"claim.name": "roles",
"clientId": "did:web:test-marketplace.org"
}
},
{
"name": "target-vc-role-mapper",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-target-role-mapper",
"config": {
"claim.name": "roles",
"clientId": "did:web:test-marketplace.org"
}
},
{
"name": "email-mapper",
"protocol": "oid4vc",
"protocolMapper": "oid4vc-user-attribute-mapper",
"config": {
"claim.name": "email",
"userAttribute": "email"
}
}
],
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [],
"optionalClientScopes": []
}
],
"clientScopes": [
{
"name": "roles",
"description": "OpenID Connect scope for add user roles to the access token",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "true",
"consent.screen.text": "${rolesScopeConsentText}"
},
"protocolMappers": [
{
"name": "audience resolve",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-resolve-mapper",
"consentRequired": false,
"config": {
"introspection.token.claim": "true",
"access.token.claim": "true"
}
},
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"introspection.token.claim": "true",
"multivalued": "true",
"user.attribute": "foo",
"access.token.claim": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String"
}
},
{
"name": "client roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"introspection.token.claim": "true",
"multivalued": "true",
"user.attribute": "foo",
"access.token.claim": "true",
"claim.name": "resource_access.${client_id}.roles",
"jsonType.label": "String"
}
}
]
}
],
"defaultDefaultClientScopes": [
],
"defaultOptionalClientScopes": [
],
"components": {
"org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder": [
{
"id": "sd-jwt-credential-builder",
"name": "credential-builder-dc+sd-jwt",
"providerId": "dc+sd-jwt",
"subComponents": {},
"config": {}
}
]
}
}

View File

@ -1,43 +1,58 @@
import { expect, test } from "@playwright/test";
import { login } from "./support/actions.ts";
import {
ADMIN_CLIENT_ID,
ADMIN_PASSWORD,
ADMIN_USERNAME,
DEFAULT_REALM,
getAdminUrl,
} from "./support/common.ts";
import { ADMIN_PASSWORD, ADMIN_USER, DEFAULT_REALM } from "./constants.ts";
import { login } from "./login.ts";
import { getAdminUrl } from "./utils.ts";
test.describe("Signing in with referrer link", () => {
test.describe("Referrer", () => {
test("shows a referrer link when a matching client exists", async ({
page,
}) => {
const referrer = "security-admin-console";
const referrerUrl = getAdminUrl();
const referrerName = "Security Admin Console";
const queryParams = new URLSearchParams([
["referrer", ADMIN_CLIENT_ID],
["referrer_uri", getAdminUrl(DEFAULT_REALM).toString()],
]);
const queryParams = {
referrer,
referrer_uri: referrerUrl,
};
// Log in with a referrer to the admin console, and check if the referrer link is displayed.
await login(
page,
DEFAULT_REALM,
ADMIN_USERNAME,
ADMIN_PASSWORD,
queryParams,
);
await expect(page.getByTestId("referrer-link")).toContainText(
"Security Admin Console",
);
await login(page, ADMIN_USER, ADMIN_PASSWORD, DEFAULT_REALM, queryParams);
await expect(page.getByTestId("referrer-link")).toContainText(referrerName);
// Navigate around to ensure the referrer is still shown.
// Navigate around and check if the referrer link is still displayed.
await page.getByTestId("accountSecurity").click();
await expect(page.getByTestId("account-security/signing-in")).toBeVisible();
await expect(page.getByTestId("referrer-link")).toContainText(referrerName);
await page.getByTestId("account-security/signing-in").click();
await expect(page.getByTestId("referrer-link")).toContainText(
"Security Admin Console",
);
});
test("shows no referrer link when an invalid URL is passed", async ({
page,
}) => {
const referrer = "security-admin-console";
const referrerUrl = "http://i-am-not-an-allowed-url.com";
const queryParams = new URLSearchParams([
["referrer", ADMIN_CLIENT_ID],
["referrer_uri", "http://i-am-not-an-allowed-url.com"],
]);
const queryParams = {
referrer,
referrer_uri: referrerUrl,
};
await login(page, ADMIN_USER, ADMIN_PASSWORD, DEFAULT_REALM, queryParams);
// Log in with an invalid referrer URL, and check if the referrer link is not displayed.
await login(
page,
DEFAULT_REALM,
ADMIN_USERNAME,
ADMIN_PASSWORD,
queryParams,
);
await expect(page.getByText("Manage your basic information")).toBeVisible();
await expect(page.getByTestId("referrer-link")).toBeHidden();
});

View File

@ -1,18 +1,27 @@
import { test, expect } from "@playwright/test";
import { login } from "./login.ts";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation.js";
import { expect, test } from "@playwright/test";
import resourcesRealm from "./realms/resources-realm.json" with { type: "json" };
import { login } from "./support/actions.ts";
import { createTestBed } from "./support/testbed.ts";
test.describe("My resources page", () => {
test.describe("Resources", () => {
test.describe.configure({ mode: "serial" });
test("List my resources", async ({ page }) => {
await login(page, "jdoe", "jdoe", "photoz");
let realm: string;
test.beforeAll(async () => {
realm = await createTestBed(resourcesRealm as RealmRepresentation);
});
test("shows the resources owned by the user", async ({ page }) => {
await login(page, realm);
await page.getByTestId("resources").click();
await expect(page.getByRole("gridcell", { name: "one" })).toBeVisible();
});
test("Nothing is shared with alice", async ({ page }) => {
await login(page, "alice", "alice", "photoz");
test("shows no resources are shared with another user", async ({ page }) => {
await login(page, realm, "alice", "alice");
await page.getByTestId("resources").click();
await page.getByTestId("sharedWithMe").click();
@ -20,8 +29,8 @@ test.describe("My resources page", () => {
expect(tableData).toBe(0);
});
test("Share one with alice", async ({ page }) => {
await login(page, "jdoe", "jdoe", "photoz");
test("shares a recourse with another user", async ({ page }) => {
await login(page, realm);
await page.getByTestId("resources").click();
await page.getByTestId("expand-one").click();
@ -52,8 +61,8 @@ test.describe("My resources page", () => {
await expect(page.getByTestId("shared-with-alice")).toBeVisible();
});
test("One is shared with alice", async ({ page }) => {
await login(page, "alice", "alice", "photoz");
test("shows the resources shared with another user", async ({ page }) => {
await login(page, realm, "alice", "alice");
await page.getByTestId("resources").click();
await page.getByTestId("sharedWithMe").click();

View File

@ -0,0 +1,27 @@
import type { Page } from "@playwright/test";
import { DEFAULT_PASSWORD, DEFAULT_USERNAME, getAccountUrl } from "./common.ts";
export async function login(
page: Page,
realm: string,
username = DEFAULT_USERNAME,
password = DEFAULT_PASSWORD,
queryParams?: URLSearchParams,
): Promise<void> {
const url = getAccountUrl(realm);
if (queryParams) {
for (const [key, value] of queryParams) {
url.searchParams.set(key, value);
}
}
await page.goto(url.toString());
await page
.getByRole("textbox", { name: "Username or email", exact: true })
.fill(username);
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(password);
await page.getByRole("button", { name: "Sign In", exact: true }).click();
}

View File

@ -0,0 +1,28 @@
import AdminClient from "@keycloak/keycloak-admin-client";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js";
import { ADMIN_PASSWORD, ADMIN_USERNAME, SERVER_URL } from "./common.ts";
export const adminClient = new AdminClient({
baseUrl: SERVER_URL.toString(),
});
await adminClient.auth({
username: ADMIN_USERNAME,
password: ADMIN_PASSWORD,
grantType: "password",
clientId: "admin-cli",
});
export async function findUserByUsername(
realm: string,
username: string,
): Promise<UserRepresentation> {
const users = await adminClient.users.find({
realm,
username,
exact: true,
max: 1,
});
return users[0];
}

View File

@ -0,0 +1,37 @@
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js";
import { generatePath } from "react-router-dom";
export const SERVER_URL = new URL("http://localhost:8080");
export const ACCOUNT_ROOT_PATH = "/realms/:realm/account" as const;
export const ADMIN_ROOT_PATH = "/admin/:realm/console" as const;
export const DEFAULT_REALM = "master";
export const ADMIN_CLIENT_ID = "security-admin-console";
export const ADMIN_USERNAME = "admin";
export const ADMIN_PASSWORD = "admin";
export const DEFAULT_USERNAME = "jdoe";
export const DEFAULT_PASSWORD = "jdoe";
export const DEFAULT_USER = {
username: DEFAULT_USERNAME,
firstName: "John",
lastName: "Doe",
email: "jdoe@keycloak.org",
enabled: true,
credentials: [
{
type: "password",
value: DEFAULT_PASSWORD,
},
],
clientRoles: {
account: ["manage-account"],
},
} satisfies UserRepresentation;
export function getAccountUrl(realm: string): URL {
return new URL(generatePath(ACCOUNT_ROOT_PATH, { realm }), SERVER_URL);
}
export function getAdminUrl(realm: string): URL {
return new URL(generatePath(ADMIN_ROOT_PATH, { realm }), SERVER_URL);
}

View File

@ -0,0 +1,16 @@
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation.js";
import { adminClient } from "./admin-client.ts";
import { DEFAULT_USER } from "./common.ts";
export async function createTestBed(
overrides?: RealmRepresentation,
): Promise<string> {
const { realmName } = await adminClient.realms.create({
enabled: true,
users: [DEFAULT_USER],
...overrides,
realm: crypto.randomUUID(),
});
return realmName;
}

View File

@ -1,4 +1,3 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {

View File

@ -1,14 +0,0 @@
import { generatePath } from "react-router-dom";
import { DEFAULT_REALM, ROOT_PATH, SERVER_URL } from "./constants.ts";
export function getAccountUrl() {
return SERVER_URL + getRootPath();
}
export function getAdminUrl() {
return SERVER_URL + "/admin/master/console/";
}
export const getRootPath = (realm = DEFAULT_REALM) =>
generatePath(ROOT_PATH, { realm });