diff --git a/readeck/Domain/Utils/EndpointValidator.swift b/readeck/Domain/Utils/EndpointValidator.swift new file mode 100644 index 0000000..cf5d1a2 --- /dev/null +++ b/readeck/Domain/Utils/EndpointValidator.swift @@ -0,0 +1,98 @@ +// +// EndpointValidator.swift +// readeck +// +// Created by Ilyas Hallak on 05.12.25. +// + +import Foundation + +/// Validates and normalizes server endpoint URLs for consistent API usage +struct EndpointValidator { + + /// Normalizes an endpoint URL by: + /// - Trimming whitespace + /// - Ensuring proper scheme (http/https, defaults to https if missing) + /// - Preserving custom ports + /// - Removing trailing slashes from path + /// - Removing query parameters and fragments + /// + /// - Parameter endpoint: Raw endpoint string from user input + /// - Returns: Normalized endpoint URL string + /// + /// Examples: + /// - "example.com" → "https://example.com" + /// - "http://100.80.0.1:8080" → "http://100.80.0.1:8080" + /// - "https://server:3000/path/" → "https://server:3000/path" + /// - "192.168.1.100:9090?query=test" → "https://192.168.1.100:9090" + static func normalize(_ endpoint: String) -> String { + var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + + // Handle empty input + guard !normalized.isEmpty else { + return normalized + } + + // Remove query parameters first + if let queryIndex = normalized.firstIndex(of: "?") { + normalized = String(normalized[.. Bool { + let normalized = normalize(endpoint) + guard let url = URL(string: normalized) else { + return false + } + // Check that we have at minimum a scheme and host + return url.scheme != nil && url.host != nil + } + + // MARK: - Private Helpers + + private static func buildNormalizedURL(from components: URLComponents) -> String { + var urlComponents = components + + // Ensure scheme is http or https, default to https + if urlComponents.scheme == nil { + urlComponents.scheme = "https" + } else if urlComponents.scheme != "http" && urlComponents.scheme != "https" { + urlComponents.scheme = "https" + } + + // Remove trailing slash from path if present + if urlComponents.path.hasSuffix("/") { + urlComponents.path = String(urlComponents.path.dropLast()) + } + + // Remove query parameters and fragments + urlComponents.query = nil + urlComponents.fragment = nil + + return urlComponents.string ?? components.string ?? "" + } +} diff --git a/readeck/UI/Settings/SettingsServerViewModel.swift b/readeck/UI/Settings/SettingsServerViewModel.swift index 5ac59df..27c35d3 100644 --- a/readeck/UI/Settings/SettingsServerViewModel.swift +++ b/readeck/UI/Settings/SettingsServerViewModel.swift @@ -63,7 +63,7 @@ class SettingsServerViewModel { defer { isLoading = false } do { // Normalize endpoint before saving - let normalizedEndpoint = normalizeEndpoint(endpoint) + let normalizedEndpoint = EndpointValidator.normalize(endpoint) let user = try await loginUseCase.execute(endpoint: normalizedEndpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password) try await saveServerSettingsUseCase.execute(endpoint: normalizedEndpoint, username: username, password: password, token: user.token) @@ -80,51 +80,6 @@ class SettingsServerViewModel { isLoggedIn = false } } - - // MARK: - Endpoint Normalization - - private func normalizeEndpoint(_ endpoint: String) -> String { - var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) - - // Remove query parameters - if let queryIndex = normalized.firstIndex(of: "?") { - normalized = String(normalized[.. String { - var urlComponents = components - - // Ensure scheme is http or https, default to https - if urlComponents.scheme == nil { - urlComponents.scheme = "https" - } else if urlComponents.scheme != "http" && urlComponents.scheme != "https" { - urlComponents.scheme = "https" - } - - // Remove trailing slash from path if present - if urlComponents.path.hasSuffix("/") { - urlComponents.path = String(urlComponents.path.dropLast()) - } - - // Remove query parameters (already done above, but double check) - urlComponents.query = nil - urlComponents.fragment = nil - - return urlComponents.string ?? components.string ?? "" - } @MainActor func logout() async { diff --git a/readeckTests/Domain/EndpointValidatorTests.swift b/readeckTests/Domain/EndpointValidatorTests.swift new file mode 100644 index 0000000..3f3d531 --- /dev/null +++ b/readeckTests/Domain/EndpointValidatorTests.swift @@ -0,0 +1,373 @@ +// +// EndpointValidatorTests.swift +// readeckTests +// +// Created by Ilyas Hallak on 05.12.25. +// + +import XCTest +@testable import readeck + +final class EndpointValidatorTests: XCTestCase { + + // MARK: - Standard HTTPS URLs + + func testNormalize_FullHTTPSURL() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com"), + "https://example.com" + ) + } + + func testNormalize_DomainWithoutScheme_AddsHTTPS() { + XCTAssertEqual( + EndpointValidator.normalize("example.com"), + "https://example.com" + ) + } + + func testNormalize_DomainWithSubdomain() { + XCTAssertEqual( + EndpointValidator.normalize("api.example.com"), + "https://api.example.com" + ) + } + + func testNormalize_DomainWithWWW() { + XCTAssertEqual( + EndpointValidator.normalize("www.example.com"), + "https://www.example.com" + ) + } + + // MARK: - HTTP URLs (Critical for Tailscale) + + func testNormalize_ExplicitHTTP_PreservesHTTP() { + XCTAssertEqual( + EndpointValidator.normalize("http://example.com"), + "http://example.com" + ) + } + + func testNormalize_HTTPWithTailscaleIP() { + XCTAssertEqual( + EndpointValidator.normalize("http://100.80.0.1"), + "http://100.80.0.1" + ) + } + + func testNormalize_HTTPWithTailscaleIPAndPort() { + XCTAssertEqual( + EndpointValidator.normalize("http://100.80.0.1:8080"), + "http://100.80.0.1:8080" + ) + } + + // MARK: - Custom Ports (Critical!) + + func testNormalize_HTTPSWithCustomPort() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com:8443"), + "https://example.com:8443" + ) + } + + func testNormalize_HTTPWithCustomPort() { + XCTAssertEqual( + EndpointValidator.normalize("http://example.com:8080"), + "http://example.com:8080" + ) + } + + func testNormalize_DomainWithPortNoScheme_AddsHTTPS() { + XCTAssertEqual( + EndpointValidator.normalize("example.com:3000"), + "https://example.com:3000" + ) + } + + func testNormalize_PortOnly8080() { + XCTAssertEqual( + EndpointValidator.normalize("localhost:8080"), + "https://localhost:8080" + ) + } + + func testNormalize_PortOnly9090() { + XCTAssertEqual( + EndpointValidator.normalize("server:9090"), + "https://server:9090" + ) + } + + // MARK: - Tailscale IP Addresses + + func testNormalize_TailscaleIPNoScheme_AddsHTTPS() { + XCTAssertEqual( + EndpointValidator.normalize("100.80.0.1"), + "https://100.80.0.1" + ) + } + + func testNormalize_TailscaleIPWithPortNoScheme() { + XCTAssertEqual( + EndpointValidator.normalize("100.80.0.1:8080"), + "https://100.80.0.1:8080" + ) + } + + func testNormalize_TailscaleIPWithHTTPAndPort() { + XCTAssertEqual( + EndpointValidator.normalize("http://100.95.200.50:3000"), + "http://100.95.200.50:3000" + ) + } + + func testNormalize_TailscaleIPWithHTTPSAndPort() { + XCTAssertEqual( + EndpointValidator.normalize("https://100.120.10.5:8443"), + "https://100.120.10.5:8443" + ) + } + + // MARK: - Private IP Addresses + + func testNormalize_PrivateIPv4NoScheme() { + XCTAssertEqual( + EndpointValidator.normalize("192.168.1.100"), + "https://192.168.1.100" + ) + } + + func testNormalize_PrivateIPv4WithPort() { + XCTAssertEqual( + EndpointValidator.normalize("192.168.1.100:9090"), + "https://192.168.1.100:9090" + ) + } + + func testNormalize_PrivateIPv4WithHTTPAndPort() { + XCTAssertEqual( + EndpointValidator.normalize("http://192.168.1.100:8080"), + "http://192.168.1.100:8080" + ) + } + + func testNormalize_LocalhostWithHTTP() { + XCTAssertEqual( + EndpointValidator.normalize("http://localhost:8080"), + "http://localhost:8080" + ) + } + + // MARK: - Trailing Slashes + + func testNormalize_RemovesTrailingSlash() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com/"), + "https://example.com" + ) + } + + func testNormalize_RemovesTrailingSlashFromPath() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com/api/"), + "https://example.com/api" + ) + } + + func testNormalize_RemovesTrailingSlashWithPort() { + XCTAssertEqual( + EndpointValidator.normalize("http://100.80.0.1:8080/"), + "http://100.80.0.1:8080" + ) + } + + // MARK: - Query Parameters and Fragments + + func testNormalize_RemovesQueryParameters() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com?query=test"), + "https://example.com" + ) + } + + func testNormalize_RemovesFragment() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com#section"), + "https://example.com" + ) + } + + func testNormalize_RemovesQueryAndFragment() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com?query=test#section"), + "https://example.com" + ) + } + + func testNormalize_RemovesQueryWithPort() { + XCTAssertEqual( + EndpointValidator.normalize("http://192.168.1.100:9090?debug=true"), + "http://192.168.1.100:9090" + ) + } + + func testNormalize_ComplexQueryParameters() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com/path?param1=value1¶m2=value2"), + "https://example.com/path" + ) + } + + // MARK: - Paths + + func testNormalize_PreservesPath() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com/readeck"), + "https://example.com/readeck" + ) + } + + func testNormalize_PreservesNestedPath() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com/api/v1"), + "https://example.com/api/v1" + ) + } + + func testNormalize_PathWithPortNoScheme() { + XCTAssertEqual( + EndpointValidator.normalize("example.com:8080/readeck"), + "https://example.com:8080/readeck" + ) + } + + func testNormalize_HTTPWithPathAndPort() { + XCTAssertEqual( + EndpointValidator.normalize("http://100.80.0.1:3000/api"), + "http://100.80.0.1:3000/api" + ) + } + + // MARK: - Whitespace Handling + + func testNormalize_TrimsLeadingWhitespace() { + XCTAssertEqual( + EndpointValidator.normalize(" https://example.com"), + "https://example.com" + ) + } + + func testNormalize_TrimsTrailingWhitespace() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com "), + "https://example.com" + ) + } + + func testNormalize_TrimsBothWhitespace() { + XCTAssertEqual( + EndpointValidator.normalize(" https://example.com "), + "https://example.com" + ) + } + + func testNormalize_TrimsWhitespaceFromComplexURL() { + XCTAssertEqual( + EndpointValidator.normalize(" http://100.80.0.1:8080/api "), + "http://100.80.0.1:8080/api" + ) + } + + // MARK: - Edge Cases + + func testNormalize_EmptyString() { + XCTAssertEqual( + EndpointValidator.normalize(""), + "" + ) + } + + func testNormalize_OnlyWhitespace() { + XCTAssertEqual( + EndpointValidator.normalize(" "), + "" + ) + } + + func testNormalize_StandardPort80_Preserved() { + XCTAssertEqual( + EndpointValidator.normalize("http://example.com:80"), + "http://example.com:80" + ) + } + + func testNormalize_StandardPort443_Preserved() { + XCTAssertEqual( + EndpointValidator.normalize("https://example.com:443"), + "https://example.com:443" + ) + } + + // MARK: - Complex Real-World Scenarios + + func testNormalize_TailscaleWithPathQueryAndTrailingSlash() { + XCTAssertEqual( + EndpointValidator.normalize("http://100.80.0.1:8080/readeck/?setup=true"), + "http://100.80.0.1:8080/readeck" + ) + } + + func testNormalize_UserInputWithEverything() { + XCTAssertEqual( + EndpointValidator.normalize(" http://192.168.1.50:9090/api/v1/?debug=true#main "), + "http://192.168.1.50:9090/api/v1" + ) + } + + func testNormalize_InvalidScheme_ConvertsToHTTPS() { + XCTAssertEqual( + EndpointValidator.normalize("ftp://example.com"), + "https://example.com" + ) + } + + // MARK: - isValid Tests + + func testIsValid_ValidHTTPSURL() { + XCTAssertTrue(EndpointValidator.isValid("https://example.com")) + } + + func testIsValid_ValidHTTPURL() { + XCTAssertTrue(EndpointValidator.isValid("http://example.com")) + } + + func testIsValid_ValidDomainWithoutScheme() { + XCTAssertTrue(EndpointValidator.isValid("example.com")) + } + + func testIsValid_ValidTailscaleIP() { + XCTAssertTrue(EndpointValidator.isValid("100.80.0.1:8080")) + } + + func testIsValid_ValidIPWithPort() { + XCTAssertTrue(EndpointValidator.isValid("192.168.1.100:9090")) + } + + func testIsValid_EmptyString() { + XCTAssertFalse(EndpointValidator.isValid("")) + } + + func testIsValid_OnlyWhitespace() { + XCTAssertFalse(EndpointValidator.isValid(" ")) + } + + func testIsValid_ValidWithPath() { + XCTAssertTrue(EndpointValidator.isValid("https://example.com/api")) + } + + func testIsValid_ValidHTTPWithPortAndPath() { + XCTAssertTrue(EndpointValidator.isValid("http://100.80.0.1:3000/readeck")) + } +}