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:
@@ -11,12 +11,64 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
|
||||
var needsAPISession: Bool { false }
|
||||
|
||||
/// Authenticated suites test the post-onboarding app. A freshly-seeded user
|
||||
/// has no residence, so without this the app routes to the onboarding flow
|
||||
/// after login instead of the main tabs. Launch with --complete-onboarding
|
||||
/// (sets OnboardingState.hasCompletedOnboarding) so login lands on main tabs.
|
||||
override var completeOnboarding: Bool { true }
|
||||
|
||||
/// Credentials for the Kratos APP identity used to seed data over the API.
|
||||
///
|
||||
/// ⚠️ TWO DIFFERENT "admin@honeydue.com" EXIST — do NOT "fix" Test1234 to password123:
|
||||
/// (a) Kratos APP identity — admin@honeydue.com / Test1234. Created by this class's
|
||||
/// `setUp` (and re-seeded by SuiteZZ). Used here for API data-seeding and login.
|
||||
/// (b) Admin-PANEL SQL super-admin — admin@honeydue.com / password123. A separate
|
||||
/// system, used ONLY by SuiteZZ_CleanupTests to call /admin/settings/clear-all-data.
|
||||
/// They happen to share an email but are unrelated. Changing Test1234 here would break
|
||||
/// all API seeding; changing password123 in SuiteZZ would break the data wipe.
|
||||
var apiCredentials: (username: String, password: String) {
|
||||
("admin", "Test1234")
|
||||
}
|
||||
|
||||
// MARK: - Account isolation
|
||||
|
||||
/// When `true` (default), each test mints its OWN unique, pre-verified
|
||||
/// Kratos account, logs in as it, seeds under its token, and deletes it in
|
||||
/// teardown — so suites are fully independent and parallel-safe. Override to
|
||||
/// `false` only in suites that must log in as a SPECIFIC seeded account
|
||||
/// (then also override `testCredentials`).
|
||||
var usesFreshAccount: Bool { true }
|
||||
|
||||
/// Short slug used in generated account emails (uit_<domain>_<uuid>@...),
|
||||
/// cosmetic for debugging. Defaults to the test class name.
|
||||
var accountDomain: String { String(describing: type(of: self)) }
|
||||
|
||||
/// The per-test isolated account (non-nil in fresh-account mode).
|
||||
private(set) var account: TestAccount?
|
||||
|
||||
/// Set `true` in suites whose UI gates on a residence existing (e.g. task
|
||||
/// or document creation). Seeds one residence BEFORE login so the app loads
|
||||
/// it on its post-login fetch; available to the test body as `seededResidence`.
|
||||
var requiresResidence: Bool { false }
|
||||
|
||||
/// The residence seeded as a precondition (when `requiresResidence`).
|
||||
private(set) var seededResidence: TestResidence?
|
||||
|
||||
/// Seed baseline data the UI gates on for this test's fresh account, BEFORE
|
||||
/// the app logs in (a fresh account is otherwise empty, so anything seeded
|
||||
/// after login is invisible until a manual refresh). Override to seed a full
|
||||
/// scenario (residence + tasks/documents); call `super` to keep the
|
||||
/// `requiresResidence` convenience.
|
||||
func seedAccountPreconditions(_ account: TestAccount) {
|
||||
if requiresResidence {
|
||||
seededResidence = account.seedResidence(name: "Precondition Home")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Session
|
||||
|
||||
/// The authenticated session used for API seeding. In fresh-account mode
|
||||
/// this is the test's own account; in legacy mode it's `apiCredentials`.
|
||||
private(set) var session: TestSession!
|
||||
private(set) var cleaner: TestDataCleaner!
|
||||
|
||||
@@ -25,11 +77,16 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
override class func setUp() {
|
||||
super.setUp()
|
||||
guard TestAccountAPIClient.isBackendReachable() else { return }
|
||||
// Ensure both known test accounts exist (covers all subclass credential overrides)
|
||||
if TestAccountAPIClient.login(username: "testuser", password: "TestPass123!") == nil {
|
||||
// Ensure both known test accounts exist (covers all subclass credential overrides).
|
||||
// Kratos uses the EMAIL as the login identifier, so log in by email.
|
||||
// NOTE: the admin@honeydue.com / Test1234 created here is the Kratos APP identity
|
||||
// (system (a) in the `apiCredentials` doc above) — NOT the admin-panel SQL
|
||||
// super-admin (admin@honeydue.com / password123) that SuiteZZ uses for the data
|
||||
// wipe. Same email, separate systems; keep Test1234 here.
|
||||
if TestAccountAPIClient.login(username: "testuser@honeydue.com", password: "TestPass123!") == nil {
|
||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "testuser", email: "testuser@honeydue.com", password: "TestPass123!")
|
||||
}
|
||||
if TestAccountAPIClient.login(username: "admin", password: "Test1234") == nil {
|
||||
if TestAccountAPIClient.login(username: "admin@honeydue.com", password: "Test1234") == nil {
|
||||
_ = TestAccountAPIClient.createVerifiedAccount(username: "admin", email: "admin@honeydue.com", password: "Test1234")
|
||||
}
|
||||
}
|
||||
@@ -58,31 +115,45 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
|
||||
try super.setUpWithError()
|
||||
|
||||
if usesFreshAccount {
|
||||
// Per-test isolation: every test logs in as its OWN fresh, pre-verified
|
||||
// account, seeds under its token, and deletes it in teardown. The app
|
||||
// may be reused from a previous test (still logged in as that test's
|
||||
// account), so always log out first.
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
let acct = TestAccount.create(domain: accountDomain)
|
||||
account = acct
|
||||
session = acct.session
|
||||
cleaner = TestDataCleaner(token: acct.token)
|
||||
// Seed UI-gated baseline data BEFORE login so the app loads it on
|
||||
// its post-login fetch (a fresh account is otherwise empty).
|
||||
seedAccountPreconditions(acct)
|
||||
acct.login(into: app, timeout: loginTimeout)
|
||||
waitForMainApp()
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy path: log in as a SPECIFIC seeded account (testCredentials),
|
||||
// optionally opening a separate API session (apiCredentials).
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let alreadyLoggedIn = tabBar.waitForExistence(timeout: defaultTimeout)
|
||||
|
||||
// Force-fresh path: log out (if needed) and re-authenticate per
|
||||
// test so every test starts with a freshly-issued JWT. Catches
|
||||
// server-side token invalidation that would otherwise surface
|
||||
// mid-suite as opaque 401s on the first mutation call.
|
||||
if forceFreshLoginPerTest {
|
||||
if alreadyLoggedIn {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
} else {
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
}
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
loginToMainApp()
|
||||
} else if !alreadyLoggedIn {
|
||||
// Legacy session-reuse path: only log in when not already in.
|
||||
UITestHelpers.ensureLoggedOut(app: app)
|
||||
loginToMainApp()
|
||||
}
|
||||
// (When `forceFreshLoginPerTest == false` AND we're already
|
||||
// logged in, fall through with the existing session.)
|
||||
|
||||
if needsAPISession {
|
||||
// Kratos uses the EMAIL as the login identifier. Subclasses still
|
||||
// declare seeded `apiCredentials` by short username (e.g. "admin"),
|
||||
// so normalize bare usernames to their "<username>@honeydue.com" email.
|
||||
let identifier = apiCredentials.username.contains("@")
|
||||
? apiCredentials.username
|
||||
: "\(apiCredentials.username)@honeydue.com"
|
||||
guard let apiSession = TestAccountManager.loginSeededAccount(
|
||||
username: apiCredentials.username,
|
||||
username: identifier,
|
||||
password: apiCredentials.password
|
||||
) else {
|
||||
XCTFail("Could not login API account '\(apiCredentials.username)'")
|
||||
@@ -94,7 +165,14 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
cleaner?.cleanAll()
|
||||
// Deleting the per-test account cascades all of its data and clears the
|
||||
// Kratos identity in one call. In legacy mode there's no account, so
|
||||
// fall back to tracked-resource cleanup.
|
||||
if let account {
|
||||
account.delete()
|
||||
} else {
|
||||
cleaner?.cleanAll()
|
||||
}
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
@@ -107,7 +185,13 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: loginTimeout)
|
||||
login.enterUsername(creds.username)
|
||||
// Kratos uses the EMAIL as the login identifier. Subclasses still declare
|
||||
// testCredentials by short username (e.g. "admin"/"testuser"), so normalize
|
||||
// a bare username to "<username>@honeydue.com" for the app's login form.
|
||||
let identifier = creds.username.contains("@")
|
||||
? creds.username
|
||||
: "\(creds.username)@honeydue.com"
|
||||
login.enterUsername(identifier)
|
||||
login.enterPassword(creds.password)
|
||||
|
||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
@@ -133,7 +217,24 @@ class AuthenticatedUITestCase: BaseUITestCase {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTAssertTrue(tabBar.exists, "Expected tab bar after login with '\(testCredentials.username)'")
|
||||
if !tabBar.exists {
|
||||
XCTFail("Expected tab bar after login with '\(testCredentials.username)'. " +
|
||||
"Root state: " + Self.diagnoseRootState(app))
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostic: report which RootView branch the app is parked on when
|
||||
/// the tab bar fails to appear after login. Helps distinguish a failed login
|
||||
/// (parked on ui.root.login) from a stuck verify-email gate.
|
||||
static func diagnoseRootState(_ app: XCUIApplication) -> String {
|
||||
let login = app.otherElements["ui.root.login"].exists
|
||||
let onboarding = app.otherElements["ui.root.onboarding"].exists
|
||||
let mainTabs = app.otherElements["ui.root.mainTabs"].exists
|
||||
let verifyCode = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField].exists
|
||||
|| app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField].exists
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].exists
|
||||
return "login=\(login) onboarding=\(onboarding) mainTabs=\(mainTabs) " +
|
||||
"verifyCodeField=\(verifyCode) usernameField=\(usernameField)"
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,29 +38,24 @@ enum TestAccountManager {
|
||||
return session
|
||||
}
|
||||
|
||||
/// Create an unverified account (register only, no email verification).
|
||||
/// Useful for testing the verification gate.
|
||||
/// Create an unverified account (Kratos identity with an unverified email).
|
||||
/// Useful for testing the verification gate. Returns a ready-to-use session.
|
||||
static func createUnverifiedAccount(
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
let creds = uniqueCredentials()
|
||||
|
||||
guard let response = TestAccountAPIClient.register(
|
||||
guard let session = TestAccountAPIClient.createUnverifiedAccount(
|
||||
username: creds.username,
|
||||
email: creds.email,
|
||||
password: creds.password
|
||||
) else {
|
||||
XCTFail("Failed to register unverified account for \(creds.username)", file: file, line: line)
|
||||
XCTFail("Failed to create unverified account for \(creds.username)", file: file, line: line)
|
||||
return nil
|
||||
}
|
||||
|
||||
return TestSession(
|
||||
token: response.token,
|
||||
user: response.user,
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
// MARK: - Seeded Accounts
|
||||
@@ -85,43 +80,4 @@ enum TestAccountManager {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Password Reset
|
||||
|
||||
/// Execute the full forgot→verify→reset cycle via the backend API.
|
||||
static func resetPassword(
|
||||
email: String,
|
||||
newPassword: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> Bool {
|
||||
guard TestAccountAPIClient.forgotPassword(email: email) != nil else {
|
||||
XCTFail("Forgot password request failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
guard let verifyResponse = TestAccountAPIClient.verifyResetCode(email: email) else {
|
||||
XCTFail("Verify reset code failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
guard TestAccountAPIClient.resetPassword(resetToken: verifyResponse.resetToken, newPassword: newPassword) != nil else {
|
||||
XCTFail("Reset password failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Token Management
|
||||
|
||||
/// Invalidate a session token via the logout API.
|
||||
static func invalidateToken(
|
||||
_ session: TestSession,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
if TestAccountAPIClient.logout(token: session.token) == nil {
|
||||
XCTFail("Failed to invalidate token for \(session.username)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ enum TestFlows {
|
||||
loginButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
/// Drive the full forgot password → verify code → reset password flow using the debug code.
|
||||
/// Drive the full forgot password → verify code → reset password flow.
|
||||
/// The recovery code is read from Mailpit — password reset is a Kratos
|
||||
/// recovery flow now, so Kratos emails a real 6-digit code (no fixed code).
|
||||
static func completeForgotPasswordFlow(
|
||||
app: XCUIApplication,
|
||||
email: String,
|
||||
@@ -80,10 +82,11 @@ enum TestFlows {
|
||||
forgotScreen.enterEmail(email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Step 2: Enter debug verification code
|
||||
// Step 2: Enter the real Kratos recovery code (emailed → Mailpit locally)
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||
verifyScreen.enterCode(code)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Step 3: Enter new password
|
||||
|
||||
Reference in New Issue
Block a user