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")" ) } }