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}}
- + @@ -263,6 +264,7 @@ export function LoginPasskey({ setLoading(false); }); }} + data-testid="submit-button" > {loading && } {t("verify.submit")} diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index 98b0ec85..d2ec163c 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -120,6 +120,7 @@ export function PasswordForm({ autoComplete="password" {...register("password", { required: "This field is required" })} label="Password" + data-testid="password-text-input" /> {!loginSettings?.hidePasswordReset && (
@@ -161,6 +162,7 @@ export function SetRegisterPasswordForm({ })} label="Confirm Password" error={errors.confirmPassword?.message as string} + data-testid="password-confirm-text-input" />
@@ -187,6 +189,7 @@ export function SetRegisterPasswordForm({ watchPassword !== watchConfirmPassword } onClick={handleSubmit(submitRegister)} + data-testid="submit-button" > {loading && } {t("password.submit")} diff --git a/apps/login/src/components/totp-register.tsx b/apps/login/src/components/totp-register.tsx index 450cb5bf..52991d0d 100644 --- a/apps/login/src/components/totp-register.tsx +++ b/apps/login/src/components/totp-register.tsx @@ -122,6 +122,7 @@ export function TotpRegister({ type="text" {...register("code", { required: "This field is required" })} label="Code" + data-testid="code-text-input" /> @@ -139,6 +140,7 @@ export function TotpRegister({ variant={ButtonVariants.Primary} disabled={loading || !formState.isValid} onClick={handleSubmit(continueWithCode)} + data-testid="submit-button" > {loading && } {t("set.submit")} diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index 80780a8e..f0766d77 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -80,6 +80,7 @@ export function UsernameForm({ autoComplete="username" {...register("loginName", { required: "This field is required" })} label="Loginname" + data-testid="username-text-input" /> {allowRegister && ( @@ -112,9 +114,10 @@ export function UsernameForm({
{children}
- +
@@ -125,6 +126,7 @@ export function VerifyEmailForm({ type="button" onClick={() => resendCode()} variant={ButtonVariants.Secondary} + data-testid="resend-button" > {t("resendCode")} @@ -135,6 +137,7 @@ export function VerifyEmailForm({ variant={ButtonVariants.Primary} disabled={loading || !formState.isValid} onClick={handleSubmit(submitCodeAndContinue)} + data-testid="submit-button" > {loading && } {t("submit")} diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 052f4dbd..3ff47cd3 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -34,20 +34,25 @@ export async function registerUser(command: RegisterUserCommand) { return { error: "Could not create user" }; } - const checks = create(ChecksSchema, { - user: { search: { case: "userId", value: human.userId } }, - password: { password: command.password }, - }); - - return createSessionAndUpdateCookie( - checks, - undefined, - command.authRequestId, - ).then((session) => { - return { - userId: human.userId, - sessionId: session.id, - factors: session.factors, - }; - }); + let checks = create(ChecksSchema, { + user: {search: {case: "userId", value: human.userId}}, + }); + if (command.password) { + checks = create(ChecksSchema, { + user: {search: {case: "userId", value: human.userId}}, + password: {password: command.password} + }); + } + console.log(checks) + return createSessionAndUpdateCookie( + checks, + undefined, + command.authRequestId, + ).then((session) => { + return { + userId: human.userId, + sessionId: session.id, + factors: session.factors, + }; + }); } From be4a20b29b5879cb69861cb73176ee8b1b81f567 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 31 Oct 2024 16:53:23 +0100 Subject: [PATCH 03/19] chore: passkey register --- acceptance/tests/admin.spec.ts | 4 +- acceptance/tests/login.ts | 19 ++-- acceptance/tests/loginname.ts | 5 ++ acceptance/tests/passkey.ts | 62 +++++++++++++ acceptance/tests/password.ts | 11 ++- acceptance/tests/register.spec.ts | 28 +++++- acceptance/tests/register.ts | 13 ++- acceptance/tests/user.ts | 39 +-------- acceptance/tests/username-passkey.spec.ts | 87 +------------------ .../tests/username-password-changed.spec.ts | 2 + acceptance/tests/username-password.spec.ts | 3 +- acceptance/tests/zitadel.ts | 50 +++++++++++ .../authentication-method-radio.tsx | 1 + .../components/set-register-password-form.tsx | 2 +- apps/login/src/lib/server/password.ts | 2 - apps/login/src/lib/server/register.ts | 1 - playwright.config.ts | 2 +- 17 files changed, 178 insertions(+), 153 deletions(-) create mode 100644 acceptance/tests/loginname.ts create mode 100644 acceptance/tests/passkey.ts create mode 100644 acceptance/tests/zitadel.ts diff --git a/acceptance/tests/admin.spec.ts b/acceptance/tests/admin.spec.ts index e08d7bf8..929e2a5b 100644 --- a/acceptance/tests/admin.spec.ts +++ b/acceptance/tests/admin.spec.ts @@ -1,7 +1,7 @@ import {test} from "@playwright/test"; -import {loginWithPassword} from "./login"; +import {checkLogin, 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(); + await checkLogin(page, "ZITADEL Admin"); }); diff --git a/acceptance/tests/login.ts b/acceptance/tests/login.ts index 20d27e69..58d8572f 100644 --- a/acceptance/tests/login.ts +++ b/acceptance/tests/login.ts @@ -1,4 +1,7 @@ -import {Page} from "@playwright/test"; +import {expect, Page} from "@playwright/test"; +import {loginnameScreen} from "./loginname"; +import {passwordScreen} from "./password"; +import {passkeyScreen} from "./passkey"; export async function loginWithPassword(page: Page, username: string, password: string) { await page.goto("/loginname"); @@ -8,17 +11,13 @@ export async function loginWithPassword(page: Page, username: string, 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"); await loginnameScreen(page, username) await page.getByTestId("submit-button").click() - await page.getByTestId("submit-button").click() + await passkeyScreen(page) +} + +export async function checkLogin(page: Page, fullName: string) { + await expect(page.getByRole('heading')).toContainText(fullName); } \ No newline at end of file diff --git a/acceptance/tests/loginname.ts b/acceptance/tests/loginname.ts new file mode 100644 index 00000000..eb490fbb --- /dev/null +++ b/acceptance/tests/loginname.ts @@ -0,0 +1,5 @@ +import {Page} from "@playwright/test"; + +export async function loginnameScreen(page: Page, username: string) { + await page.getByTestId("username-text-input").pressSequentially(username); +} diff --git a/acceptance/tests/passkey.ts b/acceptance/tests/passkey.ts new file mode 100644 index 00000000..92957cdd --- /dev/null +++ b/acceptance/tests/passkey.ts @@ -0,0 +1,62 @@ +import {Page} from "@playwright/test"; + +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=="; + +export async function passkeyScreen(page: Page) { + const client = await page.context().newCDPSession(page); + await client.send('WebAuthn.enable', {enableUI: true}); + const result = await client.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + const authenticatorId = result.authenticatorId; + + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise(resolve => { + client.on('WebAuthn.credentialAdded', () => resolve()); + client.on('WebAuthn.credentialAsserted', () => resolve()); + }); + + 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 + }); + + + // triggers passkey check + await page.getByTestId("submit-button").click() + + // wait for successfully verified + await operationCompleted; +} \ No newline at end of file diff --git a/acceptance/tests/password.ts b/acceptance/tests/password.ts index 42d7190a..7d2286d8 100644 --- a/acceptance/tests/password.ts +++ b/acceptance/tests/password.ts @@ -9,4 +9,13 @@ export async function changePassword(page: Page, loginname: string, password: st 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 +} + +export async function passwordScreen(page: Page, password: string) { + await page.getByTestId("password-text-input").pressSequentially(password); +} + +export async function registerPasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId('password-text-input').pressSequentially(password1); + await page.getByTestId('password-confirm-text-input').pressSequentially(password2); +} diff --git a/acceptance/tests/register.spec.ts b/acceptance/tests/register.spec.ts index fe6cef15..c482dd68 100644 --- a/acceptance/tests/register.spec.ts +++ b/acceptance/tests/register.spec.ts @@ -1,10 +1,30 @@ import {test} from "@playwright/test"; -import {registerWithPassword} from './register'; -import {loginWithPassword} from "./login"; +import {registerWithPasskey, registerWithPassword} from './register'; +import {checkLogin} from "./login"; +import {removeUserByUsername} from './zitadel'; +import path from 'path'; +import dotenv from 'dotenv'; + +// Read from ".env" file. +dotenv.config({path: path.resolve(__dirname, '.env.local')}); test("register with password", async ({page}) => { const username = "register@example.com" const password = "Password1!" - await registerWithPassword(page, "firstname", "lastname", username, password, password) - await loginWithPassword(page, username, password) + const firstname = "firstname" + const lastname = "lastname" + + await removeUserByUsername(username) + await registerWithPassword(page, firstname, lastname, username, password, password) + await checkLogin(page, firstname + " " + lastname); +}); + +test("register with passkey", async ({page}) => { + const username = "register@example.com" + const firstname = "firstname" + const lastname = "lastname" + + await removeUserByUsername(username) + await registerWithPasskey(page, firstname, lastname, username) + await checkLogin(page, firstname + " " + lastname); }); diff --git a/acceptance/tests/register.ts b/acceptance/tests/register.ts index 9700e9b2..e045588f 100644 --- a/acceptance/tests/register.ts +++ b/acceptance/tests/register.ts @@ -1,4 +1,6 @@ import {Page} from "@playwright/test"; +import {passkeyScreen} from './passkey'; +import {registerPasswordScreen} from './password'; export async function registerWithPassword(page: Page, firstname: string, lastname: string, email: string, password1: string, password2: string) { await page.goto('/register'); @@ -10,24 +12,19 @@ export async function registerWithPassword(page: Page, firstname: string, lastna async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) { await registerUserScreen(page, firstname, lastname, email) - await page.getByLabel('Password').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); + await page.getByTestId('Password-radio').click(); } 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(); + await passkeyScreen(page) } async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) { await registerUserScreen(page, firstname, lastname, email) - await page.getByLabel('Passkey').click(); + await page.getByTestId('Passkeys-radio').click(); } async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) { diff --git a/acceptance/tests/user.ts b/acceptance/tests/user.ts index 4e0b6b17..a2a18787 100644 --- a/acceptance/tests/user.ts +++ b/acceptance/tests/user.ts @@ -3,6 +3,7 @@ import {Page} from "@playwright/test"; import {registerWithPasskey} from "./register"; import {loginWithPasskey, loginWithPassword} from "./login"; import {changePassword} from "./password"; +import {removeUser, getUserByUsername} from './zitadel'; export interface userProps { email: string; @@ -58,17 +59,7 @@ class User { } 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); - } + await removeUser(this.userId()) return } @@ -217,29 +208,3 @@ export class PasskeyUser extends User { await super.remove() } } - -async function getUserByUsername(username: string) { - const listUsersBody = { - queries: [{ - userNameQuery: { - userName: username, - } - }] - } - 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! - } - }); - 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 405a7590..bf56f61d 100644 --- a/acceptance/tests/username-passkey.spec.ts +++ b/acceptance/tests/username-passkey.spec.ts @@ -2,94 +2,11 @@ import {test as base} from "@playwright/test"; import path from 'path'; import dotenv from 'dotenv'; import {PasskeyUser} from "./user"; +import {checkLogin} from "./login"; // 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({ @@ -105,5 +22,5 @@ const test = base.extend<{ user: PasskeyUser }>({ test("username and passkey login", async ({user, page}) => { await user.login(page) - await page.getByRole("heading", {name: "Welcome " + user.fullName() + "!"}).click(); + await checkLogin(page, user.fullName()); }); diff --git a/acceptance/tests/username-password-changed.spec.ts b/acceptance/tests/username-password-changed.spec.ts index e9ade80b..0607ad15 100644 --- a/acceptance/tests/username-password-changed.spec.ts +++ b/acceptance/tests/username-password-changed.spec.ts @@ -2,6 +2,7 @@ import {test as base} from "@playwright/test"; import {PasswordUser} from './user'; import path from 'path'; import dotenv from 'dotenv'; +import {checkLogin} from "./login"; // Read from ".env" file. dotenv.config({path: path.resolve(__dirname, '.env.local')}); @@ -23,4 +24,5 @@ const test = base.extend<{ user: PasswordUser }>({ test("username and password changed login", async ({user, page}) => { await user.changePassword(page, "ChangedPw1!") await user.login(page) + await checkLogin(page, user.fullName()); }); diff --git a/acceptance/tests/username-password.spec.ts b/acceptance/tests/username-password.spec.ts index e8403823..e24e4b50 100644 --- a/acceptance/tests/username-password.spec.ts +++ b/acceptance/tests/username-password.spec.ts @@ -2,6 +2,7 @@ import {test as base} from "@playwright/test"; import {PasswordUser} from './user'; import path from 'path'; import dotenv from 'dotenv'; +import {checkLogin} from "./login"; // Read from ".env" file. dotenv.config({path: path.resolve(__dirname, '.env.local')}); @@ -22,7 +23,7 @@ const test = base.extend<{ user: PasswordUser }>({ test("username and password login", async ({user, page}) => { await user.login(page) - await page.getByRole("heading", {name: "Welcome " + user.fullName() + "!"}).click(); + await checkLogin(page, user.fullName()); }); diff --git a/acceptance/tests/zitadel.ts b/acceptance/tests/zitadel.ts new file mode 100644 index 00000000..8a1935ad --- /dev/null +++ b/acceptance/tests/zitadel.ts @@ -0,0 +1,50 @@ +import fetch from "node-fetch"; + +export async function removeUserByUsername(username: string) { + const resp = await getUserByUsername(username) + if (!resp || !resp.result || !resp.result[0]) { + return + } + await removeUser(resp.result[0].userId) +} + +export async function removeUser(id: string) { + const response = await fetch(process.env.ZITADEL_API_URL! + "/v2/users/" + id, { + 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 +} + +export async function getUserByUsername(username: string) { + const listUsersBody = { + queries: [{ + userNameQuery: { + userName: username, + } + }] + } + 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! + } + }); + 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/apps/login/src/components/authentication-method-radio.tsx b/apps/login/src/components/authentication-method-radio.tsx index d5c52068..b01f8efd 100644 --- a/apps/login/src/components/authentication-method-radio.tsx +++ b/apps/login/src/components/authentication-method-radio.tsx @@ -30,6 +30,7 @@ export function AuthenticationMethodRadio({ `${ active diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index 91cc698a..551429c9 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -178,7 +178,7 @@ export function SetRegisterPasswordForm({ {error && {error}}
- +