apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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