Merge branch 'main' into user-discovery

This commit is contained in:
Max Peintner
2024-12-06 14:28:09 +01:00
committed by GitHub
30 changed files with 763 additions and 175 deletions

View File

@@ -105,7 +105,7 @@ jobs:
if: ${{ matrix.command == 'test:acceptance' }} if: ${{ matrix.command == 'test:acceptance' }}
- name: Run ZITADEL - name: Run ZITADEL
run: ZITADEL_DEV_UID=root pnpm run-zitadel run: ZITADEL_DEV_UID=root pnpm run-sink
if: ${{ matrix.command == 'test:acceptance' }} if: ${{ matrix.command == 'test:acceptance' }}
- name: Create Cloud Env File - name: Create Cloud Env File

View File

@@ -230,6 +230,24 @@ pnpm test
To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts. To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts.
### Run Login UI Acceptance tests
To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's.
This component should also be able to return the content of these notifications, as the codes and links are used in the login flows.
There is a basic implementation in Golang available under [the sink package](./acceptance/sink).
To setup ZITADEL with the additional Sink container for handling the notifications:
```sh
pnpm run-sink
```
Then you can start the acceptance tests with:
```sh
pnpm test:acceptance
```
### Deploy to Vercel ### Deploy to Vercel
To deploy your own version on Vercel, navigate to your instance and create a service user. To deploy your own version on Vercel, navigate to your instance and create a service user.

View File

@@ -22,7 +22,7 @@ services:
- POSTGRES_HOST_AUTH_METHOD=trust - POSTGRES_HOST_AUTH_METHOD=trust
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready"] test: [ "CMD-SHELL", "pg_isready" ]
interval: "10s" interval: "10s"
timeout: "30s" timeout: "30s"
retries: 5 retries: 5
@@ -45,6 +45,9 @@ services:
ZITADEL_API_INTERNAL_URL: http://zitadel:8080 ZITADEL_API_INTERNAL_URL: http://zitadel:8080
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local
SINK_EMAIL_INTERNAL_URL: http://sink:3333/email
SINK_SMS_INTERNAL_URL: http://sink:3333/sms
SINK_NOTIFICATION_URL: http://localhost:3333/notification
volumes: volumes:
- "./pat:/pat" - "./pat:/pat"
- "../apps/login:/apps/login" - "../apps/login:/apps/login"
@@ -52,3 +55,15 @@ services:
depends_on: depends_on:
wait_for_zitadel: wait_for_zitadel:
condition: "service_completed_successfully" condition: "service_completed_successfully"
sink:
image: golang:1.19-alpine
container_name: sink
command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification'
ports:
- 3333:3333
volumes:
- "./sink:/sink"
depends_on:
setup:
condition: "service_completed_successfully"

View File

@@ -8,6 +8,9 @@ ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}"
ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}" ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}"
ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}" ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}"
ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}" ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}"
SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}"
SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}"
SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}"
if [ -z "${PAT}" ]; then if [ -z "${PAT}" ]; then
echo "Reading PAT from file ${PAT_FILE}" echo "Reading PAT from file ${PAT_FILE}"
@@ -24,6 +27,10 @@ if [ -z "${ZITADEL_SERVICE_USER_ID}" ]; then
ZITADEL_SERVICE_USER_ID=$(echo "${USERINFO_RESPONSE}" | jq --raw-output '.sub') ZITADEL_SERVICE_USER_ID=$(echo "${USERINFO_RESPONSE}" | jq --raw-output '.sub')
fi fi
#################################################################
# Environment files
#################################################################
WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local} WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.local}
echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done." echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done."
WRITE_TEST_ENVIRONMENT_FILE=${WRITE_TEST_ENVIRONMENT_FILE:-$(dirname "$0")/../acceptance/tests/.env.local} WRITE_TEST_ENVIRONMENT_FILE=${WRITE_TEST_ENVIRONMENT_FILE:-$(dirname "$0")/../acceptance/tests/.env.local}
@@ -32,6 +39,7 @@ echo "Writing environment file to ${WRITE_TEST_ENVIRONMENT_FILE} when done."
echo "ZITADEL_API_URL=${ZITADEL_API_URL} echo "ZITADEL_API_URL=${ZITADEL_API_URL}
ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID} ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID}
ZITADEL_SERVICE_USER_TOKEN=${PAT} ZITADEL_SERVICE_USER_TOKEN=${PAT}
SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL}
DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null
echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}"
cat ${WRITE_ENVIRONMENT_FILE} cat ${WRITE_ENVIRONMENT_FILE}
@@ -39,6 +47,54 @@ cat ${WRITE_ENVIRONMENT_FILE}
echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}" echo "Wrote environment file ${WRITE_TEST_ENVIRONMENT_FILE}"
cat ${WRITE_TEST_ENVIRONMENT_FILE} cat ${WRITE_TEST_ENVIRONMENT_FILE}
#################################################################
# SMS provider with HTTP
#################################################################
SMSHTTP_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json" \
-d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}")
echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}"
SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id')
echo "Received SMS HTTP ID: ${SMSHTTP_ID}"
SMS_ACTIVE_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json")
echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}"
#################################################################
# Email provider with HTTP
#################################################################
EMAILHTTP_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json" \
-d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}")
echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}"
EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id')
echo "Received Email HTTP ID: ${EMAILHTTP_ID}"
EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \
--url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \
--header "Authorization: Bearer ${PAT}" \
--header "Host: ${ZITADEL_API_DOMAIN}" \
--header "Content-Type: application/json")
echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}"
#################################################################
# Wait for projection of default organization in ZITADEL
#################################################################
DEFAULTORG_RESPONSE_RESULTS=0 DEFAULTORG_RESPONSE_RESULTS=0
# waiting for default organization # waiting for default organization
until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ] until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ]
@@ -53,3 +109,4 @@ do
DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length') DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length')
echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}" echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}"
done done

3
acceptance/sink/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/zitadel/typescript/acceptance/sink
go 1.22.6

104
acceptance/sink/main.go Normal file
View File

@@ -0,0 +1,104 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
)
type serializableData struct {
ContextInfo map[string]interface{} `json:"contextInfo,omitempty"`
Args map[string]interface{} `json:"args,omitempty"`
}
type response struct {
Recipient string `json:"recipient,omitempty"`
}
func main() {
port := flag.String("port", "3333", "used port for the sink")
email := flag.String("email", "/email", "path for a sent email")
emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification")
sms := flag.String("sms", "/sms", "path for a sent sms")
smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification")
notification := flag.String("notification", "/notification", "path to receive the notification")
flag.Parse()
messages := make(map[string]serializableData)
http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData := serializableData{}
if err := json.Unmarshal(data, &serializableData); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
email, ok := serializableData.ContextInfo[*emailKey].(string)
if !ok {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println(email + ": " + string(data))
messages[email] = serializableData
io.WriteString(w, "Email!\n")
})
http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData := serializableData{}
if err := json.Unmarshal(data, &serializableData); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
phone, ok := serializableData.ContextInfo[*smsKey].(string)
if !ok {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Println(phone + ": " + string(data))
messages[phone] = serializableData
io.WriteString(w, "SMS!\n")
})
http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := response{}
if err := json.Unmarshal(data, &response); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
serializableData, err := json.Marshal(messages[response.Recipient])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.WriteString(w, string(serializableData))
})
fmt.Println("Starting server on", *port)
fmt.Println(*email, " for email handling")
fmt.Println(*sms, " for sms handling")
fmt.Println(*notification, " for retrieving notifications")
err := http.ListenAndServe(":"+*port, nil)
if err != nil {
panic("Server could not be started: " + err.Error())
}
}

View File

@@ -0,0 +1,12 @@
import { expect, Page } from "@playwright/test";
const codeTextInput = "code-text-input";
export async function codeScreen(page: Page, code: string) {
await page.getByTestId(codeTextInput).pressSequentially(code);
}
export async function codeScreenExpect(page: Page, code: string) {
await expect(page.getByTestId(codeTextInput)).toHaveValue(code);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code");
}

19
acceptance/tests/code.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Page } from "@playwright/test";
import { codeScreen } from "./code-screen";
import { getOtpFromSink } from "./sink";
export async function otpFromSink(page: Page, key: string) {
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getOtpFromSink(key);
await code(page, c);
}
export async function code(page: Page, code: string) {
await codeScreen(page, code);
await page.getByTestId("submit-button").click();
}
export async function codeResend(page: Page) {
await page.getByTestId("resend-button").click();
}

View File

@@ -1,6 +1,8 @@
import { expect, Page } from "@playwright/test"; import { expect, Page } from "@playwright/test";
import { code, otpFromSink } from "./code";
import { loginname } from "./loginname"; import { loginname } from "./loginname";
import { password } from "./password"; import { password } from "./password";
import { totp } from "./zitadel";
export async function startLogin(page: Page) { export async function startLogin(page: Page) {
await page.goto("/loginname"); await page.goto("/loginname");
@@ -23,6 +25,17 @@ export async function loginScreenExpect(page: Page, fullName: string) {
await expect(page.getByRole("heading")).toContainText(fullName); await expect(page.getByRole("heading")).toContainText(fullName);
} }
export async function loginWithOTP(page: Page, username: string, password: string) { export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) {
await loginWithPassword(page, username, password); await loginWithPassword(page, username, password);
await otpFromSink(page, email);
}
export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) {
await loginWithPassword(page, username, password);
await otpFromSink(page, phone);
}
export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) {
await loginWithPassword(page, username, password);
await code(page, totp(secret));
} }

View File

@@ -1,12 +1,12 @@
import { expect, Page } from "@playwright/test"; import { expect, Page } from "@playwright/test";
const usernameUserInput = "username-text-input"; const usernameTextInput = "username-text-input";
export async function loginnameScreen(page: Page, username: string) { export async function loginnameScreen(page: Page, username: string) {
await page.getByTestId(usernameUserInput).pressSequentially(username); await page.getByTestId(usernameTextInput).pressSequentially(username);
} }
export async function loginnameScreenExpect(page: Page, username: string) { export async function loginnameScreenExpect(page: Page, username: string) {
await expect(page.getByTestId(usernameUserInput)).toHaveValue(username); await expect(page.getByTestId(usernameTextInput)).toHaveValue(username);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not find user"); await expect(page.getByTestId("error").locator("div")).toContainText("Could not find user");
} }

View File

@@ -1,31 +0,0 @@
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;
}

View File

@@ -1,5 +1,7 @@
import { expect, Page } from "@playwright/test"; import { expect, Page } from "@playwright/test";
import { getCodeFromSink } from "./sink";
const codeField = "code-text-input";
const passwordField = "password-text-input"; const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input"; const passwordConfirmField = "password-confirm-text-input";
const lengthCheck = "length-check"; const lengthCheck = "length-check";
@@ -55,3 +57,26 @@ async function checkContent(page: Page, testid: string, match: boolean) {
await expect(page.getByTestId(testid)).toContainText(noMatchText); await expect(page.getByTestId(testid)).toContainText(noMatchText);
} }
} }
export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) {
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getCodeFromSink(username);
await page.getByTestId(codeField).pressSequentially(c);
await page.getByTestId(passwordField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2);
}
export async function resetPasswordScreenExpect(
page: Page,
password1: string,
password2: string,
length: boolean,
symbol: boolean,
number: boolean,
uppercase: boolean,
lowercase: boolean,
equals: boolean,
) {
await changePasswordScreenExpect(page, password1, password2, length, symbol, number, uppercase, lowercase, equals);
}

View File

@@ -1,7 +1,8 @@
import { Page } from "@playwright/test"; import { Page } from "@playwright/test";
import { changePasswordScreen, passwordScreen } from "./password-screen"; import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen";
const passwordSubmitButton = "submit-button"; const passwordSubmitButton = "submit-button";
const passwordResetButton = "reset-button";
export async function startChangePassword(page: Page, loginname: string) { export async function startChangePassword(page: Page, loginname: string) {
await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname })); await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname }));
@@ -17,3 +18,13 @@ export async function password(page: Page, password: string) {
await passwordScreen(page, password); await passwordScreen(page, password);
await page.getByTestId(passwordSubmitButton).click(); await page.getByTestId(passwordSubmitButton).click();
} }
export async function startResetPassword(page: Page) {
await page.getByTestId(passwordResetButton).click();
}
export async function resetPassword(page: Page, username: string, password: string) {
await startResetPassword(page);
await resetPasswordScreen(page, username, password, password);
await page.getByTestId(passwordSubmitButton).click();
}

View File

@@ -1,27 +1,39 @@
import { faker } from "@faker-js/faker";
import { test } from "@playwright/test"; import { test } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect } from "./login"; import { loginScreenExpect } from "./login";
import { registerWithPasskey, registerWithPassword } from "./register"; import { registerWithPasskey, registerWithPassword } from "./register";
import { removeUserByUsername } from "./zitadel"; import { removeUserByUsername } from "./zitadel";
test("register with password", async ({ page }) => { // Read from ".env" file.
const username = "register-password@example.com"; dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const password = "Password1!";
const firstname = "firstname"; test("register with password", async ({ page }) => {
const lastname = "lastname"; const username = faker.internet.email();
const password = "Password1!";
const firstname = faker.person.firstName();
const lastname = faker.person.lastName();
await removeUserByUsername(username);
await registerWithPassword(page, firstname, lastname, username, password, password); await registerWithPassword(page, firstname, lastname, username, password, password);
await loginScreenExpect(page, firstname + " " + lastname); await loginScreenExpect(page, firstname + " " + lastname);
// wait for projection of user
await page.waitForTimeout(2000);
await removeUserByUsername(username);
}); });
test("register with passkey", async ({ page }) => { test("register with passkey", async ({ page }) => {
const username = "register-passkey@example.com"; const username = faker.internet.email();
const firstname = "firstname"; const firstname = faker.person.firstName();
const lastname = "lastname"; const lastname = faker.person.lastName();
await removeUserByUsername(username);
await registerWithPasskey(page, firstname, lastname, username); await registerWithPasskey(page, firstname, lastname, username);
await loginScreenExpect(page, firstname + " " + lastname); await loginScreenExpect(page, firstname + " " + lastname);
// wait for projection of user
await page.waitForTimeout(2000);
await removeUserByUsername(username);
}); });
test("register with username and password - only password enabled", async ({ page }) => { test("register with username and password - only password enabled", async ({ page }) => {

View File

@@ -21,5 +21,9 @@ export async function registerWithPasskey(page: Page, firstname: string, lastnam
await page.goto("/register"); await page.goto("/register");
await registerUserScreenPasskey(page, firstname, lastname, email); await registerUserScreenPasskey(page, firstname, lastname, email);
await page.getByTestId("submit-button").click(); await page.getByTestId("submit-button").click();
// wait for projection of user
await page.waitForTimeout(2000);
return await passkeyRegister(page); return await passkeyRegister(page);
} }

55
acceptance/tests/sink.ts Normal file
View File

@@ -0,0 +1,55 @@
import axios from "axios";
export async function getOtpFromSink(key: string): Promise<any> {
try {
const response = await axios.post(
process.env.SINK_NOTIFICATION_URL!,
{
recipient: key,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
return response.data.args.oTP;
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function getCodeFromSink(key: string): Promise<any> {
try {
const response = await axios.post(
process.env.SINK_NOTIFICATION_URL!,
{
recipient: key,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
return response.data.args.code;
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}

View File

@@ -1,7 +1,6 @@
import { Page } from "@playwright/test"; import { Page } from "@playwright/test";
import axios from "axios";
import { registerWithPasskey } from "./register"; import { registerWithPasskey } from "./register";
import { getUserByUsername, removeUser } from "./zitadel"; import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./zitadel";
export interface userProps { export interface userProps {
email: string; email: string;
@@ -9,6 +8,7 @@ export interface userProps {
lastName: string; lastName: string;
organization: string; organization: string;
password: string; password: string;
phone: string;
} }
class User { class User {
@@ -20,54 +20,13 @@ class User {
} }
async ensure(page: Page) { async ensure(page: Page) {
await this.remove(); const response = await addUser(this.props);
const body = { this.setUserId(response.userId);
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!,
},
};
try {
const response = await axios.post(`${process.env.ZITADEL_API_URL}/v2/users/human`, body, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
});
if (response.status >= 400 && response.status !== 409) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
} catch (error) {
console.error("Error making request:", error);
throw error;
}
// wait for projection of user
await page.waitForTimeout(3000);
} }
async remove() { async cleanup() {
const resp: any = await getUserByUsername(this.getUsername()); await removeUser(this.getUserId());
if (!resp || !resp.result || !resp.result[0]) {
return;
}
await removeUser(resp.result[0].userId);
} }
public setUserId(userId: string) { public setUserId(userId: string) {
@@ -94,12 +53,22 @@ class User {
return this.props.lastName; return this.props.lastName;
} }
public getPhone() {
return this.props.phone;
}
public getFullName() { public getFullName() {
return `${this.props.firstName} ${this.props.lastName}`; return `${this.props.firstName} ${this.props.lastName}`;
} }
} }
export class PasswordUser extends User {} export class PasswordUser extends User {
async ensure(page: Page) {
await super.ensure(page);
// wait for projection of user
await page.waitForTimeout(2000);
}
}
export enum OtpType { export enum OtpType {
sms = "sms", sms = "sms",
@@ -112,12 +81,12 @@ export interface otpUserProps {
lastName: string; lastName: string;
organization: string; organization: string;
password: string; password: string;
phone: string;
type: OtpType; type: OtpType;
} }
export class PasswordUserWithOTP extends User { export class PasswordUserWithOTP extends User {
private type: OtpType; private type: OtpType;
private code: string;
constructor(props: otpUserProps) { constructor(props: otpUserProps) {
super({ super({
@@ -126,6 +95,7 @@ export class PasswordUserWithOTP extends User {
lastName: props.lastName, lastName: props.lastName,
organization: props.organization, organization: props.organization,
password: props.password, password: props.password,
phone: props.phone,
}); });
this.type = props.type; this.type = props.type;
} }
@@ -133,47 +103,27 @@ export class PasswordUserWithOTP extends User {
async ensure(page: Page) { async ensure(page: Page) {
await super.ensure(page); await super.ensure(page);
let url = "otp_"; await activateOTP(this.getUserId(), this.type);
switch (this.type) {
case OtpType.sms:
url = url + "sms";
break;
case OtpType.email:
url = url + "email";
break;
}
try { // wait for projection of user
const response = await axios.post( await page.waitForTimeout(2000);
`${process.env.ZITADEL_API_URL}/v2/users/${this.getUserId()}/${url}`, }
{}, }
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
},
);
if (response.status >= 400 && response.status !== 409) { export class PasswordUserWithTOTP extends User {
const error = `HTTP Error: ${response.status} - ${response.statusText}`; private secret: string;
console.error(error);
throw new Error(error);
}
// TODO: get code from SMS or Email provider async ensure(page: Page) {
this.code = ""; await super.ensure(page);
} catch (error) {
console.error("Error making request:", error); this.secret = await addTOTP(this.getUserId());
throw error;
}
// wait for projection of user // wait for projection of user
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
} }
public getCode() { public getSecret(): string {
return this.code; return this.secret;
} }
} }
@@ -182,6 +132,7 @@ export interface passkeyUserProps {
firstName: string; firstName: string;
lastName: string; lastName: string;
organization: string; organization: string;
phone: string;
} }
export class PasskeyUser extends User { export class PasskeyUser extends User {
@@ -194,11 +145,11 @@ export class PasskeyUser extends User {
lastName: props.lastName, lastName: props.lastName,
organization: props.organization, organization: props.organization,
password: "", password: "",
phone: props.phone,
}); });
} }
public async ensure(page: Page) { public async ensure(page: Page) {
await this.remove();
const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername()); const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername());
this.authenticatorId = authId; this.authenticatorId = authId;
@@ -206,8 +157,12 @@ export class PasskeyUser extends User {
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
} }
public async remove() { async cleanup() {
await super.remove(); const resp: any = await getUserByUsername(this.getUsername());
if (!resp || !resp.result || !resp.result[0]) {
return;
}
await removeUser(resp.result[0].userId);
} }
public getAuthenticatorId(): string { public getAuthenticatorId(): string {

View File

@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test"; import { test as base } from "@playwright/test";
import dotenv from "dotenv"; import dotenv from "dotenv";
import path from "path"; import path from "path";
@@ -10,13 +11,15 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasskeyUser }>({ const test = base.extend<{ user: PasskeyUser }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasskeyUser({ const user = new PasskeyUser({
email: "passkey@example.com", email: faker.internet.email(),
firstName: "first", firstName: faker.person.firstName(),
lastName: "last", lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number(),
}); });
await user.ensure(page); await user.ensure(page);
await use(user); await use(user);
await user.cleanup();
}, },
}); });
@@ -25,16 +28,7 @@ test("username and passkey login", async ({ user, page }) => {
await loginScreenExpect(page, user.getFullName()); await loginScreenExpect(page, user.getFullName());
}); });
test("username and passkey login, if passkey enabled", async ({ user, page }) => { test("username and passkey login, multiple auth methods", async ({ page }) => {
// Given passkey is enabled on the organization of the user
// Given the user has only passkey enabled as authentication
// enter username
// passkey popup is directly shown
// user verifies passkey
// user is redirected to app
});
test("username and passkey login, multiple auth methods", async ({ user, page }) => {
// Given passkey and password is enabled on the organization of the user // Given passkey and password is enabled on the organization of the user
// Given the user has password and passkey registered // Given the user has password and passkey registered
// enter username // enter username

View File

@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test"; import { test as base } from "@playwright/test";
import dotenv from "dotenv"; import dotenv from "dotenv";
import path from "path"; import path from "path";
@@ -12,14 +13,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({ const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUser({ const user = new PasswordUser({
email: "password-changed@example.com", email: faker.internet.email(),
firstName: "first", firstName: faker.person.firstName(),
lastName: "last", lastName: faker.person.lastName(),
password: "Password1!",
organization: "", organization: "",
phone: faker.phone.number(),
password: "Password1!",
}); });
await user.ensure(page); await user.ensure(page);
await use(user); await use(user);
await user.cleanup();
}, },
}); });
@@ -40,7 +43,7 @@ test("username and password changed login", async ({ user, page }) => {
*/ */
}); });
test("password not with desired complexity", async ({ user, page }) => { test("password change not with desired complexity", async ({ user, page }) => {
const changedPw1 = "change"; const changedPw1 = "change";
const changedPw2 = "chang"; const changedPw2 = "chang";
await loginWithPassword(page, user.getUsername(), user.getPassword()); await loginWithPassword(page, user.getUsername(), user.getPassword());

View File

@@ -1,6 +1,34 @@
import { test } from "@playwright/test"; import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code, codeResend, otpFromSink } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login";
import { OtpType, PasswordUserWithOTP } from "./user";
test("username, password and email otp login, enter code manually", async ({ page }) => { // Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
type: OtpType.email,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and email otp login, enter code manually", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user // Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor // Given the user has only email otp configured as second factor
// User enters username // User enters username
@@ -8,6 +36,8 @@ test("username, password and email otp login, enter code manually", async ({ pag
// User receives an email with a verification code // User receives an email with a verification code
// User enters the code into the ui // User enters the code into the ui
// User is redirected to the app (default redirect url) // User is redirected to the app (default redirect url)
await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername());
await loginScreenExpect(page, user.getFullName());
}); });
test("username, password and email otp login, click link in email", async ({ page }) => { test("username, password and email otp login, click link in email", async ({ page }) => {
@@ -20,7 +50,7 @@ test("username, password and email otp login, click link in email", async ({ pag
// User is redirected to the app (default redirect url) // User is redirected to the app (default redirect url)
}); });
test("username, password and email otp login, resend code", async ({ page }) => { test("username, password and email otp login, resend code", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user // Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor // Given the user has only email otp configured as second factor
// User enters username // User enters username
@@ -30,16 +60,24 @@ test("username, password and email otp login, resend code", async ({ page }) =>
// User receives a new email with a verification code // User receives a new email with a verification code
// User enters the new code in the ui // User enters the new code in the ui
// User is redirected to the app (default redirect url) // User is redirected to the app (default redirect url)
await loginWithPassword(page, user.getUsername(), user.getPassword());
await codeResend(page);
await otpFromSink(page, user.getUsername());
await loginScreenExpect(page, user.getFullName());
}); });
test("username, password and email otp login, wrong code", async ({ page }) => { test("username, password and email otp login, wrong code", async ({ user, page }) => {
// Given email otp is enabled on the organization of the user // Given email otp is enabled on the organization of the user
// Given the user has only email otp configured as second factor // Given the user has only email otp configured as second factor
// User enters username // User enters username
// User enters password // User enters password
// User receives an email with a verification code // User receives an email with a verification code
// User enters a wrond code // User enters a wrong code
// Error message - "Invalid code" is shown // Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
}); });
test("username, password and email otp login, multiple mfa options", async ({ page }) => { test("username, password and email otp login, multiple mfa options", async ({ page }) => {
@@ -49,7 +87,7 @@ test("username, password and email otp login, multiple mfa options", async ({ pa
// User enters password // User enters password
// User receives an email with a verification code // User receives an email with a verification code
// User clicks button to use sms otp as second factor // User clicks button to use sms otp as second factor
// User receives an sms with a verification code // User receives a sms with a verification code
// User enters code in ui // User enters code in ui
// User is redirected to the app (default redirect url) // User is redirected to the app (default redirect url)
}); });

View File

@@ -1,6 +1,34 @@
import { test } from "@playwright/test"; import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login";
import { OtpType, PasswordUserWithOTP } from "./user";
test("username, password and sms otp login", async ({ page }) => { // Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number({ style: "international" }),
password: "Password1!",
type: OtpType.sms,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and sms otp login, enter code manually", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user // Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor // Given the user has only sms otp configured as second factor
// User enters username // User enters username
@@ -8,9 +36,11 @@ test("username, password and sms otp login", async ({ page }) => {
// User receives a sms with a verification code // User receives a sms with a verification code
// User enters the code into the ui // User enters the code into the ui
// User is redirected to the app (default redirect url) // User is redirected to the app (default redirect url)
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
}); });
test("username, password and sms otp login, resend code", async ({ page }) => { test("username, password and sms otp login, resend code", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user // Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor // Given the user has only sms otp configured as second factor
// User enters username // User enters username
@@ -19,9 +49,11 @@ test("username, password and sms otp login, resend code", async ({ page }) => {
// User clicks resend code // User clicks resend code
// User receives a new sms with a verification code // User receives a new sms with a verification code
// User is redirected to the app (default redirect url) // User is redirected to the app (default redirect url)
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
}); });
test("username, password and sms otp login, wrong code", async ({ page }) => { test("username, password and sms otp login, wrong code", async ({ user, page }) => {
// Given sms otp is enabled on the organization of the user // Given sms otp is enabled on the organization of the user
// Given the user has only sms otp configured as second factor // Given the user has only sms otp configured as second factor
// User enters username // User enters username
@@ -29,4 +61,8 @@ test("username, password and sms otp login, wrong code", async ({ page }) => {
// User receives a sms with a verification code // User receives a sms with a verification code
// User enters a wrong code // User enters a wrong code
// Error message - "Invalid code" is shown // Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
}); });

View File

@@ -0,0 +1,51 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect, loginWithPassword, startLogin } from "./login";
import { loginname } from "./loginname";
import { resetPassword, startResetPassword } from "./password";
import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen";
import { PasswordUser } from "./user";
// 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: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username and password set login", async ({ user, page }) => {
// commented, fix in https://github.com/zitadel/zitadel/pull/8807
const changedPw = "ChangedPw1!";
await startLogin(page);
await loginname(page, user.getUsername());
await resetPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
await loginWithPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
});
test("password set not with desired complexity", async ({ user, page }) => {
const changedPw1 = "change";
const changedPw2 = "chang";
await startLogin(page);
await loginname(page, user.getUsername());
await startResetPassword(page);
await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2);
await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false);
});

View File

@@ -1,6 +1,33 @@
import { test } from "@playwright/test"; import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login";
import { PasswordUserWithTOTP } from "./user";
test("username, password and totp login", async ({ page }) => { // Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({
user: async ({ page }, use) => {
const user = new PasswordUserWithTOTP({
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number({ style: "international" }),
password: "Password1!",
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username, password and totp login", async ({ user, page }) => {
// Given totp is enabled on the organization of the user // Given totp is enabled on the organization of the user
// Given the user has only totp configured as second factor // Given the user has only totp configured as second factor
// User enters username // User enters username
@@ -8,9 +35,11 @@ test("username, password and totp login", async ({ page }) => {
// Screen for entering the code is shown directly // Screen for entering the code is shown directly
// User enters the code into the ui // User enters the code into the ui
// User is redirected to the app (default redirect url) // User is redirected to the app (default redirect url)
await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret());
await loginScreenExpect(page, user.getFullName());
}); });
test("username, password and totp otp login, wrong code", async ({ page }) => { test("username, password and totp otp login, wrong code", async ({ user, page }) => {
// Given totp is enabled on the organization of the user // Given totp is enabled on the organization of the user
// Given the user has only totp configured as second factor // Given the user has only totp configured as second factor
// User enters username // User enters username
@@ -18,6 +47,10 @@ test("username, password and totp otp login, wrong code", async ({ page }) => {
// Screen for entering the code is shown directly // Screen for entering the code is shown directly
// User enters a wrond code // User enters a wrond code
// Error message - "Invalid code" is shown // Error message - "Invalid code" is shown
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
}); });
test("username, password and totp login, multiple mfa options", async ({ page }) => { test("username, password and totp login, multiple mfa options", async ({ page }) => {

View File

@@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test"; import { test as base } from "@playwright/test";
import dotenv from "dotenv"; import dotenv from "dotenv";
import path from "path"; import path from "path";
@@ -14,14 +15,16 @@ dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({ const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUser({ const user = new PasswordUser({
email: "password@example.com", email: faker.internet.email(),
firstName: "first", firstName: faker.person.firstName(),
lastName: "last", lastName: faker.person.lastName(),
password: "Password1!",
organization: "", organization: "",
phone: faker.phone.number(),
password: "Password1!",
}); });
await user.ensure(page); await user.ensure(page);
await use(user); await use(user);
await user.cleanup();
}, },
}); });

View File

@@ -1,4 +1,34 @@
import { Authenticator } from "@otplib/core";
import { createDigest, createRandomBytes } from "@otplib/plugin-crypto";
import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin
import axios from "axios"; import axios from "axios";
import { OtpType, userProps } from "./user";
export async function addUser(props: userProps) {
const body = {
username: props.email,
organization: {
orgId: props.organization,
},
profile: {
givenName: props.firstName,
familyName: props.lastName,
},
email: {
email: props.email,
isVerified: true,
},
phone: {
phone: props.phone!,
isVerified: true,
},
password: {
password: props.password!,
},
};
return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body);
}
export async function removeUserByUsername(username: string) { export async function removeUserByUsername(username: string) {
const resp = await getUserByUsername(username); const resp = await getUserByUsername(username);
@@ -9,8 +39,12 @@ export async function removeUserByUsername(username: string) {
} }
export async function removeUser(id: string) { export async function removeUser(id: string) {
await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`);
}
async function deleteCall(url: string) {
try { try {
const response = await axios.delete(`${process.env.ZITADEL_API_URL}/v2/users/${id}`, { const response = await axios.delete(url, {
headers: { headers: {
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`, Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
}, },
@@ -27,7 +61,7 @@ export async function removeUser(id: string) {
} }
} }
export async function getUserByUsername(username: string) { export async function getUserByUsername(username: string): Promise<any> {
const listUsersBody = { const listUsersBody = {
queries: [ queries: [
{ {
@@ -38,8 +72,12 @@ export async function getUserByUsername(username: string) {
], ],
}; };
return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody);
}
async function listCall(url: string, data: any): Promise<any> {
try { try {
const response = await axios.post(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody, { const response = await axios.post(url, data, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`, Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
@@ -58,3 +96,64 @@ export async function getUserByUsername(username: string) {
throw error; throw error;
} }
} }
export async function activateOTP(userId: string, type: OtpType) {
let url = "otp_";
switch (type) {
case OtpType.sms:
url = url + "sms";
break;
case OtpType.email:
url = url + "email";
break;
}
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {});
}
async function pushCall(url: string, data: any) {
try {
const response = await axios.post(url, data, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.ZITADEL_SERVICE_USER_TOKEN}`,
},
});
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
}
} catch (error) {
console.error("Error making request:", error);
throw error;
}
}
export async function addTOTP(userId: string): Promise<string> {
const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {});
const code = totp(response.secret);
await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code });
return response.secret;
}
export function totp(secret: string) {
const authenticator = new Authenticator({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
});
// google authenticator usage
const token = authenticator.generate(secret);
// check if token can be used
if (!authenticator.verify({ token: token, secret: secret })) {
const error = `Generated token could not be verified`;
console.error(error);
throw new Error(error);
}
return token;
}

View File

@@ -95,7 +95,7 @@ export function LoginOTP({
if (method === "sms") { if (method === "sms") {
challenges = create(RequestChallengesSchema, { challenges = create(RequestChallengesSchema, {
otpSms: { returnCode: true }, otpSms: {},
}); });
} }
@@ -233,6 +233,7 @@ export function LoginOTP({
setLoading(false); setLoading(false);
}); });
}} }}
data-testid="resend-button"
> >
{t("verify.resendCode")} {t("verify.resendCode")}
</button> </button>
@@ -245,11 +246,12 @@ export function LoginOTP({
{...register("code", { required: "This field is required" })} {...register("code", { required: "This field is required" })}
label="Code" label="Code"
autoComplete="one-time-code" autoComplete="one-time-code"
data-testid="code-text-input"
/> />
</div> </div>
{error && ( {error && (
<div className="py-4"> <div className="py-4" data-testid="error">
<Alert>{error}</Alert> <Alert>{error}</Alert>
</div> </div>
)} )}

View File

@@ -134,6 +134,7 @@ export function PasswordForm({
onClick={() => resetPasswordAndContinue()} onClick={() => resetPasswordAndContinue()}
type="button" type="button"
disabled={loading} disabled={loading}
data-testid="reset-button"
> >
{t("verify.resetPassword")} {t("verify.resetPassword")}
</button> </button>

View File

@@ -176,11 +176,15 @@ export function SetPasswordForm({
label="Code" label="Code"
autoComplete="one-time-code" autoComplete="one-time-code"
error={errors.code?.message as string} error={errors.code?.message as string}
data-testid="code-text-input"
/> />
</div> </div>
<div className="ml-4 mb-1"> <div className="ml-4 mb-1">
<Button variant={ButtonVariants.Secondary}> <Button
variant={ButtonVariants.Secondary}
data-testid="resend-button"
>
{t("set.resend")} {t("set.resend")}
</Button> </Button>
</div> </div>
@@ -196,6 +200,7 @@ export function SetPasswordForm({
})} })}
label="New Password" label="New Password"
error={errors.password?.message as string} error={errors.password?.message as string}
data-testid="password-text-input"
/> />
</div> </div>
<div className=""> <div className="">
@@ -208,6 +213,7 @@ export function SetPasswordForm({
})} })}
label="Confirm Password" label="Confirm Password"
error={errors.confirmPassword?.message as string} error={errors.confirmPassword?.message as string}
data-testid="password-confirm-text-input"
/> />
</div> </div>
</div> </div>

View File

@@ -23,7 +23,9 @@
"changeset": "changeset", "changeset": "changeset",
"version-packages": "changeset version", "version-packages": "changeset version",
"release": "turbo run build --filter=login^... && changeset publish", "release": "turbo run build --filter=login^... && changeset publish",
"run-zitadel": "docker compose -f ./acceptance/docker-compose.yaml run setup" "run-zitadel": "docker compose -f ./acceptance/docker-compose.yaml run setup",
"run-sink": "docker compose -f ./acceptance/docker-compose.yaml up -d sink",
"stop": "docker compose -f ./acceptance/docker-compose.yaml stop"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
@@ -33,6 +35,10 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@otplib/core": "^12.0.0",
"@otplib/plugin-thirty-two": "^12.0.0",
"@otplib/plugin-crypto": "^12.0.0",
"@faker-js/faker": "^9.2.0",
"@changesets/cli": "^2.27.9", "@changesets/cli": "^2.27.9",
"@playwright/test": "^1.48.2", "@playwright/test": "^1.48.2",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",

44
pnpm-lock.yaml generated
View File

@@ -16,6 +16,18 @@ importers:
'@changesets/cli': '@changesets/cli':
specifier: ^2.27.9 specifier: ^2.27.9
version: 2.27.9 version: 2.27.9
'@faker-js/faker':
specifier: ^9.2.0
version: 9.2.0
'@otplib/core':
specifier: ^12.0.0
version: 12.0.1
'@otplib/plugin-crypto':
specifier: ^12.0.0
version: 12.0.1
'@otplib/plugin-thirty-two':
specifier: ^12.0.0
version: 12.0.1
'@playwright/test': '@playwright/test':
specifier: ^1.48.2 specifier: ^1.48.2
version: 1.48.2 version: 1.48.2
@@ -919,6 +931,10 @@ packages:
resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@faker-js/faker@9.2.0':
resolution: {integrity: sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg==}
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
'@floating-ui/core@1.6.8': '@floating-ui/core@1.6.8':
resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
@@ -1211,6 +1227,15 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@otplib/core@12.0.1':
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
'@otplib/plugin-crypto@12.0.1':
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
'@otplib/plugin-thirty-two@12.0.1':
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
'@parcel/watcher-android-arm64@2.5.0': '@parcel/watcher-android-arm64@2.5.0':
resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==} resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@@ -4376,6 +4401,10 @@ packages:
thenify@3.3.1: thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
throttleit@1.0.1: throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
@@ -5438,6 +5467,8 @@ snapshots:
'@eslint/js@8.57.1': {} '@eslint/js@8.57.1': {}
'@faker-js/faker@9.2.0': {}
'@floating-ui/core@1.6.8': '@floating-ui/core@1.6.8':
dependencies: dependencies:
'@floating-ui/utils': 0.2.8 '@floating-ui/utils': 0.2.8
@@ -5717,6 +5748,17 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@otplib/core@12.0.1': {}
'@otplib/plugin-crypto@12.0.1':
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-thirty-two@12.0.1':
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
'@parcel/watcher-android-arm64@2.5.0': '@parcel/watcher-android-arm64@2.5.0':
optional: true optional: true
@@ -9136,6 +9178,8 @@ snapshots:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
thirty-two@1.0.2: {}
throttleit@1.0.1: {} throttleit@1.0.1: {}
through@2.3.8: {} through@2.3.8: {}