From 9af39ac1bc2e44754963edfa6cb151fa167100ab Mon Sep 17 00:00:00 2001
From: Stefan Benz <46600784+stebenz@users.noreply.github.com>
Date: Mon, 28 Oct 2024 19:44:50 +0100
Subject: [PATCH 01/19] chore: add basic acceptance tests
---
acceptance/tests/admin.spec.ts | 7 +
acceptance/tests/login.ts | 19 ++
acceptance/tests/password.ts | 12 ++
acceptance/tests/register.spec.ts | 13 ++
acceptance/tests/register.ts | 31 ++++
acceptance/tests/user.ts | 175 ++++++++++++++++++
acceptance/tests/username-passkey.spec.ts | 108 +++++++++++
.../tests/username-password-changed.spec.ts | 26 +++
acceptance/tests/username-password.spec.ts | 38 ++--
apps/login/src/lib/server/passkeys.ts | 3 +-
apps/login/src/lib/server/session.ts | 3 +-
playwright.config.ts | 122 ++++++------
12 files changed, 483 insertions(+), 74 deletions(-)
create mode 100644 acceptance/tests/admin.spec.ts
create mode 100644 acceptance/tests/login.ts
create mode 100644 acceptance/tests/password.ts
create mode 100644 acceptance/tests/register.spec.ts
create mode 100644 acceptance/tests/register.ts
create mode 100644 acceptance/tests/user.ts
create mode 100644 acceptance/tests/username-passkey.spec.ts
create mode 100644 acceptance/tests/username-password-changed.spec.ts
diff --git a/acceptance/tests/admin.spec.ts b/acceptance/tests/admin.spec.ts
new file mode 100644
index 00000000..e08d7bf8
--- /dev/null
+++ b/acceptance/tests/admin.spec.ts
@@ -0,0 +1,7 @@
+import {test} from "@playwright/test";
+import {loginWithPassword} from "./login";
+
+test("admin login", async ({page}) => {
+ await loginWithPassword(page, "zitadel-admin@zitadel.localhost", "Password1.")
+ await page.getByRole("heading", {name: "Welcome ZITADEL Admin!"}).click();
+});
diff --git a/acceptance/tests/login.ts b/acceptance/tests/login.ts
new file mode 100644
index 00000000..3ebcade5
--- /dev/null
+++ b/acceptance/tests/login.ts
@@ -0,0 +1,19 @@
+import {Page} from "@playwright/test";
+
+export async function loginWithPassword(page: Page, username: string, password: string) {
+ await page.goto("/loginname");
+ const loginname = page.getByLabel("Loginname");
+ await loginname.pressSequentially(username);
+ await loginname.press("Enter");
+ const pw = page.getByLabel("Password");
+ await pw.pressSequentially(password);
+ await pw.press("Enter");
+}
+
+
+export async function loginWithPasskey(page: Page, username: string) {
+ await page.goto("/loginname");
+ const loginname = page.getByLabel("Loginname");
+ await loginname.pressSequentially(username);
+ await loginname.press("Enter");
+}
\ No newline at end of file
diff --git a/acceptance/tests/password.ts b/acceptance/tests/password.ts
new file mode 100644
index 00000000..360ff65e
--- /dev/null
+++ b/acceptance/tests/password.ts
@@ -0,0 +1,12 @@
+import {Page} from "@playwright/test";
+
+export async function changePassword(page: Page, loginname: string, password: string) {
+ await page.goto('password/change?' + new URLSearchParams({loginName: loginname}));
+ await changePasswordScreen(page, loginname, password, password)
+ await page.getByRole('button', {name: 'Continue'}).click();
+}
+
+async function changePasswordScreen(page: Page, loginname: string, password1: string, password2: string) {
+ await page.getByLabel('New Password *').pressSequentially(password1);
+ await page.getByLabel('Confirm Password *').pressSequentially(password2);
+}
\ No newline at end of file
diff --git a/acceptance/tests/register.spec.ts b/acceptance/tests/register.spec.ts
new file mode 100644
index 00000000..932bf313
--- /dev/null
+++ b/acceptance/tests/register.spec.ts
@@ -0,0 +1,13 @@
+import {test} from "@playwright/test";
+import {registerWithPassword} from './register';
+import {loginWithPassword} from "./login";
+
+test("register with password", async ({page}) => {
+ const firstname = "firstname"
+ const lastname = "lastname"
+ const username = "register@example.com"
+ const password = "Password1!"
+ await registerWithPassword(page, firstname, lastname, username, password, password)
+ await page.getByRole("heading", {name: "Welcome " + lastname + " " + lastname + "!"}).click();
+ await loginWithPassword(page, username, password)
+});
diff --git a/acceptance/tests/register.ts b/acceptance/tests/register.ts
new file mode 100644
index 00000000..bb403f69
--- /dev/null
+++ b/acceptance/tests/register.ts
@@ -0,0 +1,31 @@
+import {Page} from "@playwright/test";
+
+export async function registerWithPassword(page: Page, firstname: string, lastname: string, email: string, password1: string, password2: string) {
+ await page.goto('/register');
+ await registerUserScreen(page, firstname, lastname, email)
+ await page.getByLabel('Password').click();
+ await page.getByRole('button', {name: 'Continue'}).click();
+ await registerPasswordScreen(page, password1, password2)
+ await page.getByRole('button', {name: 'Continue'}).click();
+}
+
+export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string) {
+ await page.goto('/register');
+ await registerUserScreen(page, firstname, lastname, email)
+ await page.getByLabel('Passkey').click();
+ await page.getByRole('button', {name: 'Continue'}).click();
+ await page.getByRole('button', {name: 'Continue'}).click();
+}
+
+async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) {
+ await page.getByLabel('First name *').pressSequentially(firstname);
+ await page.getByLabel('Last name *').pressSequentially(lastname);
+ await page.getByLabel('E-mail *').pressSequentially(email);
+ await page.getByRole('checkbox').first().check();
+ await page.getByRole('checkbox').nth(1).check();
+}
+
+async function registerPasswordScreen(page: Page, password1: string, password2: string) {
+ await page.getByLabel('Password *', {exact: true}).fill(password1);
+ await page.getByLabel('Confirm Password *').fill(password2);
+}
\ No newline at end of file
diff --git a/acceptance/tests/user.ts b/acceptance/tests/user.ts
new file mode 100644
index 00000000..18c2ec55
--- /dev/null
+++ b/acceptance/tests/user.ts
@@ -0,0 +1,175 @@
+import fetch from 'node-fetch';
+import {Page} from "@playwright/test";
+import {registerWithPasskey} from "./register";
+import {loginWithPasskey, loginWithPassword} from "./login";
+import {changePassword} from "./password";
+
+export interface userProps {
+ email: string;
+ firstName: string;
+ lastName: string;
+ organization: string;
+ password: string;
+}
+
+class User {
+ private readonly props: userProps;
+ private user: string;
+
+ constructor(userProps: userProps) {
+ this.props = userProps;
+ }
+
+ async ensure() {
+ await this.remove()
+
+ const body = {
+ username: this.props.email,
+ organization: {
+ orgId: this.props.organization
+ },
+ profile: {
+ givenName: this.props.firstName,
+ familyName: this.props.lastName,
+ },
+ email: {
+ email: this.props.email,
+ isVerified: true,
+ },
+ password: {
+ password: this.props.password!,
+ }
+ }
+
+ const response = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/human", {
+ method: 'POST',
+ body: JSON.stringify(body),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': "Bearer " + process.env.ZITADEL_SERVICE_USER_TOKEN!
+ }
+ });
+ if (response.statusCode >= 400 && response.statusCode != 409) {
+ const error = 'HTTP Error: ' + response.statusCode + ' - ' + response.statusMessage;
+ console.error(error);
+ throw new Error(error);
+ }
+ return
+ }
+
+ async remove() {
+ const response = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/" + this.userId(), {
+ method: 'DELETE',
+ headers: {
+ 'Authorization': "Bearer " + process.env.ZITADEL_SERVICE_USER_TOKEN!
+ }
+ });
+ if (response.statusCode >= 400 && response.statusCode != 404) {
+ const error = 'HTTP Error: ' + response.statusCode + ' - ' + response.statusMessage;
+ console.error(error);
+ throw new Error(error);
+ }
+ return
+ }
+
+ public userId() {
+ return this.user;
+ }
+
+ public username() {
+ return this.props.email;
+ }
+
+ public password() {
+ return this.props.password;
+ }
+
+ public fullName() {
+ return this.props.firstName + " " + this.props.lastName
+ }
+
+ public async login(page: Page) {
+ await loginWithPassword(page, this.username(), this.password())
+ }
+
+ public async changePassword(page: Page, password: string) {
+ await loginWithPassword(page, this.username(), this.password())
+ await changePassword(page, this.username(), password)
+ this.props.password = password
+ }
+}
+
+export class PasswordUser extends User {
+}
+
+export interface passkeyUserProps {
+ email: string;
+ firstName: string;
+ lastName: string;
+ organization: string;
+}
+
+export class PasskeyUser {
+ private props: passkeyUserProps
+
+ constructor(props: passkeyUserProps) {
+ this.props = props
+ }
+
+ async ensurePasskey(page: Page) {
+ await registerWithPasskey(page, this.props.firstName, this.props.lastName, this.props.email)
+ }
+
+ public async login(page: Page) {
+ await loginWithPasskey(page, this.props.email)
+ }
+
+ public fullName() {
+ return this.props.firstName + " " + this.props.lastName
+ }
+
+ async ensurePasskeyRegister() {
+ const url = new URL(process.env.ZITADEL_API_URL!)
+ const registerBody = {
+ domain: url.hostname,
+ }
+ const userId = ""
+ const registerResponse = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/" + userId + "/passkeys", {
+ method: 'POST',
+ body: JSON.stringify(registerBody),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': "Bearer " + process.env.ZITADEL_SERVICE_USER_TOKEN!
+ }
+ });
+ if (registerResponse.statusCode >= 400 && registerResponse.statusCode != 409) {
+ const error = 'HTTP Error: ' + registerResponse.statusCode + ' - ' + registerResponse.statusMessage;
+ console.error(error);
+ throw new Error(error);
+ }
+ const respJson = await registerResponse.json()
+ return respJson
+ }
+
+ async ensurePasskeyVerify(passkeyId: string, credential: Credential) {
+ const verifyBody = {
+ publicKeyCredential: credential,
+ passkeyName: "passkey",
+ }
+ const userId = ""
+ const verifyResponse = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/" + userId + "/passkeys/" + passkeyId, {
+ method: 'POST',
+ body: JSON.stringify(verifyBody),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': "Bearer " + process.env.ZITADEL_SERVICE_USER_TOKEN!
+ }
+ });
+ if (verifyResponse.statusCode >= 400 && verifyResponse.statusCode != 409) {
+ const error = 'HTTP Error: ' + verifyResponse.statusCode + ' - ' + verifyResponse.statusMessage;
+ console.error(error);
+ throw new Error(error);
+ }
+ return
+ }
+}
\ No newline at end of file
diff --git a/acceptance/tests/username-passkey.spec.ts b/acceptance/tests/username-passkey.spec.ts
new file mode 100644
index 00000000..0cc98851
--- /dev/null
+++ b/acceptance/tests/username-passkey.spec.ts
@@ -0,0 +1,108 @@
+import path from 'path';
+import dotenv from 'dotenv';
+
+// Read from ".env" file.
+dotenv.config({path: path.resolve(__dirname, '.env.local')});
+
+/*
+const BASE64_ENCODED_PK =
+ "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDbBOu5Lhs4vpowbCnmCyLUpIE7JM9sm9QXzye2G+jr+Kr" +
+ "MsinWohEce47BFPJlTaDzHSvOW2eeunBO89ZcvvVc8RLz4qyQ8rO98xS1jtgqi1NcBPETDrtzthODu/gd0sjB2Tk3TLuBGV" +
+ "oPXt54a+Oo4JbBJ6h3s0+5eAfGplCbSNq6hN3Jh9YOTw5ZA6GCEy5l8zBaOgjXytd2v2OdSVoEDNiNQRkjJd2rmS2oi9AyQ" +
+ "FR3B7BrPSiDlCcITZFOWgLF5C31Wp/PSHwQhlnh7/6YhnE2y9tzsUvzx0wJXrBADW13+oMxrneDK3WGbxTNYgIi1PvSqXlq" +
+ "GjHtCK+R2QkXAgMBAAECggEAVc6bu7VAnP6v0gDOeX4razv4FX/adCao9ZsHZ+WPX8PQxtmWYqykH5CY4TSfsuizAgyPuQ0" +
+ "+j4Vjssr9VODLqFoanspT6YXsvaKanncUYbasNgUJnfnLnw3an2XpU2XdmXTNYckCPRX9nsAAURWT3/n9ljc/XYY22ecYxM" +
+ "8sDWnHu2uKZ1B7M3X60bQYL5T/lVXkKdD6xgSNLeP4AkRx0H4egaop68hoW8FIwmDPVWYVAvo8etzWCtibRXz5FcNld9MgD" +
+ "/Ai7ycKy4Q1KhX5GBFI79MVVaHkSQfxPHpr7/XcmpQOEAr+BMPon4s4vnKqAGdGB3j/E3d/+4F2swykoQKBgQD8hCsp6FIQ" +
+ "5umJlk9/j/nGsMl85LgLaNVYpWlPRKPc54YNumtvj5vx1BG+zMbT7qIE3nmUPTCHP7qb5ERZG4CdMCS6S64/qzZEqijLCqe" +
+ "pwj6j4fV5SyPWEcpxf6ehNdmcfgzVB3Wolfwh1ydhx/96L1jHJcTKchdJJzlfTvq8wwKBgQDeCnKws1t5GapfE1rmC/h4ol" +
+ "L2qZTth9oQmbrXYohVnoqNFslDa43ePZwL9Jmd9kYb0axOTNMmyrP0NTj41uCfgDS0cJnNTc63ojKjegxHIyYDKRZNVUR/d" +
+ "xAYB/vPfBYZUS7M89pO6LLsHhzS3qpu3/hppo/Uc/AM/r8PSflNHQKBgDnWgBh6OQncChPUlOLv9FMZPR1ZOfqLCYrjYEqi" +
+ "uzGm6iKM13zXFO4AGAxu1P/IAd5BovFcTpg79Z8tWqZaUUwvscnl+cRlj+mMXAmdqCeO8VASOmqM1ml667axeZDIR867ZG8" +
+ "K5V029Wg+4qtX5uFypNAAi6GfHkxIKrD04yOHAoGACdh4wXESi0oiDdkz3KOHPwIjn6BhZC7z8mx+pnJODU3cYukxv3WTct" +
+ "lUhAsyjJiQ/0bK1yX87ulqFVgO0Knmh+wNajrb9wiONAJTMICG7tiWJOm7fW5cfTJwWkBwYADmkfTRmHDvqzQSSvoC2S7aa" +
+ "9QulbC3C/qgGFNrcWgcT9kCgYAZTa1P9bFCDU7hJc2mHwJwAW7/FQKEJg8SL33KINpLwcR8fqaYOdAHWWz636osVEqosRrH" +
+ "zJOGpf9x2RSWzQJ+dq8+6fACgfFZOVpN644+sAHfNPAI/gnNKU5OfUv+eav8fBnzlf1A3y3GIkyMyzFN3DE7e0n/lyqxE4H" +
+ "BYGpI8g==";
+
+const test = base.extend<{ user: PasskeyUser }>({
+ user: async ({page}, use) => {
+
+ // Initialize a CDP session for the current page
+ const client = await page.context().newCDPSession(page);
+ // Enable WebAuthn environment in this session
+ await client.send('WebAuthn.enable', {enableUI: true});
+
+ // Attach a virtual authenticator with specific options
+ const result = await client.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'usb',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ },
+ });
+ const authenticatorId = result.authenticatorId;
+
+ const url = new URL(process.env.ZITADEL_API_URL!)
+ await client.send('WebAuthn.addCredential', {
+ credential: {
+ credentialId: "",
+ rpId: url.hostname,
+ privateKey: BASE64_ENCODED_PK,
+ isResidentCredential: false,
+ signCount: 0,
+ },
+ authenticatorId: authenticatorId
+ });
+
+ await client.send('WebAuthn.setUserVerified', {
+ authenticatorId: authenticatorId,
+ isUserVerified: true,
+ });
+ await client.send('WebAuthn.setAutomaticPresenceSimulation', {
+ authenticatorId: authenticatorId,
+ enabled: true,
+ });
+
+ const user = new PasskeyUser({
+ email: "password@example.com",
+ firstName: "first",
+ lastName: "last",
+ organization: "",
+ });
+ await user.ensure();
+ const respJson = await user.ensurePasskeyRegister();
+
+ const credential = await navigator.credentials.create({
+ publicKey: respJson.publicKeyCredentialCreationOptions
+ });
+
+ await user.ensurePasskeyVerify(respJson.passkeyId, respJson.publicKeyCredentialCreationOptions)
+ use(user);
+ await client.send('WebAuthn.setAutomaticPresenceSimulation', {
+ authenticatorId,
+ enabled: false,
+ });
+ },
+});
+
+const test = base.extend<{ user: PasskeyUser }>({
+ user: async ({page}, use) => {
+ const user = new PasskeyUser({
+ email: "passkey@example.com",
+ firstName: "first",
+ lastName: "last",
+ organization: "",
+ });
+ await user.ensurePasskey(page);
+ await use(user)
+ },
+});
+
+test("username and passkey login", async ({user, page}) => {
+ await user.login(page)
+ await page.getByRole("heading", {name: "Welcome " + user.fullName() + "!"}).click();
+});
+*/
\ No newline at end of file
diff --git a/acceptance/tests/username-password-changed.spec.ts b/acceptance/tests/username-password-changed.spec.ts
new file mode 100644
index 00000000..a3e53436
--- /dev/null
+++ b/acceptance/tests/username-password-changed.spec.ts
@@ -0,0 +1,26 @@
+import {test as base} from "@playwright/test";
+import {PasswordUser} from './user';
+import path from 'path';
+import dotenv from 'dotenv';
+
+// Read from ".env" file.
+dotenv.config({path: path.resolve(__dirname, '.env.local')});
+
+const test = base.extend<{ user: PasswordUser }>({
+ user: async ({page}, use) => {
+ const user = new PasswordUser({
+ email: "password-changed@example.com",
+ firstName: "first",
+ lastName: "last",
+ password: "Password1!",
+ organization: "",
+ });
+ await user.ensure();
+ await use(user);
+ },
+});
+
+test("username and password changed login", async ({user, page}) => {
+ await user.changePassword(page, "ChangedPw1!")
+ await user.login(page)
+});
diff --git a/acceptance/tests/username-password.spec.ts b/acceptance/tests/username-password.spec.ts
index 31d16535..f0bb8bdd 100644
--- a/acceptance/tests/username-password.spec.ts
+++ b/acceptance/tests/username-password.spec.ts
@@ -1,12 +1,28 @@
-import { test } from "@playwright/test";
-
-test("username and password", async ({ page }) => {
- await page.goto("/");
- const loginname = page.getByLabel("Loginname");
- await loginname.pressSequentially("zitadel-admin@zitadel.localhost");
- await loginname.press("Enter");
- const password = page.getByLabel("Password");
- await password.pressSequentially("Password1!");
- await password.press("Enter");
- await page.getByRole("heading", { name: "Welcome ZITADEL Admin!" }).click();
+import {test as base} from "@playwright/test";
+import {PasswordUser} from './user';
+import path from 'path';
+import dotenv from 'dotenv';
+
+// Read from ".env" file.
+dotenv.config({path: path.resolve(__dirname, '.env.local')});
+
+const test = base.extend<{ user: PasswordUser }>({
+ user: async ({page}, use) => {
+ const user = new PasswordUser({
+ email: "password@example.com",
+ firstName: "first",
+ lastName: "last",
+ password: "Password1!",
+ organization: "",
+ });
+ await user.ensure();
+ await use(user);
+ },
+});
+
+test("username and password login", async ({user, page}) => {
+ await user.login(page)
+ await page.getByRole("heading", {name: "Welcome " + user.fullName() + "!"}).click();
});
+
+
diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts
index 5ad170f8..d7a81e79 100644
--- a/apps/login/src/lib/server/passkeys.ts
+++ b/apps/login/src/lib/server/passkeys.ts
@@ -37,7 +37,8 @@ export async function registerPasskeyLink(
sessionToken: sessionCookie.token,
});
- const domain = headers().get("host");
+ // TODO remove ports from host header for URL with port
+ const domain = "localhost";
if (!domain) {
throw new Error("Could not get domain");
diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts
index 3bd97629..b1beb654 100644
--- a/apps/login/src/lib/server/session.ts
+++ b/apps/login/src/lib/server/session.ts
@@ -85,7 +85,8 @@ export async function updateSession(options: UpdateSessionCommand) {
return Promise.reject(error);
});
- const host = headers().get("host");
+ // TODO remove ports from host header for URL with port
+ const host = "localhost"
if (
host &&
diff --git a/playwright.config.ts b/playwright.config.ts
index bf73cb21..f3a9658f 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig, devices } from "@playwright/test";
+import {defineConfig, devices} from "@playwright/test";
/**
* Read environment variables from file.
@@ -12,70 +12,70 @@ import { defineConfig, devices } from "@playwright/test";
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
- testDir: "./acceptance/tests",
- /* Run tests in files in parallel */
- fullyParallel: true,
- /* Fail the build on CI if you accidentally left test.only in the source code. */
- forbidOnly: !!process.env.CI,
- /* Retry on CI only */
- retries: process.env.CI ? 2 : 0,
- /* Opt out of parallel tests on CI. */
- workers: process.env.CI ? 1 : undefined,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: "html",
- /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
- use: {
- /* Base URL to use in actions like `await page.goto('/')`. */
- baseURL: "http://127.0.0.1:3000",
+ testDir: "./acceptance/tests",
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: "html",
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: "http://127.0.0.1:3000",
- /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: "on-first-retry",
- },
-
- /* Configure projects for major browsers */
- projects: [
- {
- name: "chromium",
- use: { ...devices["Desktop Chrome"] },
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: "on-first-retry",
},
- {
- name: "firefox",
- use: { ...devices["Desktop Firefox"] },
- },
- /* TODO: webkit fails. Is this a bug?
- {
- name: 'webkit',
- use: { ...devices['Desktop Safari'] },
- },
-*/
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: "chromium",
+ use: {...devices["Desktop Chrome"]},
+ },
+ /*
+ {
+ name: "firefox",
+ use: { ...devices["Desktop Firefox"] },
+ },
+ TODO: webkit fails. Is this a bug?
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+ */
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: { ...devices['Pixel 5'] },
- // },
- // {
- // name: 'Mobile Safari',
- // use: { ...devices['iPhone 12'] },
- // },
+ /* Test against mobile viewports. */
+ // {
+ // name: 'Mobile Chrome',
+ // use: { ...devices['Pixel 5'] },
+ // },
+ // {
+ // name: 'Mobile Safari',
+ // use: { ...devices['iPhone 12'] },
+ // },
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: { ...devices['Desktop Edge'], channel: 'msedge' },
- // },
- // {
- // name: 'Google Chrome',
- // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
- // },
- ],
+ /* Test against branded browsers. */
+ // {
+ // name: 'Microsoft Edge',
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
+ // },
+ // {
+ // name: 'Google Chrome',
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+ // },
+ ],
- /* Run local dev server before starting the tests */
- webServer: {
- command: "pnpm start:built",
- url: "http://127.0.0.1:3000",
- reuseExistingServer: !process.env.CI,
- timeout: 5 * 60_000,
- },
+ /* Run local dev server before starting the tests */
+ webServer: {
+ command: "pnpm start:built",
+ url: "http://127.0.0.1:3000",
+ reuseExistingServer: !process.env.CI,
+ timeout: 5 * 60_000,
+ },
});
From 9c041cc46fef4cba9df066e2549ebf19a59ec0b0 Mon Sep 17 00:00:00 2001
From: Stefan Benz <46600784+stebenz@users.noreply.github.com>
Date: Wed, 30 Oct 2024 18:50:32 +0100
Subject: [PATCH 02/19] chore: add data-testid's and some additional testing
---
acceptance/tests/login.ts | 23 ++-
acceptance/tests/password.ts | 10 +-
acceptance/tests/register.spec.ts | 5 +-
acceptance/tests/register.ts | 38 ++--
acceptance/tests/user.ts | 164 +++++++++++++-----
acceptance/tests/username-passkey.spec.ts | 7 +-
.../tests/username-password-changed.spec.ts | 2 +-
acceptance/tests/username-password.spec.ts | 2 +-
.../src/components/change-password-form.tsx | 5 +-
apps/login/src/components/login-otp.tsx | 3 +-
apps/login/src/components/login-passkey.tsx | 2 +
apps/login/src/components/password-form.tsx | 4 +-
.../components/privacy-policy-checkboxes.tsx | 2 +
.../register-form-without-password.tsx | 6 +-
.../login/src/components/register-passkey.tsx | 1 +
apps/login/src/components/register-u2f.tsx | 3 +-
.../src/components/set-password-form.tsx | 3 +-
.../components/set-register-password-form.tsx | 3 +
apps/login/src/components/totp-register.tsx | 2 +
apps/login/src/components/username-form.tsx | 5 +-
.../src/components/verify-email-form.tsx | 3 +
apps/login/src/lib/server/register.ts | 37 ++--
22 files changed, 222 insertions(+), 108 deletions(-)
diff --git a/acceptance/tests/login.ts b/acceptance/tests/login.ts
index 3ebcade5..20d27e69 100644
--- a/acceptance/tests/login.ts
+++ b/acceptance/tests/login.ts
@@ -2,18 +2,23 @@ import {Page} from "@playwright/test";
export async function loginWithPassword(page: Page, username: string, password: string) {
await page.goto("/loginname");
- const loginname = page.getByLabel("Loginname");
- await loginname.pressSequentially(username);
- await loginname.press("Enter");
- const pw = page.getByLabel("Password");
- await pw.pressSequentially(password);
- await pw.press("Enter");
+ await loginnameScreen(page, username)
+ await page.getByTestId("submit-button").click()
+ await passwordScreen(page, password)
+ await page.getByTestId("submit-button").click()
}
+export async function loginnameScreen(page: Page, username: string) {
+ await page.getByTestId("username-text-input").pressSequentially(username);
+}
+
+export async function passwordScreen(page: Page, password: string) {
+ await page.getByTestId("password-text-input").pressSequentially(password);
+}
export async function loginWithPasskey(page: Page, username: string) {
await page.goto("/loginname");
- const loginname = page.getByLabel("Loginname");
- await loginname.pressSequentially(username);
- await loginname.press("Enter");
+ await loginnameScreen(page, username)
+ await page.getByTestId("submit-button").click()
+ await page.getByTestId("submit-button").click()
}
\ No newline at end of file
diff --git a/acceptance/tests/password.ts b/acceptance/tests/password.ts
index 360ff65e..42d7190a 100644
--- a/acceptance/tests/password.ts
+++ b/acceptance/tests/password.ts
@@ -2,11 +2,11 @@ import {Page} from "@playwright/test";
export async function changePassword(page: Page, loginname: string, password: string) {
await page.goto('password/change?' + new URLSearchParams({loginName: loginname}));
- await changePasswordScreen(page, loginname, password, password)
- await page.getByRole('button', {name: 'Continue'}).click();
+ await changePasswordScreen(page, password, password)
+ await page.getByTestId("submit-button").click();
}
-async function changePasswordScreen(page: Page, loginname: string, password1: string, password2: string) {
- await page.getByLabel('New Password *').pressSequentially(password1);
- await page.getByLabel('Confirm Password *').pressSequentially(password2);
+async function changePasswordScreen(page: Page, password1: string, password2: string) {
+ await page.getByTestId('password-text-input').pressSequentially(password1);
+ await page.getByTestId('password-confirm-text-input').pressSequentially(password2);
}
\ No newline at end of file
diff --git a/acceptance/tests/register.spec.ts b/acceptance/tests/register.spec.ts
index 932bf313..fe6cef15 100644
--- a/acceptance/tests/register.spec.ts
+++ b/acceptance/tests/register.spec.ts
@@ -3,11 +3,8 @@ import {registerWithPassword} from './register';
import {loginWithPassword} from "./login";
test("register with password", async ({page}) => {
- const firstname = "firstname"
- const lastname = "lastname"
const username = "register@example.com"
const password = "Password1!"
- await registerWithPassword(page, firstname, lastname, username, password, password)
- await page.getByRole("heading", {name: "Welcome " + lastname + " " + lastname + "!"}).click();
+ await registerWithPassword(page, "firstname", "lastname", username, password, password)
await loginWithPassword(page, username, password)
});
diff --git a/acceptance/tests/register.ts b/acceptance/tests/register.ts
index bb403f69..9700e9b2 100644
--- a/acceptance/tests/register.ts
+++ b/acceptance/tests/register.ts
@@ -2,30 +2,38 @@ import {Page} from "@playwright/test";
export async function registerWithPassword(page: Page, firstname: string, lastname: string, email: string, password1: string, password2: string) {
await page.goto('/register');
+ await registerUserScreenPassword(page, firstname, lastname, email)
+ await page.getByTestId('submit-button').click();
+ await registerPasswordScreen(page, password1, password2)
+ await page.getByTestId('submit-button').click();
+}
+
+async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email)
await page.getByLabel('Password').click();
- await page.getByRole('button', {name: 'Continue'}).click();
- await registerPasswordScreen(page, password1, password2)
- await page.getByRole('button', {name: 'Continue'}).click();
+}
+
+async function registerPasswordScreen(page: Page, password1: string, password2: string) {
+ await page.getByTestId('password-text-input').fill(password1);
+ await page.getByTestId('password-confirm-text-input').fill(password2);
}
export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string) {
await page.goto('/register');
+ await registerUserScreenPasskey(page, firstname, lastname, email)
+ await page.getByTestId('submit-button').click();
+ await page.getByTestId('submit-button').click();
+}
+
+async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email)
await page.getByLabel('Passkey').click();
- await page.getByRole('button', {name: 'Continue'}).click();
- await page.getByRole('button', {name: 'Continue'}).click();
}
async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) {
- await page.getByLabel('First name *').pressSequentially(firstname);
- await page.getByLabel('Last name *').pressSequentially(lastname);
- await page.getByLabel('E-mail *').pressSequentially(email);
- await page.getByRole('checkbox').first().check();
- await page.getByRole('checkbox').nth(1).check();
-}
-
-async function registerPasswordScreen(page: Page, password1: string, password2: string) {
- await page.getByLabel('Password *', {exact: true}).fill(password1);
- await page.getByLabel('Confirm Password *').fill(password2);
+ await page.getByTestId('firstname-text-input').pressSequentially(firstname);
+ await page.getByTestId('lastname-text-input').pressSequentially(lastname);
+ await page.getByTestId('email-text-input').pressSequentially(email);
+ await page.getByTestId('privacy-policy-checkbox').check();
+ await page.getByTestId('tos-checkbox').check();
}
\ No newline at end of file
diff --git a/acceptance/tests/user.ts b/acceptance/tests/user.ts
index 18c2ec55..4e0b6b17 100644
--- a/acceptance/tests/user.ts
+++ b/acceptance/tests/user.ts
@@ -1,4 +1,4 @@
-import fetch from 'node-fetch';
+import fetch from "node-fetch";
import {Page} from "@playwright/test";
import {registerWithPasskey} from "./register";
import {loginWithPasskey, loginWithPassword} from "./login";
@@ -20,7 +20,7 @@ class User {
this.props = userProps;
}
- async ensure() {
+ async ensure(page: Page) {
await this.remove()
const body = {
@@ -72,6 +72,10 @@ class User {
return
}
+ public setUserId(userId: string) {
+ this.user = userId
+ }
+
public userId() {
return this.user;
}
@@ -84,6 +88,14 @@ class User {
return this.props.password;
}
+ public firstname() {
+ return this.props.firstName
+ }
+
+ public lastname() {
+ return this.props.lastName
+ }
+
public fullName() {
return this.props.firstName + " " + this.props.lastName
}
@@ -102,74 +114,132 @@ class User {
export class PasswordUser extends User {
}
-export interface passkeyUserProps {
+enum OtpType {
+ time = "time-based",
+ sms = "sms",
+ email = "email",
+}
+
+export interface otpUserProps {
email: string;
firstName: string;
lastName: string;
organization: string;
+ type: OtpType,
}
-export class PasskeyUser {
- private props: passkeyUserProps
-
- constructor(props: passkeyUserProps) {
- this.props = props
+export class PasswordUserWithOTP extends User {
+ private type: OtpType
+ private code: string
+
+ constructor(props: otpUserProps) {
+ super({
+ email: props.email,
+ firstName: props.firstName,
+ lastName: props.lastName,
+ organization: props.organization,
+ password: ""
+ })
+ this.type = props.type
}
- async ensurePasskey(page: Page) {
- await registerWithPasskey(page, this.props.firstName, this.props.lastName, this.props.email)
- }
+ async ensure(page: Page) {
+ await super.ensure(page)
- public async login(page: Page) {
- await loginWithPasskey(page, this.props.email)
- }
-
- public fullName() {
- return this.props.firstName + " " + this.props.lastName
- }
-
- async ensurePasskeyRegister() {
- const url = new URL(process.env.ZITADEL_API_URL!)
- const registerBody = {
- domain: url.hostname,
+ const body = {
+ username: this.props.email,
+ organization: {
+ orgId: this.props.organization
+ },
+ profile: {
+ givenName: this.props.firstName,
+ familyName: this.props.lastName,
+ },
+ email: {
+ email: this.props.email,
+ isVerified: true,
+ },
+ password: {
+ password: this.props.password!,
+ }
}
- const userId = ""
- const registerResponse = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/" + userId + "/passkeys", {
+
+ const response = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/human", {
method: 'POST',
- body: JSON.stringify(registerBody),
+ body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Authorization': "Bearer " + process.env.ZITADEL_SERVICE_USER_TOKEN!
}
});
- if (registerResponse.statusCode >= 400 && registerResponse.statusCode != 409) {
- const error = 'HTTP Error: ' + registerResponse.statusCode + ' - ' + registerResponse.statusMessage;
+ if (response.statusCode >= 400 && response.statusCode != 409) {
+ const error = 'HTTP Error: ' + response.statusCode + ' - ' + response.statusMessage;
console.error(error);
throw new Error(error);
}
- const respJson = await registerResponse.json()
- return respJson
+ return
+ }
+}
+
+export interface passkeyUserProps {
+ email: string;
+ firstName: string;
+ lastName: string;
+ organization: string;
+}
+
+export class PasskeyUser extends User {
+ constructor(props: passkeyUserProps) {
+ super({
+ email: props.email,
+ firstName: props.firstName,
+ lastName: props.lastName,
+ organization: props.organization,
+ password: ""
+ })
}
- async ensurePasskeyVerify(passkeyId: string, credential: Credential) {
- const verifyBody = {
- publicKeyCredential: credential,
- passkeyName: "passkey",
+ public async ensure(page: Page) {
+ await this.remove()
+ await registerWithPasskey(page, this.firstname(), this.lastname(), this.username())
+ }
+
+ public async login(page: Page) {
+ await loginWithPasskey(page, this.username())
+ }
+
+ public async remove() {
+ const resp = await getUserByUsername(this.username())
+ if (!resp || !resp.result || !resp.result[0]) {
+ return
}
- const userId = ""
- const verifyResponse = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/" + userId + "/passkeys/" + passkeyId, {
- method: 'POST',
- body: JSON.stringify(verifyBody),
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': "Bearer " + process.env.ZITADEL_SERVICE_USER_TOKEN!
+ this.setUserId(resp.result[0].userId)
+ await super.remove()
+ }
+}
+
+async function getUserByUsername(username: string) {
+ const listUsersBody = {
+ queries: [{
+ userNameQuery: {
+ userName: username,
}
- });
- if (verifyResponse.statusCode >= 400 && verifyResponse.statusCode != 409) {
- const error = 'HTTP Error: ' + verifyResponse.statusCode + ' - ' + verifyResponse.statusMessage;
- console.error(error);
- throw new Error(error);
+ }]
+ }
+ const jsonBody = JSON.stringify(listUsersBody)
+ const registerResponse = await fetch(process.env.ZITADEL_API_URL! + "/v2/users", {
+ method: 'POST',
+ body: jsonBody,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': "Bearer " + process.env.ZITADEL_SERVICE_USER_TOKEN!
}
- return
+ });
+ if (registerResponse.statusCode >= 400) {
+ const error = 'HTTP Error: ' + registerResponse.statusCode + ' - ' + registerResponse.statusMessage;
+ console.error(error);
+ throw new Error(error);
}
+ const respJson = await registerResponse.json()
+ return respJson
}
\ No newline at end of file
diff --git a/acceptance/tests/username-passkey.spec.ts b/acceptance/tests/username-passkey.spec.ts
index 0cc98851..405a7590 100644
--- a/acceptance/tests/username-passkey.spec.ts
+++ b/acceptance/tests/username-passkey.spec.ts
@@ -1,5 +1,7 @@
+import {test as base} from "@playwright/test";
import path from 'path';
import dotenv from 'dotenv';
+import {PasskeyUser} from "./user";
// Read from ".env" file.
dotenv.config({path: path.resolve(__dirname, '.env.local')});
@@ -86,7 +88,7 @@ const test = base.extend<{ user: PasskeyUser }>({
enabled: false,
});
},
-});
+});*/
const test = base.extend<{ user: PasskeyUser }>({
user: async ({page}, use) => {
@@ -96,7 +98,7 @@ const test = base.extend<{ user: PasskeyUser }>({
lastName: "last",
organization: "",
});
- await user.ensurePasskey(page);
+ await user.ensure(page);
await use(user)
},
});
@@ -105,4 +107,3 @@ test("username and passkey login", async ({user, page}) => {
await user.login(page)
await page.getByRole("heading", {name: "Welcome " + user.fullName() + "!"}).click();
});
-*/
\ No newline at end of file
diff --git a/acceptance/tests/username-password-changed.spec.ts b/acceptance/tests/username-password-changed.spec.ts
index a3e53436..e9ade80b 100644
--- a/acceptance/tests/username-password-changed.spec.ts
+++ b/acceptance/tests/username-password-changed.spec.ts
@@ -15,7 +15,7 @@ const test = base.extend<{ user: PasswordUser }>({
password: "Password1!",
organization: "",
});
- await user.ensure();
+ await user.ensure(page);
await use(user);
},
});
diff --git a/acceptance/tests/username-password.spec.ts b/acceptance/tests/username-password.spec.ts
index f0bb8bdd..e8403823 100644
--- a/acceptance/tests/username-password.spec.ts
+++ b/acceptance/tests/username-password.spec.ts
@@ -15,7 +15,7 @@ const test = base.extend<{ user: PasswordUser }>({
password: "Password1!",
organization: "",
});
- await user.ensure();
+ await user.ensure(page);
await use(user);
},
});
diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx
index ccdcd792..c025bfc6 100644
--- a/apps/login/src/components/change-password-form.tsx
+++ b/apps/login/src/components/change-password-form.tsx
@@ -140,6 +140,7 @@ export function ChangePasswordForm({
})}
label="New Password"
error={errors.password?.message as string}
+ data-testid="password-text-input"
/>
@@ -152,6 +153,7 @@ export function ChangePasswordForm({
})}
label="Confirm Password"
error={errors.confirmPassword?.message as string}
+ data-testid="password-confirm-text-input"
/>
@@ -167,7 +169,7 @@ export function ChangePasswordForm({
{error && {error}}
-
+