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] 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 | 36 +++- apps/login/src/lib/server/passkeys.ts | 3 +- apps/login/src/lib/server/session.ts | 3 +- playwright.config.ts | 126 ++++++------- 12 files changed, 484 insertions(+), 75 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 00000000000..e08d7bf82bf --- /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 00000000000..3ebcade5f78 --- /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 00000000000..360ff65e17a --- /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 00000000000..932bf3134c2 --- /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 00000000000..bb403f6999c --- /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 00000000000..18c2ec55be4 --- /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 00000000000..0cc98851d9d --- /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 00000000000..a3e5343672c --- /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 31d165358e0..f0bb8bddb3d 100644 --- a/acceptance/tests/username-password.spec.ts +++ b/acceptance/tests/username-password.spec.ts @@ -1,12 +1,28 @@ -import { test } from "@playwright/test"; +import {test as base} from "@playwright/test"; +import {PasswordUser} from './user'; +import path from 'path'; +import dotenv from 'dotenv'; -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(); +// 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 5ad170f8d3f..d7a81e79521 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 3bd97629162..b1beb6549e7 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 bf73cb21d13..f3a9658f82c 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"] }, + /* 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 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, }, - /* 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 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, - }, });