Re-architect iOS XCUITest suite: per-test isolation + domain organization

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>
This commit is contained in:
Trey T
2026-06-05 16:26:50 -05:00
parent 09120e9d9d
commit c52ce4d497
44 changed files with 3824 additions and 3057 deletions
@@ -47,21 +47,6 @@ struct TestAuthResponse: Decodable {
let message: String?
}
struct TestVerifyEmailResponse: Decodable {
let message: String
let verified: Bool
}
struct TestVerifyResetCodeResponse: Decodable {
let message: String
let resetToken: String
enum CodingKeys: String, CodingKey {
case message
case resetToken = "reset_token"
}
}
struct TestMessageResponse: Decodable {
let message: String
}
@@ -206,64 +191,313 @@ enum TestAccountAPIClient {
static let baseURL = "http://127.0.0.1:8000/api"
static let debugVerificationCode = "123456"
// MARK: - Auth Methods
// MARK: - Kratos Configuration
/// Kratos public API (self-service login/registration flows).
static let kratosPublicURL = "http://127.0.0.1:4433"
/// Kratos admin API (create pre-verified identities directly).
static let kratosAdminURL = "http://127.0.0.1:4434"
/// Identity schema id registered in Kratos for this app.
static let kratosSchemaID = "honeydue"
// MARK: - Kratos Auth Primitives
/// Create a Kratos identity via the ADMIN API.
/// When `verified` is true the email's verifiable address is marked
/// completed/verified; when false it is left pending/unverified (mirrors a
/// freshly-registered account that has not confirmed its email yet).
/// Returns true on 201 (created) or 409 (already exists idempotent).
static func createKratosIdentity(email: String, password: String, firstName: String, lastName: String, verified: Bool = true) -> Bool {
guard let url = URL(string: "\(kratosAdminURL)/admin/identities") else { return false }
let verifiableAddress: [String: Any] = verified
? ["value": email, "verified": true, "via": "email", "status": "completed"]
: ["value": email, "verified": false, "via": "email", "status": "pending"]
static func register(username: String, email: String, password: String) -> TestAuthResponse? {
let body: [String: Any] = [
"username": username,
"email": email,
"schema_id": kratosSchemaID,
"traits": [
"email": email,
"name": ["first": firstName, "last": lastName]
],
"credentials": [
"password": ["config": ["password": password]]
],
"verifiable_addresses": [verifiableAddress],
"state": "active"
]
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 15
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
let semaphore = DispatchSemaphore(value: 0)
var success = false
let task = URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("[Kratos] createIdentity error: \(error.localizedDescription)")
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
// 201 = created, 409 = already exists (idempotent success)
if status == 201 || status == 409 {
success = true
} else {
let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) } ?? "<nil>"
print("[Kratos] createIdentity status \(status): \(bodyStr)")
}
}
task.resume()
if semaphore.wait(timeout: .now() + 30) == .timedOut {
print("[Kratos] createIdentity TIMEOUT")
task.cancel()
return false
}
return success
}
/// Perform a Kratos self-service login (API flow) and return the session token, or nil.
static func kratosLogin(email: String, password: String) -> String? {
// Step 1: GET the login flow to discover the action URL.
guard let flowURL = URL(string: "\(kratosPublicURL)/self-service/login/api") else { return nil }
var flowRequest = URLRequest(url: flowURL)
flowRequest.httpMethod = "GET"
flowRequest.setValue("application/json", forHTTPHeaderField: "Accept")
flowRequest.timeoutInterval = 15
let flowSemaphore = DispatchSemaphore(value: 0)
var actionURLString: String?
let flowTask = URLSession.shared.dataTask(with: flowRequest) { data, response, error in
defer { flowSemaphore.signal() }
if let error = error {
print("[Kratos] login flow error: \(error.localizedDescription)")
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let data = data else {
print("[Kratos] login flow no data (status \(status))")
return
}
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ui = json["ui"] as? [String: Any],
let action = ui["action"] as? String
else {
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
print("[Kratos] login flow parse failed (status \(status)): \(bodyStr)")
return
}
actionURLString = action
}
flowTask.resume()
if flowSemaphore.wait(timeout: .now() + 30) == .timedOut {
print("[Kratos] login flow TIMEOUT")
flowTask.cancel()
return nil
}
guard let actionURLString = actionURLString, let actionURL = URL(string: actionURLString) else {
return nil
}
// Step 2: POST credentials to the action URL to obtain a session token.
let body: [String: Any] = [
"method": "password",
"identifier": email,
"password": password
]
return performRequest(method: "POST", path: "/auth/register/", body: body, responseType: TestAuthResponse.self)
var loginRequest = URLRequest(url: actionURL)
loginRequest.httpMethod = "POST"
loginRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
loginRequest.setValue("application/json", forHTTPHeaderField: "Accept")
loginRequest.timeoutInterval = 15
loginRequest.httpBody = try? JSONSerialization.data(withJSONObject: body)
let loginSemaphore = DispatchSemaphore(value: 0)
var sessionToken: String?
let loginTask = URLSession.shared.dataTask(with: loginRequest) { data, response, error in
defer { loginSemaphore.signal() }
if let error = error {
print("[Kratos] login error: \(error.localizedDescription)")
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let data = data else {
print("[Kratos] login no data (status \(status))")
return
}
// Kratos returns 200 on success, 400 on bad credentials.
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["session_token"] as? String
else {
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
print("[Kratos] login no session_token (status \(status)): \(bodyStr)")
return
}
sessionToken = token
}
loginTask.resume()
if loginSemaphore.wait(timeout: .now() + 30) == .timedOut {
print("[Kratos] login TIMEOUT")
loginTask.cancel()
return nil
}
return sessionToken
}
// MARK: - Auth Methods
/// Log in via Kratos. The `username` parameter is treated as the Kratos
/// identifier i.e. the account EMAIL. Returns a TestAuthResponse carrying
/// the Kratos session token and the provisioned API user, or nil on failure.
static func login(username: String, password: String) -> TestAuthResponse? {
let body: [String: Any] = ["username": username, "password": password]
return performRequest(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
}
static func verifyEmail(token: String) -> TestVerifyEmailResponse? {
let body: [String: Any] = ["code": debugVerificationCode]
return performRequest(method: "POST", path: "/auth/verify-email/", body: body, token: token, responseType: TestVerifyEmailResponse.self)
guard let token = kratosLogin(email: username, password: password) else { return nil }
guard let user = getCurrentUser(token: token) else { return nil }
return TestAuthResponse(token: token, user: user, message: nil)
}
static func getCurrentUser(token: String) -> TestUser? {
return performRequest(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
}
static func forgotPassword(email: String) -> TestMessageResponse? {
let body: [String: Any] = ["email": email]
return performRequest(method: "POST", path: "/auth/forgot-password/", body: body, responseType: TestMessageResponse.self)
}
static func verifyResetCode(email: String) -> TestVerifyResetCodeResponse? {
let body: [String: Any] = ["email": email, "code": debugVerificationCode]
return performRequest(method: "POST", path: "/auth/verify-reset-code/", body: body, responseType: TestVerifyResetCodeResponse.self)
}
static func resetPassword(resetToken: String, newPassword: String) -> TestMessageResponse? {
let body: [String: Any] = ["reset_token": resetToken, "new_password": newPassword]
return performRequest(method: "POST", path: "/auth/reset-password/", body: body, responseType: TestMessageResponse.self)
}
static func logout(token: String) -> TestMessageResponse? {
return performRequest(method: "POST", path: "/auth/logout/", token: token, responseType: TestMessageResponse.self)
}
/// Convenience: register + verify + re-login, returns ready session.
/// Convenience: provision a pre-verified Kratos identity, log in, and fetch
/// the provisioned API user. Returns a ready-to-use session, or nil on failure.
///
/// `username` is used as the identity's first name (and retained on the
/// returned session for reference); the Kratos identifier is the `email`.
static func createVerifiedAccount(username: String, email: String, password: String) -> TestSession? {
guard let registerResponse = register(username: username, email: email, password: password) else { return nil }
guard verifyEmail(token: registerResponse.token) != nil else { return nil }
guard let loginResponse = login(username: username, password: password) else { return nil }
return TestSession(token: loginResponse.token, user: loginResponse.user, username: username, password: password)
guard createKratosIdentity(email: email, password: password, firstName: username, lastName: "Test") else { return nil }
guard let token = kratosLogin(email: email, password: password) else { return nil }
guard let user = getCurrentUser(token: token) else { return nil }
return TestSession(token: token, user: user, username: username, password: password)
}
/// Convenience: provision an UNVERIFIED Kratos identity (no email confirmed),
/// log in, and fetch the lazily-provisioned API user. Mirrors
/// `createVerifiedAccount` but leaves the email address unverified so callers
/// can exercise the verification gate. Returns a ready-to-use session, or nil.
static func createUnverifiedAccount(username: String, email: String, password: String) -> TestSession? {
guard createKratosIdentity(email: email, password: password, firstName: username, lastName: "Test", verified: false) else { return nil }
guard let token = kratosLogin(email: email, password: password) else { return nil }
guard let user = getCurrentUser(token: token) else { return nil }
return TestSession(token: token, user: user, username: username, password: password)
}
/// Delete a Kratos identity by its login email via the ADMIN API (true teardown).
/// Looks up the identity by `credentials_identifier`, then DELETEs it.
/// Returns true if the identity was deleted (204) OR no identity exists
/// (already gone idempotent success). Returns false only on a real failure.
static func deleteKratosIdentity(email: String) -> Bool {
let encoded = email.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? email
guard let lookupURL = URL(string: "\(kratosAdminURL)/admin/identities?credentials_identifier=\(encoded)") else {
print("[Kratos] deleteIdentity invalid lookup URL for \(email)")
return false
}
// Step 1: find the identity id by email.
var lookupRequest = URLRequest(url: lookupURL)
lookupRequest.httpMethod = "GET"
lookupRequest.setValue("application/json", forHTTPHeaderField: "Accept")
lookupRequest.timeoutInterval = 15
let lookupSemaphore = DispatchSemaphore(value: 0)
var identityID: String?
var lookupFound = false
let lookupTask = URLSession.shared.dataTask(with: lookupRequest) { data, response, error in
defer { lookupSemaphore.signal() }
if let error = error {
print("[Kratos] deleteIdentity lookup error: \(error.localizedDescription)")
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let data = data else {
print("[Kratos] deleteIdentity lookup no data (status \(status))")
return
}
guard let identities = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
print("[Kratos] deleteIdentity lookup parse failed (status \(status)): \(bodyStr)")
return
}
lookupFound = true
identityID = identities.first?["id"] as? String
}
lookupTask.resume()
if lookupSemaphore.wait(timeout: .now() + 30) == .timedOut {
print("[Kratos] deleteIdentity lookup TIMEOUT")
lookupTask.cancel()
return false
}
// No identity found (empty array) already gone, idempotent success.
guard let id = identityID else {
return lookupFound
}
// Step 2: DELETE the identity.
guard let deleteURL = URL(string: "\(kratosAdminURL)/admin/identities/\(id)") else {
print("[Kratos] deleteIdentity invalid delete URL for id \(id)")
return false
}
var deleteRequest = URLRequest(url: deleteURL)
deleteRequest.httpMethod = "DELETE"
deleteRequest.setValue("application/json", forHTTPHeaderField: "Accept")
deleteRequest.timeoutInterval = 15
let deleteSemaphore = DispatchSemaphore(value: 0)
var success = false
let deleteTask = URLSession.shared.dataTask(with: deleteRequest) { data, response, error in
defer { deleteSemaphore.signal() }
if let error = error {
print("[Kratos] deleteIdentity error: \(error.localizedDescription)")
return
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
// 204 = deleted, 404 = already gone (idempotent success).
if status == 204 || status == 404 {
success = true
} else {
let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) } ?? "<nil>"
print("[Kratos] deleteIdentity status \(status): \(bodyStr)")
}
}
deleteTask.resume()
if deleteSemaphore.wait(timeout: .now() + 30) == .timedOut {
print("[Kratos] deleteIdentity TIMEOUT")
deleteTask.cancel()
return false
}
return success
}
// MARK: - Auth with Status Code
/// Login returning full APIResult so callers can assert on 401, 400, etc.
/// Login returning full APIResult so callers can assert on success/failure.
/// `username` is treated as the Kratos identifier (the EMAIL). On a failed
/// Kratos login (Kratos returns 400 on bad creds) this maps to statusCode 401
/// so negative-path assertions that expect an unauthorized result still hold.
static func loginWithResult(username: String, password: String) -> APIResult<TestAuthResponse> {
let body: [String: Any] = ["username": username, "password": password]
return performRequestWithResult(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
guard let token = kratosLogin(email: username, password: password) else {
return APIResult(data: nil, statusCode: 401, errorBody: "Kratos login failed")
}
guard let user = getCurrentUser(token: token) else {
return APIResult(data: nil, statusCode: 401, errorBody: "Failed to fetch current user after login")
}
let response = TestAuthResponse(token: token, user: user, message: nil)
return APIResult(data: response, statusCode: 200, errorBody: nil)
}
/// Hit a protected endpoint without a token to get the 401.
@@ -475,7 +709,7 @@ enum TestAccountAPIClient {
request.timeoutInterval = 15
if let token = token {
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
}
if let body = body {
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
@@ -503,11 +737,84 @@ enum TestAccountAPIClient {
return result
}
// MARK: - Mailpit (real email verification codes)
/// Mailpit web/API base for the local stack.
static let mailpitURL = "http://127.0.0.1:8025"
/// Fetch the most recent 6-digit verification code Kratos emailed to `email`.
/// The app's onboarding registration uses Kratos's real verification flow
/// (not the API's DEBUG fixed code), so onboarding tests must read the live
/// code from Mailpit. Polls briefly because the email lands asynchronously.
static func latestVerificationCode(for email: String, timeout: TimeInterval = 15) -> String? {
let deadline = Date().addingTimeInterval(timeout)
let lowered = email.lowercased()
while Date() < deadline {
if let code = fetchLatestCodeOnce(for: lowered) { return code }
Thread.sleep(forTimeInterval: 1.0)
}
return nil
}
private static func fetchLatestCodeOnce(for loweredEmail: String) -> String? {
guard let url = URL(string: "\(mailpitURL)/api/v1/search?query=to:\(loweredEmail)&limit=5") else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = 10
request.setValue("application/json", forHTTPHeaderField: "Accept")
let semaphore = DispatchSemaphore(value: 0)
var messageID: String?
let task = URLSession.shared.dataTask(with: request) { data, _, _ in
defer { semaphore.signal() }
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let messages = json["messages"] as? [[String: Any]] else { return }
// Messages are newest-first; pick the first addressed to this email.
for m in messages {
let tos = (m["To"] as? [[String: Any]])?.compactMap { ($0["Address"] as? String)?.lowercased() } ?? []
if tos.contains(loweredEmail) {
messageID = m["ID"] as? String
break
}
}
}
task.resume()
_ = semaphore.wait(timeout: .now() + 15)
guard let id = messageID else { return nil }
return extractCode(messageID: id)
}
private static func extractCode(messageID: String) -> String? {
guard let url = URL(string: "\(mailpitURL)/api/v1/message/\(messageID)") else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = 10
request.setValue("application/json", forHTTPHeaderField: "Accept")
let semaphore = DispatchSemaphore(value: 0)
var code: String?
let task = URLSession.shared.dataTask(with: request) { data, _, _ in
defer { semaphore.signal() }
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
let text = (json["Text"] as? String ?? "") + " " + (json["HTML"] as? String ?? "")
// The Kratos verification email presents a standalone 6-digit code.
if let range = text.range(of: "\\b\\d{6}\\b", options: .regularExpression) {
code = String(text[range])
}
}
task.resume()
_ = semaphore.wait(timeout: .now() + 15)
return code
}
// MARK: - Reachability
static func isBackendReachable() -> Bool {
let result = rawRequest(method: "POST", path: "/auth/login/", body: [:])
// Any HTTP response (even 400) means the backend is up
// Probe a live endpoint with no token. The backend returns 401
// (unauthenticated) when it's up any HTTP response means reachable.
let result = rawRequest(method: "GET", path: "/auth/me/")
// statusCode 0 means the connection failed; anything else (incl. 401) is up.
return result.statusCode > 0
}
@@ -543,7 +850,7 @@ enum TestAccountAPIClient {
request.timeoutInterval = 15
if let token = token {
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
request.setValue(token, forHTTPHeaderField: "X-Session-Token")
}
if let body = body {