Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: acceptance test suite #256

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ on: pull_request

jobs:
quality:
env:
ZITADEL_IMAGE: ghcr.io/zitadel/zitadel:v2.65.0
POSTGRES_IMAGE: postgres:17.0-alpine3.19

name: Ensure Quality

runs-on: ubuntu-latest
Expand Down
6 changes: 4 additions & 2 deletions acceptance/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
services:
zitadel:
user: "${ZITADEL_DEV_UID}"
image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}"
image: ghcr.io/zitadel/zitadel:v2.65.0
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
ports:
- "8080:8080"
Expand All @@ -14,7 +14,7 @@ services:

db:
restart: "always"
image: "${POSTGRES_IMAGE:-postgres:latest}"
image: postgres:17.0-alpine3.19
environment:
- POSTGRES_USER=zitadel
- PGUSER=zitadel
Expand Down Expand Up @@ -44,9 +44,11 @@ services:
PAT_FILE: /pat/zitadel-admin-sa.pat
ZITADEL_API_INTERNAL_URL: http://zitadel:8080
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local
volumes:
- "./pat:/pat"
- "../apps/login:/apps/login"
- "../acceptance/tests:/acceptance/tests"
depends_on:
wait_for_zitadel:
condition: "service_completed_successfully"
8 changes: 6 additions & 2 deletions acceptance/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,19 @@ fi

WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local}
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
WRITE_TEST_ENVIRONMENT_FILE=${WRITE_TEST_ENVIRONMENT_FILE:-$(dirname "$0")/../acceptance/tests/.env.local}
echo "Writing environment file to ${WRITE_TEST_ENVIRONMENT_FILE} when done."

echo "ZITADEL_API_URL=${ZITADEL_API_URL}
ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID}
ZITADEL_SERVICE_USER_TOKEN=${PAT}
DEBUG=true" > ${WRITE_ENVIRONMENT_FILE}

DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null
echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}"
cat ${WRITE_ENVIRONMENT_FILE}

echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}"
cat ${WRITE_TEST_ENVIRONMENT_FILE}

DEFAULTORG_RESPONSE_RESULTS=0
# waiting for default organization
until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ]
Expand Down
7 changes: 7 additions & 0 deletions acceptance/tests/admin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { test } from "@playwright/test";
import { loginScreenExpect, loginWithPassword } from "./login";

test("admin login", async ({ page }) => {
await loginWithPassword(page, "[email protected]", "Password1!");
await loginScreenExpect(page, "ZITADEL Admin");
});
28 changes: 28 additions & 0 deletions acceptance/tests/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, Page } from "@playwright/test";
import { loginname } from "./loginname";
import { password } from "./password";

export async function startLogin(page: Page) {
await page.goto("/loginname");
}

export async function loginWithPassword(page: Page, username: string, pw: string) {
await startLogin(page);
await loginname(page, username);
await password(page, pw);
}

export async function loginWithPasskey(page: Page, authenticatorId: string, username: string) {
await startLogin(page);
await loginname(page, username);
// await passkey(page, authenticatorId);
}

export async function loginScreenExpect(page: Page, fullName: string) {
await expect(page).toHaveURL(/signedin.*/);
await expect(page.getByRole("heading")).toContainText(fullName);
}

export async function loginWithOTP(page: Page, username: string, password: string) {
await loginWithPassword(page, username, password);
}
12 changes: 12 additions & 0 deletions acceptance/tests/loginname-screen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, Page } from "@playwright/test";

const usernameUserInput = "username-text-input";

export async function loginnameScreen(page: Page, username: string) {
await page.getByTestId(usernameUserInput).pressSequentially(username);
}

export async function loginnameScreenExpect(page: Page, username: string) {
await expect(page.getByTestId(usernameUserInput)).toHaveValue(username);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not find user");
}
7 changes: 7 additions & 0 deletions acceptance/tests/loginname.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Page } from "@playwright/test";
import { loginnameScreen } from "./loginname-screen";

export async function loginname(page: Page, username: string) {
await loginnameScreen(page, username);
await page.getByTestId("submit-button").click();
}
31 changes: 31 additions & 0 deletions acceptance/tests/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as http from "node:http";

let messages = new Map<string, any>();

export function startSink() {
const hostname = "127.0.0.1";
const port = 3030;

const server = http.createServer((req, res) => {
console.log("Sink received message: ");
let body = "";
req.on("data", (chunk) => {
body += chunk;
});

req.on("end", () => {
console.log(body);
const data = JSON.parse(body);
messages.set(data.contextInfo.recipientEmailAddress, data.args.code);
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.write("OK");
res.end();
});
});

server.listen(port, hostname, () => {
console.log(`Sink running at http://${hostname}:${port}/`);
});
return server;
}
109 changes: 109 additions & 0 deletions acceptance/tests/passkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { expect, Page } from "@playwright/test";
import { CDPSession } from "playwright-core";

interface session {
client: CDPSession;
authenticatorId: string;
}

async function client(page: Page): Promise<session> {
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send("WebAuthn.enable", { enableUI: false });
const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
return { client: cdpSession, authenticatorId: result.authenticatorId };
}

export async function passkeyRegister(page: Page): Promise<string> {
const session = await client(page);

await passkeyNotExisting(session.client, session.authenticatorId);
await simulateSuccessfulPasskeyRegister(session.client, session.authenticatorId, () =>
page.getByTestId("submit-button").click(),
);
await passkeyRegistered(session.client, session.authenticatorId);

return session.authenticatorId;
}

export async function passkey(page: Page, authenticatorId: string) {
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send("WebAuthn.enable", { enableUI: false });

const signCount = await passkeyExisting(cdpSession, authenticatorId);

await simulateSuccessfulPasskeyInput(cdpSession, authenticatorId, () => page.getByTestId("submit-button").click());

await passkeyUsed(cdpSession, authenticatorId, signCount);
}

async function passkeyNotExisting(client: CDPSession, authenticatorId: string) {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(0);
}

async function passkeyRegistered(client: CDPSession, authenticatorId: string) {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(1);
await passkeyUsed(client, authenticatorId, 0);
}

async function passkeyExisting(client: CDPSession, authenticatorId: string): Promise<number> {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(1);
return result.credentials[0].signCount;
}

async function passkeyUsed(client: CDPSession, authenticatorId: string, signCount: number) {
const result = await client.send("WebAuthn.getCredentials", { authenticatorId });
expect(result.credentials).toHaveLength(1);
expect(result.credentials[0].signCount).toBeGreaterThan(signCount);
}

async function simulateSuccessfulPasskeyRegister(
client: CDPSession,
authenticatorId: string,
operationTrigger: () => Promise<void>,
) {
// initialize event listeners to wait for a successful passkey input event
const operationCompleted = new Promise<void>((resolve) => {
client.on("WebAuthn.credentialAdded", () => {
console.log("Credential Added!");
resolve();
});
});

// perform a user action that triggers passkey prompt
await operationTrigger();

// wait to receive the event that the passkey was successfully registered or verified
await operationCompleted;
}

async function simulateSuccessfulPasskeyInput(
client: CDPSession,
authenticatorId: string,
operationTrigger: () => Promise<void>,
) {
// initialize event listeners to wait for a successful passkey input event
const operationCompleted = new Promise<void>((resolve) => {
client.on("WebAuthn.credentialAsserted", () => {
console.log("Credential Asserted!");
resolve();
});
});

// perform a user action that triggers passkey prompt
await operationTrigger();

// wait to receive the event that the passkey was successfully registered or verified
await operationCompleted;
}
57 changes: 57 additions & 0 deletions acceptance/tests/password-screen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { expect, Page } from "@playwright/test";

const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input";
const lengthCheck = "length-check";
const symbolCheck = "symbol-check";
const numberCheck = "number-check";
const uppercaseCheck = "uppercase-check";
const lowercaseCheck = "lowercase-check";
const equalCheck = "equal-check";

const matchText = "Matches";
const noMatchText = "Doesn't match";

export async function changePasswordScreen(page: Page, password1: string, password2: string) {
await page.getByTestId(passwordField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2);
}

export async function passwordScreen(page: Page, password: string) {
await page.getByTestId(passwordField).pressSequentially(password);
}

export async function passwordScreenExpect(page: Page, password: string) {
await expect(page.getByTestId(passwordField)).toHaveValue(password);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify password");
}

export async function changePasswordScreenExpect(
page: Page,
password1: string,
password2: string,
length: boolean,
symbol: boolean,
number: boolean,
uppercase: boolean,
lowercase: boolean,
equals: boolean,
) {
await expect(page.getByTestId(passwordField)).toHaveValue(password1);
await expect(page.getByTestId(passwordConfirmField)).toHaveValue(password2);

await checkContent(page, lengthCheck, length);
await checkContent(page, symbolCheck, symbol);
await checkContent(page, numberCheck, number);
await checkContent(page, uppercaseCheck, uppercase);
await checkContent(page, lowercaseCheck, lowercase);
await checkContent(page, equalCheck, equals);
}

async function checkContent(page: Page, testid: string, match: boolean) {
if (match) {
await expect(page.getByTestId(testid)).toContainText(matchText);
} else {
await expect(page.getByTestId(testid)).toContainText(noMatchText);
}
}
19 changes: 19 additions & 0 deletions acceptance/tests/password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Page } from "@playwright/test";
import { changePasswordScreen, passwordScreen } from "./password-screen";

const passwordSubmitButton = "submit-button";

export async function startChangePassword(page: Page, loginname: string) {
await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname }));
}

export async function changePassword(page: Page, loginname: string, password: string) {
await startChangePassword(page, loginname);
await changePasswordScreen(page, password, password);
await page.getByTestId(passwordSubmitButton).click();
}

export async function password(page: Page, password: string) {
await passwordScreen(page, password);
await page.getByTestId(passwordSubmitButton).click();
}
27 changes: 27 additions & 0 deletions acceptance/tests/register-screen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Page } from "@playwright/test";

const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input";

export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email);
await page.getByTestId("Password-radio").click();
}

export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email);
await page.getByTestId("Passkeys-radio").click();
}

export async function registerPasswordScreen(page: Page, password1: string, password2: string) {
await page.getByTestId(passwordField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2);
}

export async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) {
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();
}
30 changes: 30 additions & 0 deletions acceptance/tests/register.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect } from "./login";
import { registerWithPasskey, registerWithPassword } from "./register";
import { removeUserByUsername } from "./zitadel";

// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });

test("register with password", async ({ page }) => {
const username = "[email protected]";
const password = "Password1!";
const firstname = "firstname";
const lastname = "lastname";

await removeUserByUsername(username);
await registerWithPassword(page, firstname, lastname, username, password, password);
await loginScreenExpect(page, firstname + " " + lastname);
});

test("register with passkey", async ({ page }) => {
const username = "[email protected]";
const firstname = "firstname";
const lastname = "lastname";

await removeUserByUsername(username);
await registerWithPasskey(page, firstname, lastname, username);
await loginScreenExpect(page, firstname + " " + lastname);
});
Loading
Loading