chore: add sms otp and password set acceptance tests

This commit is contained in:
Stefan Benz
2024-11-21 18:01:12 +01:00
parent 1baa2409be
commit f38b8b753c
15 changed files with 165 additions and 46 deletions

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.
### 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
To deploy your own version on Vercel, navigate to your instance and create a service user.

View File

@@ -1,11 +1,11 @@
import { Page } from "@playwright/test";
import { codeScreen } from "./code-screen";
import { getCodeFromSink } from "./sink";
import { getOtpFromSink } from "./sink";
export async function codeFromSink(page: Page, key: string) {
export async function otpFromSink(page: Page, key: string) {
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getCodeFromSink(key);
const c = await getOtpFromSink(key);
await code(page, c);
}

View File

@@ -1,5 +1,5 @@
import { expect, Page } from "@playwright/test";
import { codeFromSink } from "./code";
import { otpFromSink } from "./code";
import { loginname } from "./loginname";
import { password } from "./password";
@@ -26,10 +26,10 @@ export async function loginScreenExpect(page: Page, fullName: string) {
export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) {
await loginWithPassword(page, username, password);
await codeFromSink(page, email);
await otpFromSink(page, email);
}
export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) {
await loginWithPassword(page, username, password);
await codeFromSink(page, phone);
await otpFromSink(page, phone);
}

View File

@@ -1,5 +1,7 @@
import { expect, Page } from "@playwright/test";
import { getCodeFromSink } from "./sink";
const codeField = "code-text-input";
const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input";
const lengthCheck = "length-check";
@@ -55,3 +57,26 @@ async function checkContent(page: Page, testid: string, match: boolean) {
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 { changePasswordScreen, passwordScreen } from "./password-screen";
import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen";
const passwordSubmitButton = "submit-button";
const passwordResetButton = "reset-button";
export async function startChangePassword(page: Page, loginname: string) {
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 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,6 +1,6 @@
import axios from "axios";
export async function getCodeFromSink(key: string): Promise<any> {
export async function getOtpFromSink(key: string): Promise<any> {
try {
const response = await axios.post(
process.env.SINK_NOTIFICATION_URL!,
@@ -26,3 +26,30 @@ export async function getCodeFromSink(key: string): Promise<any> {
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

@@ -21,8 +21,6 @@ class User {
}
async ensure(page: Page) {
await this.remove();
const body = {
username: this.props.email,
organization: {
@@ -53,7 +51,7 @@ class User {
},
});
if (response.status >= 400 && response.status !== 409) {
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
@@ -65,7 +63,7 @@ class User {
}
// wait for projection of user
await page.waitForTimeout(3000);
await page.waitForTimeout(2000);
}
async remove() {
@@ -166,7 +164,7 @@ export class PasswordUserWithOTP extends User {
},
);
if (response.status >= 400 && response.status !== 409) {
if (response.status >= 400) {
const error = `HTTP Error: ${response.status} - ${response.statusText}`;
console.error(error);
throw new Error(error);
@@ -204,7 +202,6 @@ export class PasskeyUser extends User {
}
public async ensure(page: Page) {
await this.remove();
const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername());
this.authenticatorId = authId;
@@ -212,10 +209,6 @@ export class PasskeyUser extends User {
await page.waitForTimeout(2000);
}
public async remove() {
await super.remove();
}
public getAuthenticatorId(): string {
return this.authenticatorId;
}

View File

@@ -27,15 +27,6 @@ test("username and passkey login", async ({ user, page }) => {
await loginScreenExpect(page, user.getFullName());
});
test("username and passkey login, if passkey enabled", 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 ({ page }) => {
// Given passkey and password is enabled on the organization of the user
// Given the user has password and passkey registered

View File

@@ -42,7 +42,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 changedPw2 = "chang";
await loginWithPassword(page, user.getUsername(), user.getPassword());

View File

@@ -2,7 +2,7 @@ import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { code, codeFromSink, codeResend } from "./code";
import { code, codeResend, otpFromSink } from "./code";
import { codeScreenExpect } from "./code-screen";
import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login";
import { OtpType, PasswordUserWithOTP } from "./user";
@@ -61,7 +61,7 @@ test("username, password and email otp login, resend code", async ({ user, page
// User is redirected to the app (default redirect url)
await loginWithPassword(page, user.getUsername(), user.getPassword());
await codeResend(page);
await codeFromSink(page, user.getUsername());
await otpFromSink(page, user.getUsername());
await loginScreenExpect(page, user.getFullName());
});

View File

@@ -2,6 +2,9 @@ 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";
// Read from ".env" file.
@@ -14,7 +17,7 @@ const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
phone: faker.phone.number({ style: "international" }),
password: "Password1!",
type: OtpType.sms,
});
@@ -32,10 +35,8 @@ test("username, password and sms otp login, enter code manually", async ({ user,
// User receives a sms with a verification code
// User enters the code into the ui
// User is redirected to the app (default redirect url)
/* TODO fix on login, that sms is sent
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
*/
});
test("username, password and sms otp login, resend code", async ({ user, page }) => {
@@ -47,11 +48,9 @@ test("username, password and sms otp login, resend code", async ({ user, page })
// User clicks resend code
// User receives a new sms with a verification code
// User is redirected to the app (default redirect url)
/* TODO fix on login, that sms is sent
await loginWithPassword(page, user.getUsername(), user.getPassword());
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
*/
await loginWithPassword(page, user.getUsername(), user.getPassword());
await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone());
await loginScreenExpect(page, user.getFullName());
});
test("username, password and sms otp login, wrong code", async ({ user, page }) => {
@@ -62,10 +61,8 @@ test("username, password and sms otp login, wrong code", async ({ user, page })
// User receives a sms with a verification code
// User enters a wrong code
// Error message - "Invalid code" is shown
/* TODO fix on login, that sms is sent
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
*/
const c = "wrongcode";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await code(page, c);
await codeScreenExpect(page, c);
});

View File

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

@@ -77,7 +77,7 @@ export function LoginOTP({
if (method === "sms") {
challenges = create(RequestChallengesSchema, {
otpSms: { returnCode: true },
otpSms: {},
});
}

View File

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

View File

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