Files
Tarnax/Tests/ContractTests.swift
T
2026-11-04 15:16:11 +00:00

204 lines
13 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 {
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()
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) == (failure.code == .timedOut), "Only expected cancellation is silent: \(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()
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()
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()
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")
another.close(); transport.close(); print("\(checks) contract checks passed.")
}
}