Skip to content

Commit 40e16a2

Browse files
authored
Improve clarity in SessionChallengeHandler (#1116)
1 parent 1679e38 commit 40e16a2

6 files changed

Lines changed: 367 additions & 66 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@
2626
- Avoid trailing closure syntax when passing multiple closures (use parentheses for all closures to prevent multiple_closures_with_trailing_closure warnings)
2727
- Respect "BuildTools/.swiftformat" and "BuildTools/.swiftlint.yml"
2828
- Always use Swift Regex with Swift 6 syntax
29+
- Prefer `guard` for early exits over `if/else if` chains — when a branch returns, use `guard`/early return to flatten nesting
30+
- Move logic to the type that owns the data — methods that operate on a type's internals belong on that type, not in the caller
31+
- Drop argument labels for parameters already implied by the function name — use `_` for positional parameters whose meaning is obvious from the function name, keep labels only for semantically distinct parameters
32+
- Prefer direct calls to shared helpers over thin wrapper closures that just forward arguments
33+
34+
## Rules for writing tests
35+
2936
- Always write tests with Swift Testing
37+
- Add a parameter with a default value (e.g. `networkTracker: NetworkTracker = .shared`) to make functions testable without coupling them to singletons
3038

3139
## git
3240
- Always use git commit with -s -S

OpenHABCore/Sources/OpenHABCore/Util/ConnectionConfiguration.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ public struct ConnectionConfiguration: Hashable, Sendable, Codable, Equatable {
8989
}
9090

9191
public extension ConnectionConfiguration {
92+
/// The host component of the connection URL, if parseable.
93+
var host: String? { URL(string: url)?.host }
94+
9295
/// Whether this connection is to an openHAB Cloud instance.
9396
/// Currently determined by the "openHAB Cloud Service" user preference (`supportsNotifications`).
9497
var isCloudConnection: Bool { supportsNotifications }

OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -513,8 +513,17 @@ public actor NetworkTracker {
513513
}
514514

515515
public extension NetworkTracker {
516-
func configuredConnections() -> [ConnectionConfiguration] {
517-
connectionConfigurations
516+
/// Finds the connection configuration whose URL host or proxy host matches the given host,
517+
/// prioritising the active connection.
518+
func connectionConfiguration(forHost host: String) -> ConnectionConfiguration? {
519+
if let activeConnection,
520+
activeConnection.configuration.host == host || activeConnection.proxyURL?.host == host {
521+
activeConnection.configuration
522+
} else {
523+
connectionConfigurations
524+
.filter { $0 != activeConnection?.configuration }
525+
.first { $0.host == host }
526+
}
518527
}
519528

520529
private func service() async throws -> any OpenAPIServiceProtocol {
@@ -582,5 +591,9 @@ public extension NetworkTracker {
582591
func setMockConnection(_ connection: ConnectionInfo) {
583592
activeConnection = connection
584593
}
594+
595+
func setMockConnectionConfigurations(_ configurations: [ConnectionConfiguration]) {
596+
connectionConfigurations = configurations
597+
}
585598
}
586599
#endif

OpenHABCore/Sources/OpenHABCore/Util/SessionChallengeHandler.swift

Lines changed: 57 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -12,105 +12,98 @@
1212
import Foundation
1313
import os
1414

15-
@MainActor
16-
public func onReceiveSessionTaskChallenge(with challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
15+
private func logChallengeDecision(label: String, _ challenge: URLAuthenticationChallenge, _ disposition: URLSession.AuthChallengeDisposition, reason: String) {
1716
let host = challenge.protectionSpace.host
18-
let authenticationMethod = challenge.protectionSpace.authenticationMethod
19-
Logger.sessionChallenge.info("onReceiveSessionTaskChallenge host=\(host, privacy: .public), method=\(authenticationMethod, privacy: .public)")
17+
let method = challenge.protectionSpace.authenticationMethod
18+
Logger.sessionChallenge.info("\(label, privacy: .public) decision host=\(host, privacy: .public), method=\(method, privacy: .public), disposition=\(String(describing: disposition), privacy: .public), reason=\(reason, privacy: .public)")
19+
}
2020

21-
func logDecision(_ disposition: URLSession.AuthChallengeDisposition, reason: String) {
22-
Logger.sessionChallenge.info("Session task challenge decision host=\(host, privacy: .public), method=\(authenticationMethod, privacy: .public), disposition=\(String(describing: disposition), privacy: .public), reason=\(reason, privacy: .public)")
23-
}
21+
private func logSessionTaskChallengeDecision(_ challenge: URLAuthenticationChallenge, _ disposition: URLSession.AuthChallengeDisposition, reason: String) {
22+
logChallengeDecision(label: "Session task challenge", challenge, disposition, reason: reason)
23+
}
2424

25-
if challenge.previousFailureCount > 0 {
26-
let decision: URLSession.AuthChallengeDisposition = .cancelAuthenticationChallenge
27-
logDecision(decision, reason: "previous-failure")
28-
return (decision, nil)
29-
} else if authenticationMethod.isAny(of: NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodDefault) {
30-
let networkTracker = NetworkTracker.shared
31-
let activeConnection = await networkTracker.activeConnection
25+
private func logSessionChallengeDecision(_ challenge: URLAuthenticationChallenge, _ disposition: URLSession.AuthChallengeDisposition, reason: String) {
26+
logChallengeDecision(label: "Session challenge", challenge, disposition, reason: reason)
27+
}
3228

33-
var candidateConfigurations: [ConnectionConfiguration] = []
34-
if let activeConfiguration = activeConnection?.configuration {
35-
candidateConfigurations.append(activeConfiguration)
36-
}
37-
for configuration in await networkTracker.configuredConnections() where !candidateConfigurations.contains(configuration) {
38-
candidateConfigurations.append(configuration)
39-
}
29+
private func credentialForMatchedHost(
30+
_ challenge: URLAuthenticationChallenge,
31+
networkTracker: NetworkTracker,
32+
log: (URLAuthenticationChallenge, URLSession.AuthChallengeDisposition, String) -> Void
33+
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
34+
let host = challenge.protectionSpace.host
35+
guard let matchedConfiguration = await networkTracker.connectionConfiguration(forHost: host) else {
36+
Logger.sessionChallenge.error("No host match for challenge host=\(host, privacy: .public)")
37+
let decision: URLSession.AuthChallengeDisposition = .performDefaultHandling
38+
log(challenge, decision, "no-host-match")
39+
return (decision, nil)
40+
}
4041

41-
let proxyHost = activeConnection?.proxyURL?.host
42-
let matchedConfiguration = candidateConfigurations.first { configuration in
43-
URL(string: configuration.url)?.host == host
44-
}
42+
let credential = URLCredential(user: matchedConfiguration.username, password: matchedConfiguration.password, persistence: .forSession)
43+
let decision: URLSession.AuthChallengeDisposition = .useCredential
44+
log(challenge, decision, "matched-host")
45+
return (decision, credential)
46+
}
4547

46-
if let matchedConfiguration {
47-
let credential = URLCredential(user: matchedConfiguration.username, password: matchedConfiguration.password, persistence: .forSession)
48-
let decision: URLSession.AuthChallengeDisposition = .useCredential
49-
logDecision(decision, reason: "matched-connection-host")
50-
return (decision, credential)
51-
}
48+
@MainActor
49+
public func onReceiveSessionTaskChallenge(with challenge: URLAuthenticationChallenge, networkTracker: NetworkTracker = .shared) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
50+
let host = challenge.protectionSpace.host
51+
Logger.sessionChallenge.info("onReceiveSessionTaskChallenge host=\(host, privacy: .public), method=\(challenge.protectionSpace.authenticationMethod, privacy: .public)")
5252

53-
if let proxyHost, host == proxyHost, let activeConfiguration = activeConnection?.configuration {
54-
let credential = URLCredential(user: activeConfiguration.username, password: activeConfiguration.password, persistence: .forSession)
55-
let decision: URLSession.AuthChallengeDisposition = .useCredential
56-
logDecision(decision, reason: "matched-active-proxy-host")
57-
return (decision, credential)
58-
}
53+
guard challenge.previousFailureCount == 0 else {
54+
let decision: URLSession.AuthChallengeDisposition = .cancelAuthenticationChallenge
55+
logSessionTaskChallengeDecision(challenge, decision, reason: "previous-failure")
56+
return (decision, nil)
57+
}
5958

60-
let candidateHosts = candidateConfigurations.compactMap { URL(string: $0.url)?.host }.joined(separator: ",")
61-
Logger.sessionChallenge.error("No host match for challenge host=\(host, privacy: .public). Candidate hosts: \(candidateHosts, privacy: .public)")
59+
guard challenge.protectionSpace.authenticationMethod.isAny(of: NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodDefault) else {
6260
let decision: URLSession.AuthChallengeDisposition = .performDefaultHandling
63-
logDecision(decision, reason: "no-host-match-default-handling")
61+
logSessionTaskChallengeDecision(challenge, decision, reason: "unsupported-auth-method")
6462
return (decision, nil)
6563
}
66-
let decision: URLSession.AuthChallengeDisposition = .performDefaultHandling
67-
logDecision(decision, reason: "unsupported-auth-method-default-handling")
68-
return (decision, nil)
64+
65+
return await credentialForMatchedHost(challenge, networkTracker: networkTracker, log: logSessionTaskChallengeDecision)
6966
}
7067

7168
@MainActor
72-
public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
69+
public func onReceiveSessionChallenge(with challenge: URLAuthenticationChallenge, networkTracker: NetworkTracker = .shared) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
7370
let host = challenge.protectionSpace.host
7471
let authenticationMethod = challenge.protectionSpace.authenticationMethod
7572
Logger.sessionChallenge.warning("onReceiveSessionChallenge is not implemented fully (see TODOs)")
7673
Logger.sessionChallenge.info("onReceiveSessionChallenge host=\(host, privacy: .public), method=\(authenticationMethod, privacy: .public)")
77-
var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling
78-
79-
func logDecision(_ disposition: URLSession.AuthChallengeDisposition, reason: String) {
80-
Logger.sessionChallenge.info("Session challenge decision host=\(host, privacy: .public), method=\(authenticationMethod, privacy: .public), disposition=\(String(describing: disposition), privacy: .public), reason=\(reason, privacy: .public)")
81-
}
8274

8375
switch challenge.protectionSpace.authenticationMethod {
8476
case NSURLAuthenticationMethodServerTrust:
8577
// Check if the active connection has ignoreSSL enabled
86-
if let activeConnection = await NetworkTracker.shared.activeConnection,
78+
if let activeConnection = await networkTracker.activeConnection,
8779
activeConnection.configuration.ignoreSSL,
8880
let serverTrust = challenge.protectionSpace.serverTrust {
8981
Logger.sessionChallenge.info("Ignoring SSL certificate validation (ignoreSSL enabled)")
9082
let decision: URLSession.AuthChallengeDisposition = .useCredential
91-
logDecision(decision, reason: "ignore-ssl-enabled")
83+
logSessionChallengeDecision(challenge, decision, reason: "ignore-ssl-enabled")
9284
return (decision, URLCredential(trust: serverTrust))
9385
}
9486
let result = await CertificateManagers.serverCertificateManager.evaluateTrust(with: challenge)
95-
logDecision(result.0, reason: "server-trust-manager")
87+
logSessionChallengeDecision(challenge, result.0, reason: "server-trust-manager")
9688
return result
9789
case NSURLAuthenticationMethodClientCertificate:
9890
let result = CertificateManagers.clientCertificateManager.evaluateTrust(with: challenge)
99-
logDecision(result.0, reason: "client-certificate-manager")
91+
logSessionChallengeDecision(challenge, result.0, reason: "client-certificate-manager")
10092
return result
10193
// attemptCredentialAuthentication
10294
default:
103-
if challenge.previousFailureCount > 0 {
104-
disposition = .cancelAuthenticationChallenge
105-
logDecision(disposition, reason: "previous-failure")
106-
} else {
107-
// TODO: in the last version, the httpClient had never been set and always remained nil. Figure out if and how this worked and if it is still needed
108-
// credential = await NetworkTracker.shared.httpClient?.session.configuration.urlCredentialStorage?.defaultCredential(for: challenge.protectionSpace)
109-
// if credential != nil {
110-
// disposition = .useCredential
111-
// }
112-
logDecision(disposition, reason: "default-handling-no-credential")
95+
guard challenge.previousFailureCount == 0 else {
96+
let decision: URLSession.AuthChallengeDisposition = .cancelAuthenticationChallenge
97+
logSessionChallengeDecision(challenge, decision, reason: "previous-failure")
98+
return (decision, nil)
11399
}
114-
return (disposition, nil)
100+
101+
// TODO: Figure out if credential lookup for the default case is needed.
102+
// The old httpClient-based lookup was never wired up. A possible replacement:
103+
// return await credentialForMatchedHost(challenge, networkTracker: networkTracker, log: logSessionChallengeDecision)
104+
105+
let decision: URLSession.AuthChallengeDisposition = .performDefaultHandling
106+
logSessionChallengeDecision(challenge, decision, reason: "default-handling-no-credential")
107+
return (decision, nil)
115108
}
116109
}

OpenHABCore/Tests/OpenHABCoreTests/NetworkTrackerTests.swift

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Network
1515
import OSLog
1616

1717
@testable import OpenHABCore
18+
import Testing
1819
import XCTest
1920

2021
final actor MockOpenAPIService: OpenAPIServiceProtocol {
@@ -264,3 +265,103 @@ final class NetworkTrackerTests: XCTestCase {
264265
// await fulfillment(of: [becameNotConnected], timeout: 4.0)
265266
// }
266267
}
268+
269+
// MARK: - connectionConfiguration(forHost:) Tests
270+
271+
private let localConfig = ConnectionConfiguration(
272+
url: "https://local.openhab.org",
273+
username: "localuser",
274+
password: "localpass",
275+
priority: 0
276+
)
277+
278+
private let remoteConfig = ConnectionConfiguration(
279+
url: "https://remote.openhab.org",
280+
username: "remoteuser",
281+
password: "remotepass",
282+
priority: 10
283+
)
284+
285+
private let proxyURL = URL(string: "https://proxy.openhab.org")!
286+
287+
@Suite("NetworkTracker.connectionConfiguration(forHost:)")
288+
struct ConnectionConfigurationForHostTests {
289+
@Test("Returns active connection configuration when host matches active connection URL")
290+
func matchesActiveConnectionHost() async {
291+
let tracker = NetworkTracker()
292+
let connection = ConnectionInfo(configuration: localConfig, version: 1)
293+
await tracker.setMockConnection(connection)
294+
await tracker.setMockConnectionConfigurations([localConfig, remoteConfig])
295+
296+
let result = await tracker.connectionConfiguration(forHost: "local.openhab.org")
297+
#expect(result == localConfig)
298+
}
299+
300+
@Test("Returns active connection configuration when host matches proxy URL")
301+
func matchesProxyHost() async {
302+
let tracker = NetworkTracker()
303+
let connection = ConnectionInfo(configuration: remoteConfig, version: 1, proxyURL: proxyURL)
304+
await tracker.setMockConnection(connection)
305+
await tracker.setMockConnectionConfigurations([remoteConfig])
306+
307+
let result = await tracker.connectionConfiguration(forHost: "proxy.openhab.org")
308+
#expect(result == remoteConfig)
309+
}
310+
311+
@Test("Falls back to configured connections when active connection does not match")
312+
func fallsBackToConfiguredConnections() async {
313+
let tracker = NetworkTracker()
314+
let connection = ConnectionInfo(configuration: localConfig, version: 1)
315+
await tracker.setMockConnection(connection)
316+
await tracker.setMockConnectionConfigurations([localConfig, remoteConfig])
317+
318+
let result = await tracker.connectionConfiguration(forHost: "remote.openhab.org")
319+
#expect(result == remoteConfig)
320+
}
321+
322+
@Test("Returns nil when no configuration matches")
323+
func returnsNilForUnknownHost() async {
324+
let tracker = NetworkTracker()
325+
let connection = ConnectionInfo(configuration: localConfig, version: 1)
326+
await tracker.setMockConnection(connection)
327+
await tracker.setMockConnectionConfigurations([localConfig, remoteConfig])
328+
329+
let result = await tracker.connectionConfiguration(forHost: "unknown.example.com")
330+
#expect(result == nil)
331+
}
332+
333+
@Test("Returns matching configured connection when there is no active connection")
334+
func matchesWithoutActiveConnection() async {
335+
let tracker = NetworkTracker()
336+
await tracker.setMockConnectionConfigurations([localConfig, remoteConfig])
337+
338+
let result = await tracker.connectionConfiguration(forHost: "remote.openhab.org")
339+
#expect(result == remoteConfig)
340+
}
341+
342+
@Test("Returns nil when there are no configured connections and no active connection")
343+
func returnsNilWhenEmpty() async {
344+
let tracker = NetworkTracker()
345+
346+
let result = await tracker.connectionConfiguration(forHost: "local.openhab.org")
347+
#expect(result == nil)
348+
}
349+
350+
@Test("Active connection takes priority over same-host configured connection")
351+
func activeConnectionPrioritisedOverConfigured() async {
352+
let tracker = NetworkTracker()
353+
let activeConfig = ConnectionConfiguration(
354+
url: "https://local.openhab.org",
355+
username: "activeuser",
356+
password: "activepass",
357+
priority: 5
358+
)
359+
let connection = ConnectionInfo(configuration: activeConfig, version: 1)
360+
await tracker.setMockConnection(connection)
361+
await tracker.setMockConnectionConfigurations([localConfig, remoteConfig])
362+
363+
let result = await tracker.connectionConfiguration(forHost: "local.openhab.org")
364+
#expect(result == activeConfig)
365+
#expect(result?.username == "activeuser")
366+
}
367+
}

0 commit comments

Comments
 (0)