diff --git a/Hemera/Auth/OAuthFlowManager.swift b/Hemera/Auth/OAuthFlowManager.swift index 402c9b6..8587e2b 100644 --- a/Hemera/Auth/OAuthFlowManager.swift +++ b/Hemera/Auth/OAuthFlowManager.swift @@ -24,14 +24,29 @@ final class OAuthFlowManager { let state = UUID().uuidString // Same-origin redirect URI — HA accepts without fetching client_id - var redirectComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! - redirectComponents.path = "/hemera_callback" + guard var redirectComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { + throw AuthError.invalidServerURL + } + + /** + Preserve any base path (e.g. subpath-hosted HA behind a reverse proxy) + by appending endpoint paths instead of replacing the server URL's path. + */ + let rawBasePath = redirectComponents.path + let basePath = rawBasePath.hasSuffix("/") ? String(rawBasePath.dropLast()) : rawBasePath + + redirectComponents.path = basePath + "/hemera_callback" redirectComponents.queryItems = nil redirectComponents.fragment = nil - let redirectURI = redirectComponents.url!.absoluteString + guard let redirectURL = redirectComponents.url else { + throw AuthError.invalidServerURL + } + let redirectURI = redirectURL.absoluteString - var authComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)! - authComponents.path = "/auth/authorize" + guard var authComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else { + throw AuthError.invalidServerURL + } + authComponents.path = basePath + "/auth/authorize" authComponents.queryItems = [ URLQueryItem(name: "client_id", value: clientId), URLQueryItem(name: "redirect_uri", value: redirectURI), diff --git a/Hemera/UI/Onboarding/ManualEntrySheet.swift b/Hemera/UI/Onboarding/ManualEntrySheet.swift index 0b0f783..d272d39 100644 --- a/Hemera/UI/Onboarding/ManualEntrySheet.swift +++ b/Hemera/UI/Onboarding/ManualEntrySheet.swift @@ -71,12 +71,20 @@ struct ManualEntrySheet: View { } } .presentationDetents([.medium]) + .onDisappear { viewModel.errorMessage = nil } } private func connectAndDismiss() { - if viewModel.connectManual() { - onDismiss() + /** + Keep the sheet open if the attempt failed synchronously (e.g. an + invalid URL that passes field validation but is rejected by OAuth + preparation) so the inline error stays visible instead of the sheet + dismissing over an unshown error. + */ + guard viewModel.connectManual(), viewModel.errorMessage == nil else { + return } + onDismiss() } } diff --git a/Hemera/UI/Onboarding/ServerSelectionView.swift b/Hemera/UI/Onboarding/ServerSelectionView.swift index 357fd88..9c98ca0 100644 --- a/Hemera/UI/Onboarding/ServerSelectionView.swift +++ b/Hemera/UI/Onboarding/ServerSelectionView.swift @@ -33,6 +33,17 @@ struct ServerSelectionView: View { } message: { Text(Localization.unencryptedMessage) } + .alert( + Localization.connectionFailedTitle, + isPresented: Binding( + get: { viewModel.errorMessage != nil && activeSheet == nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + ) + ) { + Button(Localization.ok, role: .cancel) { viewModel.errorMessage = nil } + } message: { + Text(viewModel.errorMessage ?? "") + } .sheet(item: $activeSheet, onDismiss: { if viewModel.authSession != nil { viewModel.cancelAuth() @@ -151,7 +162,10 @@ struct ServerSelectionView: View { iconColor: .blue, title: Localization.enterManually, subtitle: Localization.enterManuallySubtitle, - action: { activeSheet = .manualEntry } + action: { + viewModel.prepareManualEntry() + activeSheet = .manualEntry + } ) { DisclosureIndicator() } @@ -179,6 +193,8 @@ private extension ServerSelectionView { private extension ServerSelectionView { enum Localization { static let title = String(localized: "Connect to Server", comment: "Navigation title for the server connection screen") + static let connectionFailedTitle = String(localized: "Couldn’t Connect", comment: "Alert title shown when connecting to a Home Assistant server fails during onboarding") + static let ok = String(localized: "OK", comment: "Button dismissing the connection-failure alert") static let signIn = String(localized: "Sign In", comment: "Navigation title for the OAuth sign-in screen") static let cancel = String(localized: "Cancel", comment: "Button to cancel the current action") diff --git a/Hemera/UI/Onboarding/ServerSelectionViewModel.swift b/Hemera/UI/Onboarding/ServerSelectionViewModel.swift index fb32bfe..106bef7 100644 --- a/Hemera/UI/Onboarding/ServerSelectionViewModel.swift +++ b/Hemera/UI/Onboarding/ServerSelectionViewModel.swift @@ -39,6 +39,10 @@ final class ServerSelectionViewModel { self.init(authManager: ServiceLocator.shared.authManager) } + func prepareManualEntry() { + errorMessage = nil + } + func startDiscovery() { discovery.startDiscovery() } diff --git a/HemeraTests/Auth/OAuthFlowManagerTests.swift b/HemeraTests/Auth/OAuthFlowManagerTests.swift new file mode 100644 index 0000000..31d1640 --- /dev/null +++ b/HemeraTests/Auth/OAuthFlowManagerTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import Hemera + +struct OAuthFlowManagerTests { + + // MARK: - No Base Path + + @Test + func prepare_withNoBasePath_buildsRootLevelURLs() throws { + let session = try OAuthFlowManager().prepare(serverURL: URL(string: "https://ha.example.com:8123")!) + + #expect(session.redirectURI == "https://ha.example.com:8123/hemera_callback") + + let authComponents = URLComponents(url: session.authorizeURL, resolvingAgainstBaseURL: false)! + #expect(authComponents.path == "/auth/authorize") + #expect(redirectURI(in: authComponents) == "https://ha.example.com:8123/hemera_callback") + } + + // MARK: - Base Path + + @Test + func prepare_withBasePath_preservesBasePath() throws { + let session = try OAuthFlowManager().prepare(serverURL: URL(string: "https://example.com/ha")!) + + #expect(session.redirectURI == "https://example.com/ha/hemera_callback") + + let authComponents = URLComponents(url: session.authorizeURL, resolvingAgainstBaseURL: false)! + #expect(authComponents.path == "/ha/auth/authorize") + #expect(redirectURI(in: authComponents) == "https://example.com/ha/hemera_callback") + } + + @Test + func prepare_withTrailingSlash_doesNotDoubleSlash() throws { + let session = try OAuthFlowManager().prepare(serverURL: URL(string: "https://example.com/ha/")!) + + #expect(session.redirectURI == "https://example.com/ha/hemera_callback") + + let authComponents = URLComponents(url: session.authorizeURL, resolvingAgainstBaseURL: false)! + #expect(authComponents.path == "/ha/auth/authorize") + } +} + +// MARK: - Helpers + +private extension OAuthFlowManagerTests { + func redirectURI(in components: URLComponents) -> String? { + components.queryItems?.first { $0.name == "redirect_uri" }?.value + } +} diff --git a/HemeraTests/UI/Onboarding/ServerSelectionViewModelTests.swift b/HemeraTests/UI/Onboarding/ServerSelectionViewModelTests.swift index 108d6d2..0473fc4 100644 --- a/HemeraTests/UI/Onboarding/ServerSelectionViewModelTests.swift +++ b/HemeraTests/UI/Onboarding/ServerSelectionViewModelTests.swift @@ -114,6 +114,59 @@ struct ServerSelectionViewModelTests { #expect(viewModel.pendingHTTPURL == nil) } + // MARK: - OAuth Callback Error Surfacing + + @Test + func handleOAuthCallback_whenCallbackFails_setsErrorMessageAndDoesNotAuthenticate() async { + let authManager = MockAuthManager() + let viewModel = ServerSelectionViewModel(authManager: authManager) + let serverURL = URL(string: "https://ha.example.com:8123")! + let session = OAuthFlowManager.AuthSession( + authorizeURL: serverURL, + redirectURI: "https://ha.example.com:8123/hemera_callback", + state: "expected-state", + clientId: serverURL.absoluteString, + serverURL: serverURL + ) + // Callback carries a mismatched state, so handleCallback throws before any token exchange. + let callbackURL = URL(string: "https://ha.example.com:8123/hemera_callback?state=wrong&code=abc")! + + await viewModel.handleOAuthCallback(url: callbackURL, session: session) + + #expect(viewModel.errorMessage != nil) + #expect(viewModel.isConnecting == false) + #expect(authManager.didAuthenticateCallCount == 0) + } + + @Test + func connectManual_withCredentialsInURL_returnsTrueButSetsError() { + // The URL passes field validation but OAuth preparation rejects the + // embedded credentials, so connectManual returns true yet leaves an + // error and no session — the manual sheet relies on this to stay open. + let viewModel = makeViewModel() + viewModel.manualURL = "https://user:pass@ha.example.com" + let result = viewModel.connectManual() + #expect(result == true) + #expect(viewModel.errorMessage != nil) + #expect(viewModel.authSession == nil) + } + + @Test + func prepareManualEntry_clearsStaleErrorMessage() { + let viewModel = makeViewModel() + viewModel.errorMessage = "stale error" + viewModel.prepareManualEntry() + #expect(viewModel.errorMessage == nil) + } + + @Test + func startOAuth_clearsPreviousErrorMessage() { + let viewModel = makeViewModel() + viewModel.errorMessage = "stale error" + viewModel.startOAuth(url: URL(string: "https://ha.example.com:8123")!) + #expect(viewModel.errorMessage == nil) + } + // MARK: - Auth Cancellation @Test