Tests: add email-gating API coverage; robust task-uncancel seeding; re-quarantine flaky onboarding e2e
Android UI Tests / ui-tests (push) Has been cancelled

- Issue 2 (coverage gap): add HoneyDueAPITests/AuthGatingAPITests — verifies the
  backend's RequireVerified gate (unverified -> 403, verified -> 200) at the API
  layer, since UI-test mode bypasses verification. NOTE: surfaced that the gate
  is applied to only the share-code routes, not residence/task routes — unverified
  users are NOT broadly blocked (flagged for product/backend).
- Issue 4: TaskCRUDUITests seedAccountPreconditions now guarantees a residence
  (no silent early-return), so the cancelled-task precondition always populates;
  XCTUnwrap replaces the misleading "not seeded" skip. The two uncancel tests now
  skip with the ACCURATE reason: cancelled tasks are intentionally hidden from the
  Tasks Kanban and the iOS Tasks view has no "show cancelled" surface (product gap).
- Issue 3: re-quarantine testF110 after a hardening attempt — the register->verify
  transition is irreducibly flaky; coverage is redundant with OnboardingTaskCache
  + the F-series. Skip reason is now precise, with a TODO to stabilize the handoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-06-06 09:17:31 -05:00
parent 73a60c886d
commit 912888f14c
4 changed files with 272 additions and 40 deletions
@@ -0,0 +1,143 @@
import XCTest
/// Email-verification gating tests against the real backend's `RequireVerified`
/// middleware.
///
/// The app's UI-test mode bypasses email verification, so the rule
/// "an unverified user is blocked from the app" is not exercised by the UI
/// suite. These tests close that gap by hitting a `RequireVerified`-gated
/// endpoint directly with a real Kratos session token and asserting the gate
/// behaves as designed:
///
/// - Test A (negative): an UNVERIFIED account is rejected with **403** by the
/// verification gate.
/// - Test B (positive control): a VERIFIED account is NOT blocked (200/list),
/// proving the 403 in Test A is the verification gate and not an unrelated
/// failure.
///
/// Both run entirely via the API no app launch required.
final class AuthGatingAPITests: XCTestCase {
/// `POST /residences/:id/generate-share-code/` is wrapped with the
/// `RequireVerified` middleware in the Go router (see
/// `setupResidenceRoutes` only the share-code/share-package generation
/// routes carry the gate). Because the middleware runs BEFORE the handler,
/// an unverified caller is rejected with 403 regardless of whether the
/// residence id exists making it a clean, setup-free probe of the gate.
private func gatedPath(residenceId: Int) -> String {
"/residences/\(residenceId)/generate-share-code/"
}
/// Kratos identity emails created during a test, deleted in tearDown.
private var createdEmails: [String] = []
/// Residence ids created during a test (verified path), deleted in tearDown.
private var createdResidences: [(token: String, id: Int)] = []
override func setUpWithError() throws {
continueAfterFailure = false
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
}
}
override func tearDownWithError() throws {
// True teardown: clean residences first (need their owner's token), then
// remove every Kratos identity we provisioned. Both are idempotent and
// run even if a test failed mid-way.
for res in createdResidences {
_ = TestAccountAPIClient.deleteResidence(token: res.token, id: res.id)
}
createdResidences.removeAll()
for email in createdEmails {
_ = TestAccountAPIClient.deleteKratosIdentity(email: email)
}
createdEmails.removeAll()
try super.tearDownWithError()
}
// MARK: - Test A: unverified user is blocked
/// An unverified account must be rejected by the `RequireVerified` gate with
/// a 403 when it tries to reach a gated endpoint.
func test01_unverifiedUserIsBlockedWith403() throws {
let runId = UUID().uuidString.prefix(6)
let email = "authgate_unverified_\(runId)@test.com"
createdEmails.append(email)
guard let session = TestAccountAPIClient.createUnverifiedAccount(
username: "authgate_unverified_\(runId)",
email: email,
password: "TestPass123!"
) else {
XCTFail("Could not create unverified account")
return
}
// Hit the RequireVerified-gated endpoint with the unverified session
// token. The gate runs before the handler, so a non-existent residence
// id (999_999) still produces the 403 from the verification middleware
// rather than a 404 from the handler no residence setup required.
let result = TestAccountAPIClient.rawRequest(
method: "POST",
path: gatedPath(residenceId: 999_999),
body: [:],
token: session.token
)
XCTAssertEqual(
result.statusCode, 403,
"Unverified user should be blocked by the RequireVerified gate with 403, got \(result.statusCode): \(result.errorBody ?? "nil")"
)
}
// MARK: - Test B: verified user is not blocked (positive control)
/// A verified account must pass the `RequireVerified` gate i.e. the gated
/// endpoint must NOT return 403. This proves Test A's 403 is the
/// verification gate, not an unrelated rejection.
func test02_verifiedUserIsNotBlocked() throws {
let runId = UUID().uuidString.prefix(6)
let email = "authgate_verified_\(runId)@test.com"
createdEmails.append(email)
guard let session = TestAccountAPIClient.createVerifiedAccount(
username: "authgate_verified_\(runId)",
email: email,
password: "TestPass123!"
) else {
XCTFail("Could not create verified account")
return
}
// The verified caller must own a real residence so the share-code
// handler (which runs AFTER it passes the gate) returns a clean 200
// rather than a 404. This is what makes it a positive control: it
// exercises the gate AND succeeds past it.
guard let residence = TestAccountAPIClient.createResidence(
token: session.token,
name: "AuthGate Verified \(runId)"
) else {
XCTFail("Verified user should be able to create a residence")
return
}
createdResidences.append((token: session.token, id: residence.id))
let result = TestAccountAPIClient.rawRequest(
method: "POST",
path: gatedPath(residenceId: residence.id),
body: [:],
token: session.token
)
XCTAssertNotEqual(
result.statusCode, 403,
"Verified user should NOT be blocked by the RequireVerified gate, but got 403: \(result.errorBody ?? "nil")"
)
XCTAssertEqual(
result.statusCode, 200,
"Verified user should pass the gate and generate a share code (200), got \(result.statusCode): \(result.errorBody ?? "nil")"
)
}
}