c52ce4d497
Migrate the XCUITest suite off the legacy shared-account model (and the prior Django-style auth assumptions) to a parallel-safe, domain-organized architecture, validated end-to-end against the live Kratos stack. Isolation (parallel-safe by construction): - Core/Fixtures/TestAccount.swift: each test mints its own pre-verified Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds under its own token, and deletes the identity in teardown (cascading all data + clearing Kratos). No shared testuser; parallel workers no longer race. - AuthenticatedUITestCase rewritten to that model (member surface preserved); adds requiresResidence / seedAccountPreconditions to seed UI-gated data BEFORE login (a fresh account is empty at login). Organization (255 tests preserved, none dropped): - 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/ Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild naming chaos and the overlapping task/residence/auth suites. Runner + test plans: - run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The parallel phase runs the whole target minus phase-managed suites via -skip-testing, so new suites auto-include (no hand-maintained list to drift). Drops the 2-worker cap and Suite6 isolation (isolation made them moot). - HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan. Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos): real Mailpit verification codes replace the obsolete fixed "123456"; teardown deletes Kratos identities; admin-panel login uses the correct seeded password. Build green; isolation, parallelism, and the precondition/sharing migrations validated against the live stack (0 leaked accounts). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
412 lines
18 KiB
Swift
412 lines
18 KiB
Swift
import XCTest
|
||
|
||
/// Consolidated login authentication UI tests.
|
||
///
|
||
/// Merged from four legacy suites:
|
||
/// - SimpleLoginTest (basic login screen smoke tests)
|
||
/// - AuthCriticalPathTests (critical-path login / logout / entry navigation)
|
||
/// - AuthenticationTests (F201–F209 login-screen element + navigation checks)
|
||
/// - Suite2_AuthenticationRebuildTests (R201–R206 valid-credential landing / logout)
|
||
///
|
||
/// Logged-OUT suite: extends `BaseUITestCase` (no auth). Each test drives the
|
||
/// login / registration-entry / forgot-password screens itself via the existing
|
||
/// page objects and helpers (LoginScreenObject, UITestHelpers, TestFlows, …).
|
||
final class AuthLoginUITests: BaseUITestCase {
|
||
// Merged override: SimpleLogin and Suite2 both disabled reset-state; the
|
||
// other two relied on the default. Disabling reset-state is the safe union
|
||
// because every test here re-establishes its own starting screen state.
|
||
override var includeResetStateLaunchArgument: Bool { false }
|
||
// AuthCriticalPath, Authentication, and Suite2 all relaunched between tests.
|
||
override var relaunchBetweenTests: Bool { true }
|
||
// AuthCriticalPath, Authentication, and Suite2 all booted with onboarding
|
||
// completed so a successful login lands on the main tabs (a freshly-seeded
|
||
// user has no residence; without this it routes to onboarding).
|
||
override var completeOnboarding: Bool { true }
|
||
|
||
private let validUser = RebuildTestUserFactory.seeded
|
||
|
||
/// The seeded user's Kratos login identifier. Kratos keys honeyDue
|
||
/// identities by EMAIL, and the app sends the typed identifier straight to
|
||
/// Kratos (AuthApi.login), so login must use the email — not the bare
|
||
/// display username.
|
||
private let seededLoginIdentifier = "testuser@honeydue.com"
|
||
|
||
private enum AuthLandingState {
|
||
case main
|
||
case verification
|
||
}
|
||
|
||
override func setUpWithError() throws {
|
||
// Force a clean app launch so no stale field text persists between tests
|
||
// (union of SimpleLogin + Suite2 setUp behavior).
|
||
app.terminate()
|
||
try super.setUpWithError()
|
||
|
||
// CRITICAL: Ensure we're logged out before each test
|
||
UITestHelpers.ensureLoggedOut(app: app)
|
||
}
|
||
|
||
override func tearDownWithError() throws {
|
||
try super.tearDownWithError()
|
||
}
|
||
|
||
// MARK: - Helpers (from SimpleLoginTest)
|
||
|
||
/// Ensures the user is logged out and on the login screen
|
||
private func ensureLoggedOut() {
|
||
UITestHelpers.ensureLoggedOut(app: app)
|
||
}
|
||
|
||
// MARK: - Helpers (from AuthCriticalPathTests)
|
||
|
||
private func navigateToLogin() {
|
||
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||
if loginField.waitForExistence(timeout: defaultTimeout) { return }
|
||
|
||
// On onboarding — tap login button
|
||
let onboardingLogin = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
|
||
if onboardingLogin.waitForExistence(timeout: navigationTimeout) {
|
||
onboardingLogin.tap()
|
||
}
|
||
|
||
loginField.waitForExistenceOrFail(timeout: navigationTimeout, message: "Login screen should appear")
|
||
}
|
||
|
||
private func loginAsTestUser() {
|
||
navigateToLogin()
|
||
|
||
let login = LoginScreenObject(app: app)
|
||
// Kratos uses the EMAIL as the login identifier (no username trait).
|
||
login.enterUsername("testuser@honeydue.com")
|
||
login.enterPassword("TestPass123!")
|
||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].tap()
|
||
|
||
// Wait for main app or verification gate
|
||
let tabBar = app.tabBars.firstMatch
|
||
let verification = VerificationScreen(app: app)
|
||
|
||
let deadline = Date().addingTimeInterval(loginTimeout)
|
||
while Date() < deadline {
|
||
if tabBar.exists { return }
|
||
if verification.codeField.exists {
|
||
verification.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||
verification.submitCode()
|
||
_ = tabBar.waitForExistence(timeout: loginTimeout)
|
||
return
|
||
}
|
||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||
}
|
||
|
||
XCTAssertTrue(tabBar.exists, "Should reach main app after login")
|
||
}
|
||
|
||
// MARK: - Helpers (from Suite2_AuthenticationRebuildTests)
|
||
|
||
private func loginFromLoginScreen(user: RebuildTestUser = RebuildTestUserFactory.seeded) {
|
||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||
let login = LoginScreenObject(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
login.enterUsername(seededLoginIdentifier)
|
||
login.enterPassword(user.password)
|
||
|
||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||
loginButton.forceTap()
|
||
}
|
||
|
||
@discardableResult
|
||
private func loginAndWaitForAuthenticatedLanding(user: RebuildTestUser = RebuildTestUserFactory.seeded) -> AuthLandingState {
|
||
loginFromLoginScreen(user: user)
|
||
|
||
let mainRoot = app.otherElements[UITestID.Root.mainTabs]
|
||
if mainRoot.waitForExistence(timeout: loginTimeout) || app.tabBars.firstMatch.waitForExistence(timeout: 2) {
|
||
return .main
|
||
}
|
||
|
||
let verification = VerificationScreen(app: app)
|
||
if verification.codeField.waitForExistence(timeout: defaultTimeout) || verification.verifyButton.waitForExistence(timeout: 2) {
|
||
return .verification
|
||
}
|
||
|
||
XCTFail("Expected authenticated landing on main tabs or verification screen")
|
||
return .verification
|
||
}
|
||
|
||
private func logoutFromVerificationIfNeeded() {
|
||
let verification = VerificationScreen(app: app)
|
||
verification.waitForLoad(timeout: defaultTimeout)
|
||
verification.tapLogoutIfAvailable()
|
||
|
||
let toolbarLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
|
||
if toolbarLogout.waitForExistence(timeout: 3) {
|
||
toolbarLogout.forceTap()
|
||
}
|
||
}
|
||
|
||
private func logoutFromMainApp() {
|
||
UITestHelpers.logout(app: app)
|
||
}
|
||
|
||
// MARK: - Tests (from SimpleLoginTest)
|
||
|
||
/// Test 1: App launches and shows login screen (or logs out if needed)
|
||
func testAppLaunchesAndShowsLoginScreen() {
|
||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||
XCTAssertTrue(usernameField.exists, "Username field should be visible on login screen after logout")
|
||
}
|
||
|
||
/// Test 2: Can type in username and password fields
|
||
func testCanTypeInLoginFields() {
|
||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||
usernameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Username field should exist on login screen")
|
||
usernameField.focusAndType("testuser", app: app)
|
||
|
||
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField].exists
|
||
? app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||
: app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||
XCTAssertTrue(passwordField.exists, "Password field should exist on login screen")
|
||
passwordField.focusAndType("testpass123", app: app)
|
||
|
||
let signInButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||
XCTAssertTrue(signInButton.exists, "Login button should exist on login screen")
|
||
}
|
||
|
||
// MARK: - Tests (from AuthCriticalPathTests)
|
||
|
||
// MARK: Login
|
||
|
||
func testLoginWithValidCredentials() {
|
||
loginAsTestUser()
|
||
XCTAssertTrue(app.tabBars.firstMatch.exists, "Tab bar should be visible after login")
|
||
}
|
||
|
||
func testLoginWithInvalidCredentials() {
|
||
navigateToLogin()
|
||
|
||
let login = LoginScreenObject(app: app)
|
||
login.enterUsername("invaliduser")
|
||
login.enterPassword("wrongpassword")
|
||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].tap()
|
||
|
||
// Should stay on login screen
|
||
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||
XCTAssertTrue(loginField.waitForExistence(timeout: navigationTimeout), "Should remain on login screen after invalid credentials")
|
||
XCTAssertFalse(app.tabBars.firstMatch.exists, "Tab bar should not appear after failed login")
|
||
}
|
||
|
||
// MARK: Logout
|
||
|
||
func testLogoutFlow() {
|
||
loginAsTestUser()
|
||
UITestHelpers.logout(app: app)
|
||
|
||
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||
let onboardingLogin = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
|
||
let loggedOut = loginField.waitForExistence(timeout: loginTimeout)
|
||
|| onboardingLogin.waitForExistence(timeout: navigationTimeout)
|
||
XCTAssertTrue(loggedOut, "Should return to login or onboarding after logout")
|
||
}
|
||
|
||
// MARK: Registration Entry
|
||
|
||
func testSignUpButtonNavigatesToRegistration() {
|
||
navigateToLogin()
|
||
app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].tap()
|
||
|
||
let registerUsername = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||
XCTAssertTrue(registerUsername.waitForExistence(timeout: navigationTimeout), "Registration form should appear")
|
||
}
|
||
|
||
// MARK: Forgot Password
|
||
|
||
func testForgotPasswordButtonExists() {
|
||
navigateToLogin()
|
||
let forgotButton = app.buttons[AccessibilityIdentifiers.Authentication.forgotPasswordButton]
|
||
XCTAssertTrue(forgotButton.waitForExistence(timeout: defaultTimeout), "Forgot password button should exist")
|
||
}
|
||
|
||
// MARK: - Tests (from AuthenticationTests)
|
||
|
||
func testF201_OnboardingLoginEntryShowsLoginScreen() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
}
|
||
|
||
func testF202_LoginScreenCanTogglePasswordVisibility() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.enterUsername("u")
|
||
login.enterPassword("p")
|
||
login.tapPasswordVisibilityToggle()
|
||
login.assertPasswordFieldVisible()
|
||
}
|
||
|
||
func testF203_RegisterSheetCanOpenAndDismiss() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
login.tapSignUp()
|
||
|
||
let register = RegisterScreenObject(app: app)
|
||
register.waitForLoad(timeout: navigationTimeout)
|
||
register.tapCancel()
|
||
|
||
login.waitForLoad(timeout: navigationTimeout)
|
||
}
|
||
|
||
func testF204_RegisterFormAcceptsInput() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
login.tapSignUp()
|
||
|
||
let register = RegisterScreenObject(app: app)
|
||
register.waitForLoad(timeout: navigationTimeout)
|
||
|
||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist on register form")
|
||
}
|
||
|
||
func testF205_LoginButtonDisabledWhenCredentialsAreEmpty() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
|
||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||
XCTAssertFalse(loginButton.isEnabled, "Login button should be disabled when username/password are empty")
|
||
}
|
||
|
||
func testF206_ForgotPasswordButtonIsAccessible() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
|
||
let forgotButton = app.buttons[UITestID.Auth.forgotPasswordButton]
|
||
forgotButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||
XCTAssertTrue(forgotButton.isHittable, "Forgot password button should be hittable on login screen")
|
||
}
|
||
|
||
func testF207_LoginScreenShowsAllExpectedElements() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
|
||
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Username field should exist")
|
||
XCTAssertTrue(
|
||
app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists,
|
||
"Password field should exist"
|
||
)
|
||
XCTAssertTrue(app.buttons[UITestID.Auth.loginButton].exists, "Login button should exist")
|
||
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists, "Sign up button should exist")
|
||
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists, "Forgot password button should exist")
|
||
XCTAssertTrue(app.buttons[UITestID.Auth.passwordVisibilityToggle].exists, "Password visibility toggle should exist")
|
||
}
|
||
|
||
func testF208_RegisterFormShowsAllRequiredFields() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
login.tapSignUp()
|
||
|
||
let register = RegisterScreenObject(app: app)
|
||
register.waitForLoad(timeout: navigationTimeout)
|
||
|
||
XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists, "Register username field should exist")
|
||
XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists, "Register email field should exist")
|
||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists, "Register password field should exist")
|
||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists, "Register confirm password field should exist")
|
||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist")
|
||
XCTAssertTrue(app.buttons[UITestID.Auth.registerCancelButton].exists, "Register cancel button should exist")
|
||
}
|
||
|
||
func testF209_ForgotPasswordNavigatesToResetFlow() {
|
||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
login.tapForgotPassword()
|
||
|
||
// Verify forgot password screen loaded by checking for its email field (accessibility ID, not label)
|
||
let emailField = app.textFields[UITestID.PasswordReset.emailField]
|
||
let sendCodeButton = app.buttons[UITestID.PasswordReset.sendCodeButton]
|
||
let loaded = emailField.waitForExistence(timeout: navigationTimeout)
|
||
|| sendCodeButton.waitForExistence(timeout: navigationTimeout)
|
||
XCTAssertTrue(loaded, "Forgot password screen should appear with email field or send code button")
|
||
}
|
||
|
||
// MARK: - Tests (from Suite2_AuthenticationRebuildTests)
|
||
|
||
func testR201_loginScreenLoadsFromOnboardingEntry() {
|
||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||
let login = LoginScreenObject(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
}
|
||
|
||
func testR202_validCredentialsSubmitFromLogin() {
|
||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||
let login = LoginScreenObject(app: app)
|
||
login.waitForLoad(timeout: defaultTimeout)
|
||
|
||
login.enterUsername(seededLoginIdentifier)
|
||
login.enterPassword(validUser.password)
|
||
|
||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||
XCTAssertTrue(loginButton.waitForExistence(timeout: defaultTimeout), "Login button must exist before submit")
|
||
XCTAssertTrue(loginButton.isHittable, "Login button must be tappable")
|
||
}
|
||
|
||
func testR203_validLoginTransitionsToMainAppRoot() {
|
||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||
switch landing {
|
||
case .main:
|
||
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
|
||
case .verification:
|
||
RebuildSessionAssertions.assertOnVerification(app, timeout: loginTimeout)
|
||
}
|
||
}
|
||
|
||
func testR204_mainAppHasExpectedPrimaryTabsAfterLogin() {
|
||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||
|
||
switch landing {
|
||
case .main:
|
||
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
|
||
|
||
let tabBar = app.tabBars.firstMatch
|
||
if tabBar.waitForExistence(timeout: 5) {
|
||
let residences = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||
let tasks = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||
let contractors = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||
let docs = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Doc'")).firstMatch
|
||
XCTAssertTrue(residences.exists, "Residences tab should exist")
|
||
XCTAssertTrue(tasks.exists, "Tasks tab should exist")
|
||
XCTAssertTrue(contractors.exists, "Contractors tab should exist")
|
||
XCTAssertTrue(docs.exists, "Documents tab should exist")
|
||
} else {
|
||
XCTAssertTrue(app.otherElements[UITestID.Root.mainTabs].exists, "Main tabs root should exist")
|
||
}
|
||
case .verification:
|
||
let verify = VerificationScreen(app: app)
|
||
verify.waitForLoad(timeout: defaultTimeout)
|
||
XCTAssertTrue(verify.codeField.exists, "Verification code field should exist for unverified accounts")
|
||
}
|
||
}
|
||
|
||
func testR205_logoutFromMainAppReturnsToLoginRoot() {
|
||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||
|
||
switch landing {
|
||
case .main:
|
||
logoutFromMainApp()
|
||
case .verification:
|
||
logoutFromVerificationIfNeeded()
|
||
}
|
||
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
|
||
}
|
||
|
||
func testR206_postLogoutMainAppIsNoLongerAccessible() {
|
||
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
|
||
|
||
switch landing {
|
||
case .main:
|
||
logoutFromMainApp()
|
||
case .verification:
|
||
logoutFromVerificationIfNeeded()
|
||
}
|
||
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
|
||
|
||
XCTAssertFalse(app.otherElements[UITestID.Root.mainTabs].exists, "Main app root should not be visible after logout")
|
||
}
|
||
}
|