diff --git a/iosApp/HoneyDueAPITests/AuthGatingAPITests.swift b/iosApp/HoneyDueAPITests/AuthGatingAPITests.swift index b072f06..60780f3 100644 --- a/iosApp/HoneyDueAPITests/AuthGatingAPITests.swift +++ b/iosApp/HoneyDueAPITests/AuthGatingAPITests.swift @@ -3,30 +3,41 @@ import XCTest /// Email-verification gating tests against the real backend's `RequireVerified` /// middleware. /// +/// The backend policy changed so that ALL app-data endpoints now require a +/// VERIFIED user (not merely an authenticated one). Previously only the +/// share-code generation routes carried the gate; now the broad set of +/// app-data reads/writes (residences, tasks, contractors, documents, +/// notifications, subscription, …) are all gated behind verification. +/// /// 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: +/// "an unverified user is blocked from app data" is not exercised by the UI +/// suite. These tests close that gap by hitting the gated endpoints directly +/// with a real Kratos session token and asserting the policy: /// -/// - 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. +/// - VERIFIED required (403 for an authenticated-but-unverified user) on the +/// app-data routes — e.g. `GET /residences/`, `GET /tasks/`, +/// `GET /contractors/`, `GET /documents/`. +/// - AUTHENTICATED-ONLY (must still work unverified — NOT 403): +/// `GET /auth/me/`. The sign-up allow-list keeps these reachable so a freshly +/// registered, not-yet-verified user can complete onboarding. +/// - PUBLIC / lookup GETs (reachable without verification): e.g. +/// `GET /tasks/categories/`. /// -/// Both run entirely via the API — no app launch required. +/// All tests run entirely via the API — no app launch required. Because the +/// `RequireVerified` middleware fires BEFORE the handler, a 403 is produced for +/// an unverified caller regardless of whether any data exists — so the +/// negative-path reads need no seeded residences/tasks. 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/" - } + /// App-data endpoints now gated behind email verification. Each is a read + /// (GET) so no body is needed; the gate runs before the handler, so an + /// unverified caller is rejected with 403 even with no seeded data. + private let verifiedGatedDataPaths = [ + "/residences/", + "/tasks/", + "/contractors/", + "/documents/", + ] /// Kratos identity emails created during a test, deleted in tearDown. private var createdEmails: [String] = [] @@ -57,11 +68,11 @@ final class AuthGatingAPITests: XCTestCase { try super.tearDownWithError() } - // MARK: - Test A: unverified user is blocked + // MARK: - Test 01: unverified user is blocked from app data (broad policy) - /// 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 { + /// An authenticated-but-UNVERIFIED account must be rejected by the + /// `RequireVerified` gate with a 403 on every app-data endpoint. + func test01_unverifiedUserBlockedFromAppData() throws { let runId = UUID().uuidString.prefix(6) let email = "authgate_unverified_\(runId)@test.com" createdEmails.append(email) @@ -75,29 +86,80 @@ final class AuthGatingAPITests: XCTestCase { 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: [:], + // Each gated data endpoint must return 403 for the unverified caller. + // The gate fires before the handler, so no seeded data is required. + for path in verifiedGatedDataPaths { + let result = TestAccountAPIClient.rawRequest( + method: "GET", + path: path, + token: session.token + ) + XCTAssertEqual( + result.statusCode, 403, + "Unverified user should be blocked by RequireVerified on GET \(path) with 403, got \(result.statusCode): \(result.errorBody ?? "nil")" + ) + } + } + + // MARK: - Test 02: unverified user can still reach sign-up endpoints + + /// The sign-up allow-list must keep working for an unverified user, so a + /// freshly registered account can complete onboarding before verifying. + /// `GET /auth/me/` (authenticated-only) and lookup GETs (public) must NOT be + /// blocked by the verification gate. + func test02_unverifiedUserCanReachSignupEndpoints() throws { + let runId = UUID().uuidString.prefix(6) + let email = "authgate_signup_\(runId)@test.com" + createdEmails.append(email) + + guard let session = TestAccountAPIClient.createUnverifiedAccount( + username: "authgate_signup_\(runId)", + email: email, + password: "TestPass123!" + ) else { + XCTFail("Could not create unverified account") + return + } + + // `GET /auth/me/` is authenticated-only — an unverified token must still + // succeed (200), proving the sign-up allow-list bypasses the gate. + let me = TestAccountAPIClient.rawRequest( + method: "GET", + path: "/auth/me/", token: session.token ) - + XCTAssertNotEqual( + me.statusCode, 403, + "GET /auth/me/ must NOT be verification-gated for an unverified user, got 403: \(me.errorBody ?? "nil")" + ) XCTAssertEqual( - result.statusCode, 403, - "Unverified user should be blocked by the RequireVerified gate with 403, got \(result.statusCode): \(result.errorBody ?? "nil")" + me.statusCode, 200, + "Unverified user should reach GET /auth/me/ (sign-up allow-list), got \(me.statusCode): \(me.errorBody ?? "nil")" + ) + + // A lookup GET is public reference data — reachable without + // verification (not 401/403) even for an unverified caller. + let categories = TestAccountAPIClient.rawRequest( + method: "GET", + path: "/tasks/categories/", + token: session.token + ) + XCTAssertNotEqual( + categories.statusCode, 401, + "Lookup GET /tasks/categories/ should be reachable, got 401: \(categories.errorBody ?? "nil")" + ) + XCTAssertNotEqual( + categories.statusCode, 403, + "Lookup GET /tasks/categories/ should not be verification-gated, got 403: \(categories.errorBody ?? "nil")" ) } - // MARK: - Test B: verified user is not blocked (positive control) + // MARK: - Test 03: 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 { + /// A VERIFIED account must pass the gate — `GET /residences/` must NOT + /// return 403 (it returns 200). This proves Test 01's 403s are the + /// verification gate and not an unrelated failure. + func test03_verifiedUserNotBlocked() throws { let runId = UUID().uuidString.prefix(6) let email = "authgate_verified_\(runId)@test.com" createdEmails.append(email) @@ -111,33 +173,18 @@ final class AuthGatingAPITests: XCTestCase { 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: [:], + method: "GET", + path: "/residences/", token: session.token ) - XCTAssertNotEqual( result.statusCode, 403, - "Verified user should NOT be blocked by the RequireVerified gate, but got 403: \(result.errorBody ?? "nil")" + "Verified user should NOT be blocked by RequireVerified on GET /residences/, 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")" + "Verified user should pass the gate and list residences (200), got \(result.statusCode): \(result.errorBody ?? "nil")" ) } }