import XCTest /// Tests for the password reset flow against the local stack. /// /// The app's reset flow is wired to a real Kratos recovery flow: the /// forgot-password screen starts a Kratos recovery flow and submits the email /// (AuthApi.kt:406 `forgotPassword`), which makes Kratos EMAIL a 6-digit /// recovery code that lands in Mailpit locally. The verify screen submits that /// emailed code back to the same flow (AuthApi.kt:448 `verifyResetCode`). So /// these tests read the live code from Mailpit instead of any fixed code. /// /// Test Plan IDs: AUTH-015, AUTH-016, AUTH-017 final class AuthPasswordResetUITests: BaseUITestCase { override var relaunchBetweenTests: Bool { true } private var testSession: TestSession? private var cleaner: TestDataCleaner? override func setUpWithError() throws { guard TestAccountAPIClient.isBackendReachable() else { throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)") } guard let session = TestAccountManager.createVerifiedAccount() else { throw XCTSkip("Could not create verified test account") } testSession = session cleaner = TestDataCleaner(token: session.token) // Force clean app launch — password reset flow leaves complex screen state app.terminate() try super.setUpWithError() } override func tearDownWithError() throws { cleaner?.cleanAll() try super.tearDownWithError() } // MARK: - AUTH-015: Verify reset code reaches new password screen func testAUTH015_VerifyResetCodeSuccessPath() throws { let session = try XCTUnwrap(testSession) // Capture the recovery email ONCE and reuse it for both the request and // the Mailpit lookup, so the lookup address matches what we submitted. let email = session.user.email // Navigate to forgot password let login = TestFlows.navigateToLoginFromOnboarding(app: app) login.tapForgotPassword() // Enter email and send code let forgotScreen = ForgotPasswordScreen(app: app) forgotScreen.waitForLoad() forgotScreen.enterEmail(email) forgotScreen.tapSendCode() // Read the REAL Kratos recovery code Kratos emailed to Mailpit. let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? "" XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)") let verifyScreen = VerifyResetCodeScreen(app: app) verifyScreen.waitForLoad() verifyScreen.enterCode(code) verifyScreen.tapVerify() // Should reach the new password screen let resetScreen = ResetPasswordScreen(app: app) try resetScreen.waitForLoad(timeout: loginTimeout) } // MARK: - AUTH-016: Full reset password cycle + login with new password func testAUTH016_ResetPasswordSuccess() throws { let session = try XCTUnwrap(testSession) let newPassword = "NewPass9876!" // Capture the recovery email ONCE for both the request and Mailpit lookup. let email = session.user.email // Navigate to forgot password let login = TestFlows.navigateToLoginFromOnboarding(app: app) login.tapForgotPassword() // Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow, // which hardcodes the obsolete debug code) so we submit the REAL Kratos // recovery code read from Mailpit. try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword) // After reset, the app auto-logs in with the new password. // If auto-login succeeds → app goes directly to main tabs (sheet dismissed). // If auto-login fails → success message + "Return to Login" button appear. let tabBar = app.tabBars.firstMatch let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton] let deadline = Date().addingTimeInterval(loginTimeout) var reachedPostReset = false while Date() < deadline { if tabBar.exists { // Auto-login succeeded — password reset worked! reachedPostReset = true break } if returnButton.exists { // Auto-login failed — manual login needed reachedPostReset = true returnButton.forceTap() break } RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button (manual login) after password reset") if tabBar.exists { // Already logged in via auto-login — test passed return } // Manual login path: return button was tapped, now on login screen let loginScreen = LoginScreenObject(app: app) loginScreen.waitForLoad(timeout: loginTimeout) loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL loginScreen.enterPassword(newPassword) let loginButton = app.buttons[UITestID.Auth.loginButton] loginButton.waitUntilHittable(timeout: 10).tap() XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password") } // MARK: - AUTH-015 (alias): Verify reset code reaches the new password screen func test03_verifyResetCodeSuccess() throws { try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable") let session = try XCTUnwrap(testSession) // Capture the recovery email ONCE for both the request and Mailpit lookup. let email = session.user.email // Navigate to forgot password let login = TestFlows.navigateToLoginFromOnboarding(app: app) login.tapForgotPassword() // Enter email and send the reset code let forgotScreen = ForgotPasswordScreen(app: app) forgotScreen.waitForLoad() forgotScreen.enterEmail(email) forgotScreen.tapSendCode() // Read the REAL Kratos recovery code Kratos emailed to Mailpit. let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? "" XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)") let verifyScreen = VerifyResetCodeScreen(app: app) verifyScreen.waitForLoad() verifyScreen.enterCode(code) verifyScreen.tapVerify() // The reset password screen should now appear let resetScreen = ResetPasswordScreen(app: app) try resetScreen.waitForLoad(timeout: loginTimeout) } // MARK: - AUTH-016 (alias): Full reset flow + login with new password func test04_resetPasswordSuccessAndLogin() throws { try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable") let session = try XCTUnwrap(testSession) let newPassword = "NewPass9876!" // Capture the recovery email ONCE for both the request and Mailpit lookup. let email = session.user.email // Navigate to forgot password, then drive the complete 3-step reset flow let login = TestFlows.navigateToLoginFromOnboarding(app: app) login.tapForgotPassword() // Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow, // which hardcodes the obsolete debug code) so we submit the REAL Kratos // recovery code read from Mailpit. try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword) // Wait for a success indication — either a success message or the return-to-login button let successText = app.staticTexts.containing( NSPredicate(format: "label CONTAINS[c] 'success' OR label CONTAINS[c] 'reset'") ).firstMatch let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton] // After reset, the app auto-logs in with the new password. // If auto-login succeeds → app goes to main tabs. If fails → return button appears. let tabBar = app.tabBars.firstMatch let deadline = Date().addingTimeInterval(loginTimeout) var reachedPostReset = false while Date() < deadline { if tabBar.exists { reachedPostReset = true break } if returnButton.exists { reachedPostReset = true returnButton.forceTap() break } RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button after password reset") if tabBar.exists { return } // Manual login fallback let loginScreen = LoginScreenObject(app: app) loginScreen.waitForLoad(timeout: loginTimeout) loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL loginScreen.enterPassword(newPassword) let loginButton = app.buttons[UITestID.Auth.loginButton] loginButton.waitUntilHittable(timeout: 10).tap() XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password") } // MARK: - AUTH-017: Mismatched passwords are blocked func testAUTH017_MismatchedPasswordBlocked() throws { let session = try XCTUnwrap(testSession) // Capture the recovery email ONCE for both the request and Mailpit lookup. let email = session.user.email // Navigate to forgot password let login = TestFlows.navigateToLoginFromOnboarding(app: app) login.tapForgotPassword() // Get to the reset password screen let forgotScreen = ForgotPasswordScreen(app: app) forgotScreen.waitForLoad() forgotScreen.enterEmail(email) forgotScreen.tapSendCode() // Read the REAL Kratos recovery code Kratos emailed to Mailpit. let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? "" XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)") let verifyScreen = VerifyResetCodeScreen(app: app) verifyScreen.waitForLoad() verifyScreen.enterCode(code) verifyScreen.tapVerify() // Enter mismatched passwords let resetScreen = ResetPasswordScreen(app: app) try resetScreen.waitForLoad(timeout: loginTimeout) resetScreen.enterNewPassword("ValidPass123!") resetScreen.enterConfirmPassword("DifferentPass456!") // The reset button should be disabled when passwords don't match XCTAssertFalse(resetScreen.isResetButtonEnabled, "Reset button should be disabled when passwords don't match") } // MARK: - Helpers /// Drive the full forgot-password → verify-code → reset-password UI flow using /// the REAL Kratos recovery code read from Mailpit. /// /// This is a local replacement for `TestFlows.completeForgotPasswordFlow`, which /// still hardcodes the obsolete `debugVerificationCode` ("123456"). The caller is /// expected to have already reached the forgot-password screen (e.g. via /// `login.tapForgotPassword()`). The `email` MUST be the same captured value used /// elsewhere in the test so the Mailpit lookup matches the address submitted. private func completeForgotPasswordFlowWithRealCode(email: String, newPassword: String) throws { // Step 1: Enter email and request the recovery code. let forgotScreen = ForgotPasswordScreen(app: app) forgotScreen.waitForLoad() forgotScreen.enterEmail(email) forgotScreen.tapSendCode() // Step 2: Read the REAL Kratos recovery code Kratos emailed to Mailpit. let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? "" XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)") let verifyScreen = VerifyResetCodeScreen(app: app) verifyScreen.waitForLoad() verifyScreen.enterCode(code) verifyScreen.tapVerify() // Step 3: Set the new password. let resetScreen = ResetPasswordScreen(app: app) try resetScreen.waitForLoad() resetScreen.enterNewPassword(newPassword) resetScreen.enterConfirmPassword(newPassword) resetScreen.tapReset() } }