315 lines
23 KiB
Swift
315 lines
23 KiB
Swift
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.")
|
|
}
|
|
}
|