RadrootsNativeUploadLeaseTests.swift (7378B)
1 #if os(iOS) && targetEnvironment(simulator) 2 import Foundation 3 import Network 4 @testable import RadrootsKit 5 import RadrootsKitTesting 6 import Testing 7 8 @Test func nativeUploadConsumesLeaseAfterOriginalChangesBeforeTaskCreation() async throws { 9 let server = try NativeLeaseHTTPServer() 10 defer { server.stop() } 11 let port = try await server.start() 12 let roots = try appleTransferRoots() 13 defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } 14 let bytes = Data("immutable native upload body".utf8) 15 let source = RadrootsFileReference(scope: .cache, relativePath: "native_upload") 16 try RadrootsAppleFileAccess(roots: roots).write(.inline(bytes), to: source) 17 let resolver = ReplacingNativeLeaseResolver(roots: roots, source: source) 18 let store = RadrootsAppleBackgroundTransferStore(roots: roots) 19 let adapters = try RadrootsAppleBackgroundTransferAdapters.live( 20 sessionIdentifier: "org.radroots.native-lease.\(UUID().uuidString.lowercased())", 21 store: store, fileResolver: resolver, downloadStagingRoot: roots.temporaryRoot 22 ) 23 let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters) 24 let request = try RadrootsBackgroundTransferRequest( 25 identifier: RadrootsBackgroundTransferIdentifier("native.lease.upload"), 26 remoteURL: #require(URL(string: "http://127.0.0.1:\(port)/upload")), method: .put, 27 operation: .upload(source: .file(source)), networkPolicy: .simulatorLoopbackHTTP, 28 expectedSourceSHA256: RadrootsAppleFileDigest.sha256(bytes), maximumTransferBytes: 65536 29 ) 30 _ = try await transfer.enqueue(request) 31 await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { _ = try await transfer.enqueue(request) 32 } 33 let deadline = Date().addingTimeInterval(15) 34 var snapshot = try await transfer.snapshot(for: request.identifier) 35 while snapshot?.state == .queued || snapshot?.state == .running { 36 guard Date() < deadline else { throw RadrootsBackgroundTransferError.transferFailure } 37 try await Task.sleep(for: .milliseconds(25)) 38 snapshot = try await transfer.snapshot(for: request.identifier) 39 } 40 let completed = try #require(snapshot) 41 #expect(completed.state == .awaitingVerification) 42 #expect(completed.uploadLease?.blobID == request.expectedSourceSHA256) 43 #expect(server.bodies == [bytes]) 44 #expect(try resolver.read(.file(source), maximumBytes: 65536) != bytes) 45 try await transfer.settle(request.identifier, verification: .accepted) 46 #expect(try await transfer.snapshot(for: request.identifier)?.state == .completed) 47 } 48 49 private struct ReplacingNativeLeaseResolver: RadrootsBackgroundTransferFileResolver { 50 let roots: RadrootsAppleFileRoots 51 let source: RadrootsFileReference 52 private var live: RadrootsAppleBackgroundTransferFileResolver { 53 .init(roots: roots) 54 } 55 56 func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL { 57 try live.resolve(file) 58 } 59 60 func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data { 61 try live.read(file, maximumBytes: maximumBytes) 62 } 63 64 func prepareUploadLease( 65 for request: RadrootsBackgroundTransferRequest, executionID: UUID, existing: RadrootsStagedBlobReference? 66 ) throws -> RadrootsStagedBlobLease { 67 let lease = try live.prepareUploadLease(for: request, executionID: executionID, existing: existing) 68 try RadrootsAppleFileAccess(roots: roots).write(.inline(Data("replaced original".utf8)), to: source) 69 return lease 70 } 71 72 func releaseUploadLease(executionID: UUID) throws { 73 try live.releaseUploadLease(executionID: executionID) 74 } 75 } 76 77 /// Network callbacks use one private queue; observed result copies use a lock. 78 private final class NativeLeaseHTTPServer: @unchecked Sendable { 79 private let listener: NWListener 80 private let queue = DispatchQueue(label: "org.radroots.tests.native-lease-http") 81 private let lock = NSLock() 82 private var receivedBodies: [Data] = [] 83 private var connections: [NWConnection] = [] 84 85 init() throws { 86 let parameters = NWParameters.tcp 87 parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) 88 listener = try NWListener(using: parameters) 89 } 90 91 var bodies: [Data] { 92 lock.withLock { receivedBodies } 93 } 94 95 func start() async throws -> UInt16 { 96 listener.newConnectionHandler = { [weak self] connection in 97 guard let self else { connection.cancel(); return } 98 connections.append(connection) 99 connection.start(queue: queue) 100 read(connection, accumulated: Data()) 101 } 102 listener.start(queue: queue) 103 for _ in 0 ..< 500 { 104 if let port = listener.port, port.rawValue > 0 { 105 return port.rawValue 106 } 107 try await Task.sleep(for: .milliseconds(10)) 108 } 109 throw RadrootsBackgroundTransferError.unavailable 110 } 111 112 func stop() { 113 listener.cancel() 114 queue.async { for connection in self.connections { 115 connection.cancel() 116 } } 117 } 118 119 private func read(_ connection: NWConnection, accumulated: Data) { 120 connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, complete, error in 121 var bytes = accumulated 122 if let data { 123 bytes.append(data) 124 } 125 guard error == nil, bytes.count <= 131_072 else { connection.cancel(); return } 126 if let body = self.body(in: bytes) { 127 self.lock.withLock { self.receivedBodies.append(body) } 128 let response = Data("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8) 129 connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) 130 } else if complete { 131 connection.cancel() 132 } else { 133 self.read(connection, accumulated: bytes) 134 } 135 } 136 } 137 138 private func body(in bytes: Data) -> Data? { 139 guard let separator = bytes.range(of: Data("\r\n\r\n".utf8)), 140 let header = String(data: bytes[..<separator.lowerBound], encoding: .utf8), 141 header.hasPrefix("PUT /upload HTTP/1.1\r\n") 142 else { return nil } 143 let length = header.components(separatedBy: "\r\n").first { 144 $0.lowercased().hasPrefix("content-length:") 145 }.flatMap { Int($0.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)) } 146 guard let length, (0 ... 65536).contains(length), 147 bytes.count - separator.upperBound == length else { return nil } 148 return Data(bytes[separator.upperBound...]) 149 } 150 } 151 #endif