|
| 1 | +import Testing |
| 2 | +import Foundation |
| 3 | +import Combine |
| 4 | +@testable import Auth0 |
| 5 | +@testable import OAuth2 |
| 6 | + |
| 7 | +// MARK: - MockRequestable |
| 8 | + |
| 9 | +/// Generic test double for `Requestable`. Immediately delivers a predetermined result. |
| 10 | +struct MockRequestable<T: Sendable, E: Auth0APIError>: Requestable { |
| 11 | + let result: Result<T, E> |
| 12 | + |
| 13 | + func start(_ callback: @escaping (Result<T, E>) -> Void) { |
| 14 | + callback(result) |
| 15 | + } |
| 16 | + |
| 17 | + func parameters(_ extraParameters: [String: Any]) -> any Requestable<T, E> { self } |
| 18 | + func headers(_ extraHeaders: [String: String]) -> any Requestable<T, E> { self } |
| 19 | + func requestValidators(_ extraValidators: [RequestValidator]) -> any Requestable<T, E> { self } |
| 20 | +} |
| 21 | + |
| 22 | +// MARK: - StubRequestable |
| 23 | + |
| 24 | +/// A no-op requestable used for Authentication protocol methods not under test. |
| 25 | +private struct StubRequestable<T: Sendable, E: Auth0APIError>: Requestable { |
| 26 | + func start(_ callback: @escaping (Result<T, E>) -> Void) {} |
| 27 | + func parameters(_ extraParameters: [String: Any]) -> any Requestable<T, E> { self } |
| 28 | + func headers(_ extraHeaders: [String: String]) -> any Requestable<T, E> { self } |
| 29 | + func requestValidators(_ extraValidators: [RequestValidator]) -> any Requestable<T, E> { self } |
| 30 | +} |
| 31 | + |
| 32 | +// MARK: - MockAuthentication |
| 33 | + |
| 34 | +/// Test double for `Authentication`. Injects a `Requestable` for the login method under test; |
| 35 | +/// all other protocol methods return no-op stubs. |
| 36 | +struct MockAuthentication: Authentication { |
| 37 | + var clientId: String |
| 38 | + var url: URL |
| 39 | + var dpop: DPoP? |
| 40 | + var telemetry: Telemetry |
| 41 | + var logger: (any Logger)? |
| 42 | + |
| 43 | + /// The request returned by `login(usernameOrEmail:password:realmOrConnection:audience:scope:)`. |
| 44 | + let loginRequest: any Requestable<Credentials, AuthenticationError> |
| 45 | + |
| 46 | + init(loginRequest: any Requestable<Credentials, AuthenticationError>, |
| 47 | + clientId: String = "mock-client", |
| 48 | + url: URL = URL(string: "https://mock.auth0.com")!) { |
| 49 | + self.loginRequest = loginRequest |
| 50 | + self.clientId = clientId |
| 51 | + self.url = url |
| 52 | + self.dpop = nil |
| 53 | + self.telemetry = Telemetry() |
| 54 | + self.logger = nil |
| 55 | + } |
| 56 | + |
| 57 | + // MARK: Method under test |
| 58 | + |
| 59 | + func login(usernameOrEmail username: String, password: String, |
| 60 | + realmOrConnection realm: String, |
| 61 | + audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { |
| 62 | + loginRequest |
| 63 | + } |
| 64 | + |
| 65 | + // MARK: Required stubs |
| 66 | + |
| 67 | + func login(email: String, code: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 68 | + func login(phoneNumber: String, code: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 69 | + func login(withOTP otp: String, mfaToken: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 70 | + func login(withOOBCode oobCode: String, mfaToken: String, bindingCode: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 71 | + func login(withRecoveryCode recoveryCode: String, mfaToken: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 72 | + func multifactorChallenge(mfaToken: String, types: [String]?, authenticatorId: String?) -> any Requestable<Challenge, AuthenticationError> { StubRequestable() } |
| 73 | + func login(appleAuthorizationCode authorizationCode: String, fullName: PersonNameComponents?, profile: [String: Any]?, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 74 | + func login(facebookSessionAccessToken sessionAccessToken: String, profile: [String: Any], audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 75 | + func loginDefaultDirectory(withUsername username: String, password: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 76 | + func signup(email: String, username: String?, password: String, connection: String, userMetadata: [String: Any]?, rootAttributes: [String: Any]?) -> any Requestable<DatabaseUser, AuthenticationError> { StubRequestable() } |
| 77 | + |
| 78 | + #if PASSKEYS_PLATFORM |
| 79 | + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) |
| 80 | + func login(passkey: LoginPasskey, challenge: PasskeyLoginChallenge, connection: String?, audience: String?, scope: String, organization: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 81 | + |
| 82 | + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) |
| 83 | + func passkeyLoginChallenge(connection: String?, organization: String?) -> any Requestable<PasskeyLoginChallenge, AuthenticationError> { StubRequestable() } |
| 84 | + |
| 85 | + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) |
| 86 | + func login(passkey: SignupPasskey, challenge: PasskeySignupChallenge, connection: String?, audience: String?, scope: String, organization: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 87 | + |
| 88 | + @available(iOS 16.6, macOS 13.5, visionOS 1.0, *) |
| 89 | + func passkeySignupChallenge(email: String?, phoneNumber: String?, username: String?, name: String?, connection: String?, organization: String?) -> any Requestable<PasskeySignupChallenge, AuthenticationError> { StubRequestable() } |
| 90 | + #endif |
| 91 | + |
| 92 | + func resetPassword(email: String, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() } |
| 93 | + func startPasswordless(email: String, type: PasswordlessType, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() } |
| 94 | + func startPasswordless(phoneNumber: String, type: PasswordlessType, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() } |
| 95 | + func userInfo(withAccessToken accessToken: String, tokenType: String) -> any Requestable<UserProfile, AuthenticationError> { StubRequestable() } |
| 96 | + func codeExchange(withCode code: String, codeVerifier: String, redirectURI: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 97 | + func ssoExchange(withRefreshToken refreshToken: String) -> any Requestable<SSOCredentials, AuthenticationError> { StubRequestable() } |
| 98 | + func renew(withRefreshToken refreshToken: String, audience: String?, scope: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 99 | + func revoke(refreshToken: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() } |
| 100 | + func jwks() -> any Requestable<JWKS, AuthenticationError> { StubRequestable() } |
| 101 | + func customTokenExchange(subjectToken: String, subjectTokenType: String, audience: String?, scope: String, organization: String?, parameters: [String: Any]) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() } |
| 102 | +} |
| 103 | + |
| 104 | +// MARK: - Stubs |
| 105 | + |
| 106 | +private extension CredentialsManager { |
| 107 | + static var dummy: CredentialsManager { |
| 108 | + CredentialsManager(authentication: Auth0.authentication(clientId: "test-client", domain: "test.auth0.com")) |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +private extension Credentials { |
| 113 | + static var stub: Credentials { |
| 114 | + Credentials(accessToken: "access-token", tokenType: "Bearer", idToken: "id-token") |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +private extension AuthenticationError { |
| 119 | + static var stub: AuthenticationError { |
| 120 | + AuthenticationError(info: ["error": "access_denied", "error_description": "Access denied"], statusCode: 401) |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +// MARK: - ContentViewModelTests |
| 125 | + |
| 126 | +@MainActor |
| 127 | +@Suite("ContentViewModel") |
| 128 | +struct ContentViewModelTests { |
| 129 | + |
| 130 | + private func makeViewModel( |
| 131 | + email: String = "", |
| 132 | + password: String = "", |
| 133 | + isAuthenticated: Bool = false, |
| 134 | + loginRequest: any Requestable<Credentials, AuthenticationError> |
| 135 | + ) -> ContentViewModel { |
| 136 | + ContentViewModel( |
| 137 | + email: email, |
| 138 | + password: password, |
| 139 | + isAuthenticated: isAuthenticated, |
| 140 | + authenticationClient: MockAuthentication(loginRequest: loginRequest), |
| 141 | + credentialsManager: .dummy |
| 142 | + ) |
| 143 | + } |
| 144 | + |
| 145 | + // MARK: Initial state |
| 146 | + |
| 147 | + @Test("Default initial state has empty fields and no error") |
| 148 | + func defaultInitialState() { |
| 149 | + let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub))) |
| 150 | + |
| 151 | + #expect(viewModel.email == "") |
| 152 | + #expect(viewModel.password == "") |
| 153 | + #expect(viewModel.isLoading == false) |
| 154 | + #expect(viewModel.isAuthenticated == false) |
| 155 | + #expect(viewModel.errorMessage == nil) |
| 156 | + } |
| 157 | + |
| 158 | + @Test("Custom initial state is reflected in published properties") |
| 159 | + func customInitialState() { |
| 160 | + let viewModel = makeViewModel( |
| 161 | + email: "user@example.com", |
| 162 | + password: "secret", |
| 163 | + isAuthenticated: true, |
| 164 | + loginRequest: MockRequestable(result: .success(.stub)) |
| 165 | + ) |
| 166 | + |
| 167 | + #expect(viewModel.email == "user@example.com") |
| 168 | + #expect(viewModel.password == "secret") |
| 169 | + #expect(viewModel.isAuthenticated == true) |
| 170 | + } |
| 171 | + |
| 172 | + // MARK: login() — success |
| 173 | + |
| 174 | + @Test("login() sets isAuthenticated to true on success") |
| 175 | + func loginSuccessSetsAuthenticated() async { |
| 176 | + let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub))) |
| 177 | + |
| 178 | + await viewModel.login() |
| 179 | + |
| 180 | + #expect(viewModel.isAuthenticated == true) |
| 181 | + #expect(viewModel.errorMessage == nil) |
| 182 | + } |
| 183 | + |
| 184 | + @Test("login() clears isLoading after success") |
| 185 | + func loginSuccessClearsLoading() async { |
| 186 | + let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub))) |
| 187 | + |
| 188 | + await viewModel.login() |
| 189 | + |
| 190 | + #expect(viewModel.isLoading == false) |
| 191 | + } |
| 192 | + |
| 193 | + // MARK: login() — failure |
| 194 | + |
| 195 | + @Test("login() sets errorMessage and keeps isAuthenticated false on failure") |
| 196 | + func loginFailureSetsErrorMessage() async { |
| 197 | + let viewModel = makeViewModel(loginRequest: MockRequestable(result: .failure(.stub))) |
| 198 | + |
| 199 | + await viewModel.login() |
| 200 | + |
| 201 | + #expect(viewModel.isAuthenticated == false) |
| 202 | + #expect(viewModel.errorMessage == AuthenticationError.stub.localizedDescription) |
| 203 | + } |
| 204 | + |
| 205 | + @Test("login() clears isLoading after failure") |
| 206 | + func loginFailureClearsLoading() async { |
| 207 | + let viewModel = makeViewModel(loginRequest: MockRequestable(result: .failure(.stub))) |
| 208 | + |
| 209 | + await viewModel.login() |
| 210 | + |
| 211 | + #expect(viewModel.isLoading == false) |
| 212 | + } |
| 213 | + |
| 214 | + // MARK: login() — uses injected request |
| 215 | + |
| 216 | + @Test("login() uses the injected Authentication client, not a live network call") |
| 217 | + func loginUsesInjectedAuthenticationClient() async { |
| 218 | + // The mock always succeeds; a live Auth0 call would fail without network/credentials. |
| 219 | + let viewModel = makeViewModel( |
| 220 | + email: "any@example.com", |
| 221 | + password: "any", |
| 222 | + loginRequest: MockRequestable(result: .success(.stub)) |
| 223 | + ) |
| 224 | + |
| 225 | + await viewModel.login() |
| 226 | + |
| 227 | + #expect(viewModel.isAuthenticated == true) |
| 228 | + } |
| 229 | +} |
0 commit comments