Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Auth0


struct ContentView: View {
@StateObject private var viewModel = ContentViewModel()
@StateObject private var viewModel = ContentViewModel(authenticationClient: Auth0.authentication())

#if os(macOS)
@State private var currentWindow: Auth0WindowRepresentable?
Expand All @@ -23,6 +23,7 @@ struct ContentView: View {

Button {
Task {

#if WEB_AUTH_PLATFORM
#if os(macOS)
await viewModel.webLogin(presentationWindow: currentWindow)
Expand All @@ -45,7 +46,6 @@ struct ContentView: View {
Button {
Task {
#if WEB_AUTH_PLATFORM

#if os(macOS)
await viewModel.logout(presentationWindow: currentWindow)
#else
Expand Down
54 changes: 38 additions & 16 deletions App/ContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,49 @@

@MainActor
final class ContentViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isAuthenticated: Bool = false
private let credentialsManager = CredentialsManager(authentication: Auth0.authentication())
private let credentialsManager: CredentialsManager

#if WEB_AUTH_PLATFORM
private let authenticationClient: Authentication
init(email: String = "",
password: String = "",
isLoading: Bool = false,
errorMessage: String? = nil,
isAuthenticated: Bool = false,
authenticationClient: Authentication,
credentialsManager: CredentialsManager? = nil) {
self.email = email
self.password = password
self.isLoading = isLoading
self.errorMessage = errorMessage
self.isAuthenticated = isAuthenticated
self.authenticationClient = authenticationClient
self.credentialsManager = credentialsManager ?? CredentialsManager(authentication: Auth0.authentication())
}

func login() async {
isLoading = true
do {
let credentials = try await authenticationClient

Check warning on line 34 in App/ContentViewModel.swift

View workflow job for this annotation

GitHub Actions / Test on iOS using Xcode 16.1

initialization of immutable value 'credentials' was never used; consider replacing with assignment to '_' or removing it
.login(usernameOrEmail: email, password: password, realmOrConnection: "Username-Password-Authentication", audience: nil, scope: "openid profile offline_access")
.start()
isAuthenticated = true
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}

#if WEB_AUTH_PLATFORM
func webLogin(presentationWindow window: Auth0WindowRepresentable? = nil) async {
isLoading = true
errorMessage = nil

#if !os(tvOS) && !os(watchOS)
do {

let credentials = try await Auth0
.webAuth()
.scope("openid profile email offline_access")
Expand All @@ -33,23 +63,17 @@
} catch {
errorMessage = "Unexpected error: \(error.localizedDescription)"
}
#endif

isLoading = false
}
#endif

#if WEB_AUTH_PLATFORM
func logout(presentationWindow window: Auth0WindowRepresentable? = nil) async {
isLoading = true
errorMessage = nil
#if !os(tvOS) && !os(watchOS)
do {
var webAuth = Auth0.webAuth()

if let window = window {
webAuth = webAuth.presentationWindow(window)
}

try await webAuth.logout()
try await Auth0.webAuth().logout()

let cleared = credentialsManager.clear()
if cleared {
Expand All @@ -62,13 +86,11 @@
} catch {
errorMessage = "Unexpected error: \(error.localizedDescription)"
}
#endif

isLoading = false
}

#endif

func checkAuthentication() async {
do {
let credentials = try await credentialsManager.credentials()
Expand Down
9 changes: 9 additions & 0 deletions AppTests/AppTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Testing

struct AppTests {

@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}

}
229 changes: 229 additions & 0 deletions AppTests/ContentViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import Testing
import Foundation
import Combine
@testable import Auth0
@testable import OAuth2

// MARK: - MockRequestable

/// Generic test double for `Requestable`. Immediately delivers a predetermined result.
struct MockRequestable<T: Sendable, E: Auth0APIError>: Requestable {
let result: Result<T, E>

func start(_ callback: @escaping (Result<T, E>) -> Void) {
callback(result)
}

func parameters(_ extraParameters: [String: Any]) -> any Requestable<T, E> { self }
func headers(_ extraHeaders: [String: String]) -> any Requestable<T, E> { self }
func requestValidators(_ extraValidators: [RequestValidator]) -> any Requestable<T, E> { self }
}

// MARK: - StubRequestable

/// A no-op requestable used for Authentication protocol methods not under test.
private struct StubRequestable<T: Sendable, E: Auth0APIError>: Requestable {
func start(_ callback: @escaping (Result<T, E>) -> Void) {}
func parameters(_ extraParameters: [String: Any]) -> any Requestable<T, E> { self }
func headers(_ extraHeaders: [String: String]) -> any Requestable<T, E> { self }
func requestValidators(_ extraValidators: [RequestValidator]) -> any Requestable<T, E> { self }
}

// MARK: - MockAuthentication

/// Test double for `Authentication`. Injects a `Requestable` for the login method under test;
/// all other protocol methods return no-op stubs.
struct MockAuthentication: Authentication {
var clientId: String
var url: URL
var dpop: DPoP?
var telemetry: Telemetry
var logger: (any Logger)?

/// The request returned by `login(usernameOrEmail:password:realmOrConnection:audience:scope:)`.
let loginRequest: any Requestable<Credentials, AuthenticationError>

init(loginRequest: any Requestable<Credentials, AuthenticationError>,
clientId: String = "mock-client",
url: URL = URL(string: "https://mock.auth0.com")!) {
self.loginRequest = loginRequest
self.clientId = clientId
self.url = url
self.dpop = nil
self.telemetry = Telemetry()
self.logger = nil
}

// MARK: Method under test

func login(usernameOrEmail username: String, password: String,
realmOrConnection realm: String,
audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> {
loginRequest
}

// MARK: Required stubs

func login(email: String, code: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func login(phoneNumber: String, code: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func login(withOTP otp: String, mfaToken: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func login(withOOBCode oobCode: String, mfaToken: String, bindingCode: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func login(withRecoveryCode recoveryCode: String, mfaToken: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func multifactorChallenge(mfaToken: String, types: [String]?, authenticatorId: String?) -> any Requestable<Challenge, AuthenticationError> { StubRequestable() }
func login(appleAuthorizationCode authorizationCode: String, fullName: PersonNameComponents?, profile: [String: Any]?, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func login(facebookSessionAccessToken sessionAccessToken: String, profile: [String: Any], audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func loginDefaultDirectory(withUsername username: String, password: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func signup(email: String, username: String?, password: String, connection: String, userMetadata: [String: Any]?, rootAttributes: [String: Any]?) -> any Requestable<DatabaseUser, AuthenticationError> { StubRequestable() }

#if PASSKEYS_PLATFORM
@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
func login(passkey: LoginPasskey, challenge: PasskeyLoginChallenge, connection: String?, audience: String?, scope: String, organization: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
func passkeyLoginChallenge(connection: String?, organization: String?) -> any Requestable<PasskeyLoginChallenge, AuthenticationError> { StubRequestable() }

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
func login(passkey: SignupPasskey, challenge: PasskeySignupChallenge, connection: String?, audience: String?, scope: String, organization: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }

@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
func passkeySignupChallenge(email: String?, phoneNumber: String?, username: String?, name: String?, connection: String?, organization: String?) -> any Requestable<PasskeySignupChallenge, AuthenticationError> { StubRequestable() }
#endif

func resetPassword(email: String, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
func startPasswordless(email: String, type: PasswordlessType, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
func startPasswordless(phoneNumber: String, type: PasswordlessType, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
func userInfo(withAccessToken accessToken: String, tokenType: String) -> any Requestable<UserProfile, AuthenticationError> { StubRequestable() }
func codeExchange(withCode code: String, codeVerifier: String, redirectURI: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func ssoExchange(withRefreshToken refreshToken: String) -> any Requestable<SSOCredentials, AuthenticationError> { StubRequestable() }
func renew(withRefreshToken refreshToken: String, audience: String?, scope: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
func revoke(refreshToken: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
func jwks() -> any Requestable<JWKS, AuthenticationError> { StubRequestable() }
func customTokenExchange(subjectToken: String, subjectTokenType: String, audience: String?, scope: String, organization: String?, parameters: [String: Any]) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
}

// MARK: - Stubs

private extension CredentialsManager {
static var dummy: CredentialsManager {
CredentialsManager(authentication: Auth0.authentication(clientId: "test-client", domain: "test.auth0.com"))
}
}

private extension Credentials {
static var stub: Credentials {
Credentials(accessToken: "access-token", tokenType: "Bearer", idToken: "id-token")
}
}

private extension AuthenticationError {
static var stub: AuthenticationError {
AuthenticationError(info: ["error": "access_denied", "error_description": "Access denied"], statusCode: 401)
}
}

// MARK: - ContentViewModelTests

@MainActor
@Suite("ContentViewModel")
struct ContentViewModelTests {

private func makeViewModel(
email: String = "",
password: String = "",
isAuthenticated: Bool = false,
loginRequest: any Requestable<Credentials, AuthenticationError>
) -> ContentViewModel {
ContentViewModel(
email: email,
password: password,
isAuthenticated: isAuthenticated,
authenticationClient: MockAuthentication(loginRequest: loginRequest),
credentialsManager: .dummy
)
}

// MARK: Initial state

@Test("Default initial state has empty fields and no error")
func defaultInitialState() {
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub)))

#expect(viewModel.email == "")
#expect(viewModel.password == "")
#expect(viewModel.isLoading == false)
#expect(viewModel.isAuthenticated == false)
#expect(viewModel.errorMessage == nil)
}

@Test("Custom initial state is reflected in published properties")
func customInitialState() {
let viewModel = makeViewModel(
email: "user@example.com",
password: "secret",
isAuthenticated: true,
loginRequest: MockRequestable(result: .success(.stub))
)

#expect(viewModel.email == "user@example.com")
#expect(viewModel.password == "secret")
#expect(viewModel.isAuthenticated == true)
}

// MARK: login() — success

@Test("login() sets isAuthenticated to true on success")
func loginSuccessSetsAuthenticated() async {
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub)))

await viewModel.login()

#expect(viewModel.isAuthenticated == true)
#expect(viewModel.errorMessage == nil)
}

@Test("login() clears isLoading after success")
func loginSuccessClearsLoading() async {
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub)))

await viewModel.login()

#expect(viewModel.isLoading == false)
}

// MARK: login() — failure

@Test("login() sets errorMessage and keeps isAuthenticated false on failure")
func loginFailureSetsErrorMessage() async {
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .failure(.stub)))

await viewModel.login()

#expect(viewModel.isAuthenticated == false)
#expect(viewModel.errorMessage == AuthenticationError.stub.localizedDescription)
}

@Test("login() clears isLoading after failure")
func loginFailureClearsLoading() async {
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .failure(.stub)))

await viewModel.login()

#expect(viewModel.isLoading == false)
}

// MARK: login() — uses injected request

@Test("login() uses the injected Authentication client, not a live network call")
func loginUsesInjectedAuthenticationClient() async {
// The mock always succeeds; a live Auth0 call would fail without network/credentials.
let viewModel = makeViewModel(
email: "any@example.com",
password: "any",
loginRequest: MockRequestable(result: .success(.stub))
)

await viewModel.login()

#expect(viewModel.isAuthenticated == true)
}
}
Loading
Loading