diff --git a/acceptance/docker-compose.yaml b/acceptance/docker-compose.yaml index de6990387df..43bd475759c 100644 --- a/acceptance/docker-compose.yaml +++ b/acceptance/docker-compose.yaml @@ -1,7 +1,7 @@ services: zitadel: user: "${ZITADEL_DEV_UID}" - image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:v2.65.0}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:v2.67.1}" command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' ports: - "8080:8080" diff --git a/acceptance/setup.sh b/acceptance/setup.sh index e6277854a70..596c985d783 100755 --- a/acceptance/setup.sh +++ b/acceptance/setup.sh @@ -40,6 +40,7 @@ echo "ZITADEL_API_URL=${ZITADEL_API_URL} ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID} ZITADEL_SERVICE_USER_TOKEN=${PAT} SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL} +EMAIL_VERIFICATION=true DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" cat ${WRITE_ENVIRONMENT_FILE} diff --git a/acceptance/tests/email-verify-screen.ts b/acceptance/tests/email-verify-screen.ts new file mode 100644 index 00000000000..b077ecb4244 --- /dev/null +++ b/acceptance/tests/email-verify-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function emailVerifyScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function emailVerifyScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify email"); +} diff --git a/acceptance/tests/email-verify.spec.ts b/acceptance/tests/email-verify.spec.ts new file mode 100644 index 00000000000..d95c1f691d1 --- /dev/null +++ b/acceptance/tests/email-verify.spec.ts @@ -0,0 +1,73 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { emailVerify, emailVerifyResend } from "./email-verify"; +import { emailVerifyScreenExpect } from "./email-verify-screen"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { getCodeFromSink } from "./sink"; +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(), + isEmailVerified: false, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("user email not verified, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + await emailVerifyResend(page); + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, old code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + // wait for send of the code + await page.waitForTimeout(3000); + const c = await getCodeFromSink(user.getUsername()); + await emailVerifyResend(page); + // wait for resend of the code + await page.waitForTimeout(1000); + await emailVerify(page, c); + await emailVerifyScreenExpect(page, c); +}); + +test("user email not verified, wrong code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + const code = "wrong"; + await emailVerify(page, code); + await emailVerifyScreenExpect(page, code); +}); diff --git a/acceptance/tests/email-verify.ts b/acceptance/tests/email-verify.ts new file mode 100644 index 00000000000..dd7f74b29aa --- /dev/null +++ b/acceptance/tests/email-verify.ts @@ -0,0 +1,15 @@ +import { Page } from "@playwright/test"; +import { emailVerifyScreen } from "./email-verify-screen"; + +export async function startEmailVerify(page: Page, loginname: string) { + await page.goto("/verify"); +} + +export async function emailVerify(page: Page, code: string) { + await emailVerifyScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function emailVerifyResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/acceptance/tests/password-screen.ts b/acceptance/tests/password-screen.ts index 57334a07d22..f207b170359 100644 --- a/acceptance/tests/password-screen.ts +++ b/acceptance/tests/password-screen.ts @@ -4,6 +4,10 @@ import { getCodeFromSink } from "./sink"; const codeField = "code-text-input"; const passwordField = "password-text-input"; const passwordConfirmField = "password-confirm-text-input"; +const passwordChangeField = "password-change-text-input"; +const passwordChangeConfirmField = "password-change-confirm-text-input"; +const passwordSetField = "password-set-text-input"; +const passwordSetConfirmField = "password-set-confirm-text-input"; const lengthCheck = "length-check"; const symbolCheck = "symbol-check"; const numberCheck = "number-check"; @@ -15,8 +19,8 @@ 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); + await page.getByTestId(passwordChangeField).pressSequentially(password1); + await page.getByTestId(passwordChangeConfirmField).pressSequentially(password2); } export async function passwordScreen(page: Page, password: string) { @@ -39,9 +43,21 @@ export async function changePasswordScreenExpect( lowercase: boolean, equals: boolean, ) { - await expect(page.getByTestId(passwordField)).toHaveValue(password1); - await expect(page.getByTestId(passwordConfirmField)).toHaveValue(password2); + await expect(page.getByTestId(passwordChangeField)).toHaveValue(password1); + await expect(page.getByTestId(passwordChangeConfirmField)).toHaveValue(password2); + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} + +async function checkComplexity( + page: Page, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { await checkContent(page, lengthCheck, length); await checkContent(page, symbolCheck, symbol); await checkContent(page, numberCheck, number); @@ -63,8 +79,8 @@ export async function resetPasswordScreen(page: Page, username: string, password 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); + await page.getByTestId(passwordSetField).pressSequentially(password1); + await page.getByTestId(passwordSetConfirmField).pressSequentially(password2); } export async function resetPasswordScreenExpect( @@ -78,5 +94,8 @@ export async function resetPasswordScreenExpect( lowercase: boolean, equals: boolean, ) { - await changePasswordScreenExpect(page, password1, password2, length, symbol, number, uppercase, lowercase, equals); + await expect(page.getByTestId(passwordSetField)).toHaveValue(password1); + await expect(page.getByTestId(passwordSetConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); } diff --git a/acceptance/tests/password.ts b/acceptance/tests/password.ts index b3d31fcaeef..1dc304cc848 100644 --- a/acceptance/tests/password.ts +++ b/acceptance/tests/password.ts @@ -8,8 +8,7 @@ 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); +export async function changePassword(page: Page, password: string) { await changePasswordScreen(page, password, password); await page.getByTestId(passwordSubmitButton).click(); } diff --git a/acceptance/tests/register.ts b/acceptance/tests/register.ts index 693bdfbc0d9..19cb0f04fd0 100644 --- a/acceptance/tests/register.ts +++ b/acceptance/tests/register.ts @@ -1,6 +1,8 @@ import { Page } from "@playwright/test"; +import { emailVerify } from "./email-verify"; import { passkeyRegister } from "./passkey"; import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; +import { getCodeFromSink } from "./sink"; export async function registerWithPassword( page: Page, @@ -15,6 +17,9 @@ export async function registerWithPassword( await page.getByTestId("submit-button").click(); await registerPasswordScreen(page, password1, password2); await page.getByTestId("submit-button").click(); + await page.waitForTimeout(3000); + + await verifyEmail(page, email); } export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise { @@ -23,7 +28,15 @@ export async function registerWithPasskey(page: Page, firstname: string, lastnam await page.getByTestId("submit-button").click(); // wait for projection of user - await page.waitForTimeout(2000); + await page.waitForTimeout(3000); + const authId = await passkeyRegister(page); - return await passkeyRegister(page); + await verifyEmail(page, email); + return authId; +} + +async function verifyEmail(page: Page, email: string) { + await page.waitForTimeout(1000); + const c = await getCodeFromSink(email); + await emailVerify(page, c); } diff --git a/acceptance/tests/user.ts b/acceptance/tests/user.ts index 3daefdff082..68a8eecd2bf 100644 --- a/acceptance/tests/user.ts +++ b/acceptance/tests/user.ts @@ -4,11 +4,14 @@ import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./ export interface userProps { email: string; + isEmailVerified?: boolean; firstName: string; lastName: string; organization: string; password: string; + passwordChangeRequired?: boolean; phone: string; + isPhoneVerified?: boolean; } class User { @@ -77,11 +80,14 @@ export enum OtpType { export interface otpUserProps { email: string; + isEmailVerified?: boolean; firstName: string; lastName: string; organization: string; password: string; + passwordChangeRequired?: boolean; phone: string; + isPhoneVerified?: boolean; type: OtpType; } @@ -96,6 +102,9 @@ export class PasswordUserWithOTP extends User { organization: props.organization, password: props.password, phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + passwordChangeRequired: props.passwordChangeRequired, }); this.type = props.type; } @@ -133,6 +142,8 @@ export interface passkeyUserProps { lastName: string; organization: string; phone: string; + isEmailVerified?: boolean; + isPhoneVerified?: boolean; } export class PasskeyUser extends User { @@ -146,6 +157,8 @@ export class PasskeyUser extends User { organization: props.organization, password: "", phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, }); } diff --git a/acceptance/tests/username-passkey.spec.ts b/acceptance/tests/username-passkey.spec.ts index e73de3547fd..54b1bf0a291 100644 --- a/acceptance/tests/username-passkey.spec.ts +++ b/acceptance/tests/username-passkey.spec.ts @@ -12,10 +12,12 @@ const test = base.extend<{ user: PasskeyUser }>({ user: async ({ page }, use) => { const user = new PasskeyUser({ email: faker.internet.email(), + isEmailVerified: true, firstName: faker.person.firstName(), lastName: faker.person.lastName(), organization: "", phone: faker.phone.number(), + isPhoneVerified: false, }); await user.ensure(page); await use(user); diff --git a/acceptance/tests/username-password-change-required.spec.ts b/acceptance/tests/username-password-change-required.spec.ts new file mode 100644 index 00000000000..50177d95e90 --- /dev/null +++ b/acceptance/tests/username-password-change-required.spec.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword } from "./password"; +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(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: true, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login, change required", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await page.waitForTimeout(100); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); diff --git a/acceptance/tests/username-password-changed.spec.ts b/acceptance/tests/username-password-changed.spec.ts index c185e51ec91..c43ec13797f 100644 --- a/acceptance/tests/username-password-changed.spec.ts +++ b/acceptance/tests/username-password-changed.spec.ts @@ -2,8 +2,8 @@ import { faker } from "@faker-js/faker"; import { test as base } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; -import { loginWithPassword } from "./login"; -import { startChangePassword } from "./password"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword, startChangePassword } from "./password"; import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; import { PasswordUser } from "./user"; @@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUser }>({ user: async ({ page }, use) => { const user = new PasswordUser({ email: faker.internet.email(), + isEmailVerified: true, firstName: faker.person.firstName(), lastName: faker.person.lastName(), organization: "", phone: faker.phone.number(), + isPhoneVerified: false, password: "Password1!", + passwordChangeRequired: false, }); await user.ensure(page); await use(user); @@ -27,20 +30,18 @@ const test = base.extend<{ user: PasswordUser }>({ }); test("username and password changed login", async ({ user, page }) => { - // commented, fix in https://github.com/zitadel/zitadel/pull/8807 - /* - const changedPw = "ChangedPw1!"; - await loginWithPassword(page, user.getUsername(), user.getPassword()); + const changedPw = "ChangedPw1!"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); - // wait for projection of token - await page.waitForTimeout(2000); + // wait for projection of token + await page.waitForTimeout(2000); - await changePassword(page, user.getUsername(), changedPw); - await loginScreenExpect(page, user.getFullName()); + await startChangePassword(page, user.getUsername()); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); - await loginWithPassword(page, user.getUsername(), changedPw); - await loginScreenExpect(page, user.getFullName()); - */ + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); }); test("password change not with desired complexity", async ({ user, page }) => { diff --git a/acceptance/tests/username-password-otp_email.spec.ts b/acceptance/tests/username-password-otp_email.spec.ts index daa2e0a4298..d06cc878342 100644 --- a/acceptance/tests/username-password-otp_email.spec.ts +++ b/acceptance/tests/username-password-otp_email.spec.ts @@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ user: async ({ page }, use) => { const user = new PasswordUserWithOTP({ email: faker.internet.email(), + isEmailVerified: true, firstName: faker.person.firstName(), lastName: faker.person.lastName(), organization: "", phone: faker.phone.number(), + isPhoneVerified: false, password: "Password1!", + passwordChangeRequired: false, type: OtpType.email, }); diff --git a/acceptance/tests/username-password-otp_sms.spec.ts b/acceptance/tests/username-password-otp_sms.spec.ts index 8fb91a66c79..ac69b25f08f 100644 --- a/acceptance/tests/username-password-otp_sms.spec.ts +++ b/acceptance/tests/username-password-otp_sms.spec.ts @@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ user: async ({ page }, use) => { const user = new PasswordUserWithOTP({ email: faker.internet.email(), + isEmailVerified: true, firstName: faker.person.firstName(), lastName: faker.person.lastName(), organization: "", phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, password: "Password1!", + passwordChangeRequired: false, type: OtpType.sms, }); diff --git a/acceptance/tests/username-password-set.spec.ts b/acceptance/tests/username-password-set.spec.ts index 30d442df50b..dcdfbb1c528 100644 --- a/acceptance/tests/username-password-set.spec.ts +++ b/acceptance/tests/username-password-set.spec.ts @@ -15,11 +15,14 @@ const test = base.extend<{ user: PasswordUser }>({ user: async ({ page }, use) => { const user = new PasswordUser({ email: faker.internet.email(), + isEmailVerified: true, firstName: faker.person.firstName(), lastName: faker.person.lastName(), organization: "", phone: faker.phone.number(), + isPhoneVerified: false, password: "Password1!", + passwordChangeRequired: false, }); await user.ensure(page); await use(user); @@ -28,8 +31,6 @@ const test = base.extend<{ user: PasswordUser }>({ }); 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()); diff --git a/acceptance/tests/username-password-totp.spec.ts b/acceptance/tests/username-password-totp.spec.ts index 4b6e6789318..e897cd77488 100644 --- a/acceptance/tests/username-password-totp.spec.ts +++ b/acceptance/tests/username-password-totp.spec.ts @@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({ user: async ({ page }, use) => { const user = new PasswordUserWithTOTP({ email: faker.internet.email(), + isEmailVerified: true, firstName: faker.person.firstName(), lastName: faker.person.lastName(), organization: "", phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, password: "Password1!", + passwordChangeRequired: false, }); await user.ensure(page); diff --git a/acceptance/tests/username-password.spec.ts b/acceptance/tests/username-password.spec.ts index fcb6aad037b..209c415511a 100644 --- a/acceptance/tests/username-password.spec.ts +++ b/acceptance/tests/username-password.spec.ts @@ -16,11 +16,14 @@ const test = base.extend<{ user: PasswordUser }>({ user: async ({ page }, use) => { const user = new PasswordUser({ email: faker.internet.email(), + isEmailVerified: true, firstName: faker.person.firstName(), lastName: faker.person.lastName(), organization: "", phone: faker.phone.number(), + isPhoneVerified: false, password: "Password1!", + passwordChangeRequired: false, }); await user.ensure(page); await use(user); diff --git a/acceptance/tests/zitadel.ts b/acceptance/tests/zitadel.ts index 587723f10dc..1923b63dce7 100644 --- a/acceptance/tests/zitadel.ts +++ b/acceptance/tests/zitadel.ts @@ -19,13 +19,20 @@ export async function addUser(props: userProps) { isVerified: true, }, phone: { - phone: props.phone!, + phone: props.phone, isVerified: true, }, password: { - password: props.password!, + password: props.password, + changeRequired: props.passwordChangeRequired ?? false, }, }; + if (!props.isEmailVerified) { + delete body.email.isVerified; + } + if (!props.isPhoneVerified) { + delete body.phone.isVerified; + } return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); } diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index f8c22029f76..c581a40b8dd 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -161,7 +161,7 @@ export function ChangePasswordForm({ })} label="New Password" error={errors.password?.message as string} - data-testid="password-text-input" + data-testid="password-change-text-input" />
@@ -174,7 +174,7 @@ export function ChangePasswordForm({ })} label="Confirm Password" error={errors.confirmPassword?.message as string} - data-testid="password-confirm-text-input" + data-testid="password-change-confirm-text-input" />
diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index d093d1d131e..ec6cf3cc6bd 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -237,7 +237,7 @@ export function SetPasswordForm({ })} label="New Password" error={errors.password?.message as string} - data-testid="password-text-input" + data-testid="password-set-text-input" />
@@ -250,7 +250,7 @@ export function SetPasswordForm({ })} label="Confirm Password" error={errors.confirmPassword?.message as string} - data-testid="password-confirm-text-input" + data-testid="password-set-confirm-text-input" />
diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 6b6189297e5..1982375ba16 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -115,7 +115,7 @@ export function VerifyForm({ {t("verify.noCodeReceived")}