import Foundation import SwiftUI import LocalAuthentication final class MockProtocol: URLProtocol, @unchecked Sendable { static var handler: ((URLRequest) throws -> (Int, String))! override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { do { let (status, body) = try Self.handler(request) let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Content-Type": "application/json"])! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data(body.utf8)); client?.urlProtocolDidFinishLoading(self) } catch { client?.urlProtocol(self, didFailWithError: error) } } override func stopLoading() {} } @main struct ContractTests { static var checks = 0 static func check(_ condition: Bool, _ name: String) { precondition(condition, name); checks += 1; print("PASS: \(name)") } static func body(_ request: URLRequest) -> Data { if let body = request.httpBody { return body } guard let stream = request.httpBodyStream else { return Data() } stream.open(); defer { stream.close() } var data = Data(); var buffer = [UInt8](repeating: 0, count: 1024) while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; data.append(buffer, count: count) } return data } @MainActor static func main() async throws { let testLogURL = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("contract-logs-" + UUID().uuidString + ".json") defer { try? FileManager.default.removeItem(at: testLogURL) } for server in ["http://example.test", "https://u:p@example.test", "https://example.test/?password=x"] { do { _ = try Transport(Credentials(server: server)); fatalError("Insecure URL accepted") } catch { check(true, "Reject unsafe URL: \(server)") } } let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [MockProtocol.self] let transport = try Transport(Credentials(server: "https://example.test/qbit", username: "user+name", password: "p&= +雪", proxyUser: "proxy", proxyPassword: "test-only"), configuration: config) MockProtocol.handler = { request in check(request.url!.path == "/qbit/api/v2/auth/login", "Preserve reverse proxy subpath") check(request.httpMethod == "POST", "Login uses POST") check(request.value(forHTTPHeaderField: "Origin") == "https://example.test", "Same origin CSRF header") check(request.value(forHTTPHeaderField: "Referer") == "https://example.test/qbit/", "Referer includes base path") check(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Basic ") == true, "Optional proxy authentication") let encoded = String(decoding: body(request), as: UTF8.self) var c = URLComponents(); c.percentEncodedQuery = encoded check(c.queryItems?.first(where: { $0.name == "password" })?.value == "p&= +雪", "Password form encoding round trip") return (200, "Ok.") } try await transport.login() MockProtocol.handler = { request in check(request.url!.path == "/qbit/api/v2/torrents/files", "Query stays out of URL path") check(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "abcdef", "Torrent hash query preserved") return (200, "[]") } let files: [TorrentFile] = try await transport.get("torrents/files?hash=abcdef") check(files.isEmpty, "Decode empty file list") MockProtocol.handler = { _ in (200, "Fails.") } do { try await transport.login(); fatalError("Rejected login accepted") } catch { check(true, "Reject HTTP 200 login failure") } for status in [401, 403, 404, 500] { MockProtocol.handler = { _ in (status, "private response must not appear") } do { _ = try await transport.request("app/version"); fatalError("HTTP error accepted") } catch { check(!error.localizedDescription.contains("private response"), "HTTP \(status) produces safe actionable error") } } MockProtocol.handler = { request in let payload = String(decoding: body(request), as: UTF8.self) check(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("multipart/form-data; boundary=") == true, "Multipart boundary") check(payload.contains("name=\"torrents\"; filename=\"sample.torrent\""), "Correct torrent upload field") check(payload.contains("torrent-content"), "Torrent bytes included") check(payload.contains("name=\"savepath\""), "Upload options included") return (200, "Ok.") } _ = try await transport.request("torrents/add", ["savepath":"/downloads"], multipart: (Data("torrent-content".utf8), "sample.torrent")) let sample = #"{"hash":"abc","name":"Example","state":"downloading","progress":0.5,"size":1000,"dlspeed":100,"upspeed":10,"eta":5,"ratio":0.1,"category":"","tags":""}"# var t = try JSONDecoder().decode(Torrent.self, from: Data(sample.utf8)) for state in ["downloading", "forcedDL", "metaDL", "forcedMetaDL"] { t.state = state; check(t.active, "Home includes \(state)") } for state in ["uploading", "stalledDL", "queuedDL", "pausedDL", "stoppedDL", "error", "checkingDL"] { t.state = state; check(!t.active, "Home excludes \(state)") } let originalURL = URL(string: "https://example.test/qbit/api/v2/auth/login")! let redirect = HTTPURLResponse(url: originalURL, statusCode: 307, httpVersion: nil, headerFields: nil)! let task = transport.session.dataTask(with: originalURL) for target in ["http://example.test/api/v2/auth/login", "https://attacker.test/login", "https://example.test:8443/login"] { var accepted = true transport.urlSession(transport.session, task: task, willPerformHTTPRedirection: redirect, newRequest: URLRequest(url: URL(string: target)!)) { accepted = $0 != nil } check(!accepted, "Reject unsafe redirect: \(target)") } var accepted = false transport.urlSession(transport.session, task: task, willPerformHTTPRedirection: redirect, newRequest: URLRequest(url: originalURL)) { accepted = $0 != nil } check(accepted, "Allow same-origin HTTPS redirect") let cookie = HTTPCookie(properties: [.domain: "example.test", .path: "/", .name: "SID", .value: "mock-session-only", .secure: "TRUE"])! transport.session.configuration.httpCookieStorage?.setCookie(cookie) let another = try Transport(Credentials(server: "https://example.test")) check(another.session.configuration.httpCookieStorage?.cookies?.contains(where: { $0.name == "SID" }) != true, "Cookie jar isolated from other connections") let model = AppModel(logs: AppLogStore(url: testLogURL)) model.client = transport; model.connected = true; model.locked = false let gate = DispatchSemaphore(value: 0) var preferenceRequests = 0 MockProtocol.handler = { _ in preferenceRequests += 1 gate.wait() return (200, "{\"save_path\":\"/downloads\"}") } let first = Task { await model.loadPreferences() } while !model.preferencesLoading { await Task.yield() } let second = Task { await model.loadPreferences() } await Task.yield() first.cancel() // Simulate SwiftUI removing the original Settings view. gate.signal() await first.value; await second.value check(preferenceRequests == 1, "Concurrent Settings loads share one request") check(model.preferences["save_path"] as? String == "/downloads", "View cancellation preserves session-owned preference load") check(!model.preferencesLoading && model.error == nil, "Cancelled view leaves no spinner or alert") for failure in [URLError(.cancelled), URLError(.timedOut)] { MockProtocol.handler = { _ in throw failure } await model.loadPreferences() check(model.error != nil, "Unexpected active-session cancellation and timeout remain visible: \(failure.code)") check(model.preferences["save_path"] as? String == "/downloads", "Failed reload retains preferences") } model.error = nil check(!model.shouldReport(CancellationError(), from: transport), "Swift task cancellation is silent") let disconnectGate = DispatchSemaphore(value: 0) MockProtocol.handler = { _ in disconnectGate.wait() return (200, "{\"save_path\":\"/stale\"}") } let pending = Task { await model.loadPreferences() } while !model.preferencesLoading { await Task.yield() } model.disconnect() disconnectGate.signal() await pending.value check(model.preferences.isEmpty && model.error == nil, "In-flight disconnect cannot restore stale preferences or alert") model.client = another model.report(URLError(.networkConnectionLost), from: transport) check(model.error == nil, "Retired session cannot raise a global alert") model.disconnect() check(model.preferences.isEmpty && !model.preferencesLoading, "Disconnect clears session preferences") let startup = AppModel(logs: AppLogStore(url: testLogURL)) startup.credentials = Credentials(server: "https://example.test") startup.locked = false check(!startup.canRestoreSession, "Default server without a saved user does not auto-login") await startup.restoreSessionIfNeeded() check(!startup.busy && startup.error == nil, "Fresh installation stays on connection form") startup.credentials = Credentials(server: "http://invalid.test", username: "test-user") startup.locked = true await startup.restoreSessionIfNeeded() check(startup.error == nil && !startup.restoringSession, "App lock prevents automatic network connection") startup.locked = false await startup.restoreSessionIfNeeded() check(startup.error != nil && !startup.connected && !startup.restoringSession, "Failed automatic login returns to connection form") startup.error = nil await startup.restoreSessionIfNeeded() check(startup.error == nil, "Failed automatic login is not retried on view updates") let disconnected = AppModel(logs: AppLogStore(url: testLogURL)) disconnected.locked = false disconnected.credentials = Credentials(server: "http://invalid.test", username: "test-user") disconnected.disconnect() await disconnected.restoreSessionIfNeeded() check(disconnected.error == nil && !disconnected.connected, "Explicit disconnect suppresses automatic login for this launch") let protection = AppModel(logs: AppLogStore(url: testLogURL)) protection.locked = true; protection.biometric = true var authCalls = 0 protection.authenticate = { _ in authCalls += 1; return true } await protection.updateScene(.active) check(!protection.locked && authCalls == 1, "Face ID starts automatically on opening the locked app") await protection.updateScene(.inactive) await protection.updateScene(.active) check(authCalls == 1, "Authentication inactive/active transitions do not prompt again") await protection.updateScene(.background) check(protection.locked, "Background locks protected app") protection.authenticate = { _ in authCalls += 1; throw LAError(.userCancel) } await protection.updateScene(.active) check(protection.locked && protection.error == nil, "Cancelled Face ID stays locked without an alert") await protection.updateScene(.inactive) await protection.updateScene(.active) check(authCalls == 2, "Cancelled authentication does not automatically loop") protection.authenticate = { _ in authCalls += 1; return true } await protection.unlock() check(!protection.locked && authCalls == 3, "Manual unlock remains available after cancellation") await protection.updateScene(.background) await protection.updateScene(.active) check(!protection.locked && authCalls == 4, "Returning from background automatically authenticates again") await protection.updateScene(.background) protection.authenticate = { _ in authCalls += 1 await protection.unlock() return true } await protection.updateScene(.active) check(authCalls == 5 && !protection.authenticating, "Overlapping unlock attempts share one authentication prompt") await protection.updateScene(.background) protection.authenticate = { _ in await protection.updateScene(.background) return true } await protection.updateScene(.active) check(protection.locked, "An old authentication result cannot unlock after backgrounding") protection.authenticate = { _ in await protection.updateScene(.inactive) return true } await protection.updateScene(.active) check(!protection.locked, "Face ID success while system prompt is inactive unlocks the app") let presentation = AppModel(logs: AppLogStore(url: testLogURL)) presentation.biometric = true; presentation.locked = true check(!presentation.unlockNeedsRetry, "Fresh protected launch shows branding instead of lock fallback") presentation.authenticate = { _ in check(!presentation.unlockNeedsRetry, "Face ID prompt keeps lock fallback hidden") throw LAError(.userCancel) } await presentation.updateScene(.active) check(presentation.locked && presentation.unlockNeedsRetry, "Only unsuccessful authentication shows the Unlock fallback") presentation.authenticate = { _ in check(!presentation.unlockNeedsRetry, "Retry returns to branded authentication screen") return true } await presentation.unlock() check(!presentation.locked && !presentation.unlockNeedsRetry, "Successful authentication opens app without lock screen") await presentation.updateScene(.background) check(!presentation.unlockNeedsRetry, "New background lock clears previous fallback state") presentation.authenticate = { _ in false } await presentation.updateScene(.active) check(presentation.locked && presentation.unlockNeedsRetry, "Incomplete authentication offers a retry instead of a permanent spinner") var history = TransferHistory() let origin = Date(timeIntervalSince1970: 10000) history.record(download: 100, upload: 20, at: origin) history.record(download: 200, upload: 30, at: origin.addingTimeInterval(3)) check(history.samples.count == 2 && history.samples.last?.download == 200, "History records actual upload and download speed samples") history.record(download: -1, upload: -5, at: origin.addingTimeInterval(3.5)) check(history.samples.count == 2 && history.samples.last?.download == 0 && history.samples.last?.upload == 0, "Rapid refreshes coalesce and negative speeds clamp to zero") history.record(download: 99, upload: 99, at: origin) check(history.samples.count == 2, "Out-of-order speed samples cannot corrupt the timeline") history.record(download: 300, upload: 30, at: origin.addingTimeInterval(30)) check(history.samples.last?.segment == 1, "Background and network gaps start a new chart segment") history.record(download: 400, upload: 40, at: origin.addingTimeInterval(86_431)) check(history.samples.count == 1, "History expires samples older than 24 hours") var dense = TransferHistory() for second in 0...86_500 { dense.record(download: 1, upload: 2, at: origin.addingTimeInterval(Double(second))) } check(dense.samples.count == TransferHistory.maximumSamples, "History storage remains bounded during frequent refreshes") check(model.transferHistory.samples.isEmpty, "Disconnected session has no previous server history") let historyTransport = try Transport(Credentials(server: "https://example.test"), configuration: config) let liveHistory = AppModel(logs: AppLogStore(url: testLogURL)) liveHistory.historyStore = HistoryStore(url: URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("test-history.json")) defer { try? liveHistory.historyStore.clear() } liveHistory.client = historyTransport; liveHistory.connected = true; liveHistory.locked = false MockProtocol.handler = { request in if request.url!.path.hasSuffix("torrents/info") { return (200, "[]") } return (200, "{\"server_state\":{\"dl_info_speed\":1234,\"up_info_speed\":567}}") } await liveHistory.refresh() check(liveHistory.transferHistory.samples.last?.download == 1234 && liveHistory.transferHistory.samples.last?.upload == 567, "Successful dashboard refresh records both speed series") MockProtocol.handler = { _ in throw URLError(.notConnectedToInternet) } await liveHistory.refresh() check(liveHistory.transferHistory.samples.count == 1, "Failed refresh never fabricates a speed sample") liveHistory.disconnect() check(liveHistory.transferHistory.samples.isEmpty, "Disconnect removes actual collected history") var windowHistory = TransferHistory() for second in [0, 3, 6] { windowHistory.record(download: 1, upload: 2, at: origin.addingTimeInterval(Double(second))) } check(windowHistory.samples(from: origin.addingTimeInterval(3), through: origin.addingTimeInterval(6)).count == 2, "History window includes exact timestamp boundaries without new Date comparison entry points") check(windowHistory.samples(from: origin.addingTimeInterval(7), through: origin.addingTimeInterval(9)).isEmpty, "History window excludes samples outside its numeric time range") let temp = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent(UUID().uuidString).appendingPathComponent("history.json") let store = HistoryStore(url: temp) defer { try? FileManager.default.removeItem(at: temp.deletingLastPathComponent()) } let scope = Credentials(server: "https://history.test", username: "alice") try store.save(windowHistory, for: scope) let restored = try store.load(for: scope, at: origin.addingTimeInterval(6)) check(restored.samples.count == 3 && restored.samples.first?.download == 1, "History round trips timestamps and speeds across store instances") check(try store.load(for: Credentials(server: "https://other.test"), at: origin).samples.isEmpty, "History never crosses server identities") check(try store.load(for: scope, at: origin.addingTimeInterval(90_000)).samples.isEmpty, "Restoration prunes expired history") try store.clear() check(!FileManager.default.fileExists(atPath: temp.path), "Forgetting removes persistent history") check(duration(45) == "45 sec" && duration(90) == "1.5 min" && duration(7200) == "2 hr", "Duration formatting retains seconds and scales to minutes and hours") check(StatFormat.display(120, key: "max_seeding_time", preference: true) == "2 hr", "Seeding preferences convert API minutes correctly") check(StatFormat.display(42, key: "total_peers") == "42", "Unit metadata leaves counters untouched") check(StatFormat.display(1024, key: "dl_info_speed").hasSuffix("/s"), "Speeds retain per-second units") let queueJSON = "[{\"hash\":\"queued\",\"name\":\"Queued torrent\",\"state\":\"queuedDL\",\"progress\":0.5,\"size\":100,\"dlspeed\":0,\"upspeed\":0,\"eta\":10,\"ratio\":0,\"category\":\"\",\"tags\":\"\",\"priority\":3}]" let queue = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.utf8)) check(queue.first?.queuePosition == 3, "Queue position decodes from server priority") let unnumbered = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.replacingOccurrences(of: "3}", with: "-1}").utf8)) check(unnumbered.first?.queuePosition == nil, "Unnumbered server priority is not presented as a queue position") let noPriority = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.replacingOccurrences(of: ",\"priority\":3", with: "").utf8)) check(noPriority.first?.queuePosition == nil, "Older servers without priority still decode") var completionFixture = queue[0] for state in ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"] { completionFixture.state = state; completionFixture.progress = 0.75 check(!completionFixture.complete, "Incomplete progress is excluded even for \(state)") } completionFixture.progress = 1 check(completionFixture.complete, "Full measured progress is completed") completionFixture.progress = 0.9999 check(!completionFixture.complete, "Rounded near-100-percent progress is not completed") var notificationTracker = DownloadNotificationTracker() var eventTorrent = queue[0] eventTorrent.state = "queuedDL"; eventTorrent.progress = 0 check(notificationTracker.observe([eventTorrent]).isEmpty, "Initial notification snapshot is silent") eventTorrent.state = "metaDL" check(notificationTracker.observe([eventTorrent]).isEmpty, "Metadata lookup does not notify download start") eventTorrent.state = "downloading" check(notificationTracker.observe([eventTorrent]).map(\.type) == [.started], "Queued torrent entering downloading emits a start event") check(notificationTracker.observe([eventTorrent]).isEmpty, "Unchanged polling never repeats start notifications") eventTorrent.state = "pausedDL" check(notificationTracker.observe([eventTorrent]).isEmpty, "Pausing emits no download notification") eventTorrent.state = "forcedDL" check(notificationTracker.observe([eventTorrent]).map(\.type) == [.started], "Resuming emits a new start event") eventTorrent.progress = 1; eventTorrent.state = "uploading" check(notificationTracker.observe([eventTorrent]).map(\.type) == [.completed], "Completion emits only a completion event") check(notificationTracker.observe([eventTorrent]).isEmpty, "Unchanged completion does not repeat") var reconnectTracker = DownloadNotificationTracker() check(reconnectTracker.observe([eventTorrent]).isEmpty, "Reconnect does not alert existing completed torrents") eventTorrent.hash = "new-download"; eventTorrent.progress = 0; eventTorrent.state = "downloading" check(reconnectTracker.observe([eventTorrent]).map(\.type) == [.started], "Newly added active download alerts after baseline") another.close(); transport.close(); print("\(checks) contract checks passed.") } }