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

RadrootsBackgroundTransferTests.swift (18547B)


      1 import Darwin
      2 import Foundation
      3 import Testing
      4 
      5 @testable import RadrootsKit
      6 
      7 @Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws {
      8   let identifier = try RadrootsBackgroundTransferIdentifier(" FIELD-IOS.TRANSFER_1 ")
      9 
     10   #expect(identifier.rawValue == "field-ios.transfer_1")
     11 
     12   #expect(
     13         throws: RadrootsBackgroundTransferError.invalidRequest
     14   ) {
     15     _ = try RadrootsBackgroundTransferIdentifier(" ")
     16   }
     17   #expect(
     18         throws: RadrootsBackgroundTransferError.invalidRequest
     19   ) { _ = try RadrootsBackgroundTransferIdentifier("/escape") }
     20   #expect(
     21         throws: RadrootsBackgroundTransferError.invalidRequest
     22   ) {
     23     _ = try RadrootsBackgroundTransferIdentifier("field..transfer")
     24   }
     25 }
     26 
     27 @Test func backgroundTransferRequestValidatesUrlMethodAndHeaders() throws {
     28   let destination = RadrootsBackgroundTransferLocalFile.file(
     29     RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json"))
     30   let request = try RadrootsBackgroundTransferRequest(
     31     identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"),
     32     remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get,
     33     operation: .download(destination: destination),
     34     headers: ["Accept": "application/json"], metadata: ["purpose": "diagnostics"]
     35   )
     36 
     37   #expect(request.method == .get)
     38   #expect(request.operation == .download(destination: destination))
     39   #expect(request.headers["Accept"] == "application/json")
     40 
     41   let longAuthorization = String(repeating: "a", count: 2048)
     42   let authorized = try RadrootsBackgroundTransferRequest(
     43     remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get,
     44     operation: .download(destination: destination),
     45     headers: ["Authorization": longAuthorization]
     46   )
     47   #expect(authorized.headers["Authorization"] == longAuthorization)
     48 
     49   #expect(
     50         throws: RadrootsBackgroundTransferError.invalidRequest
     51   ) {
     52     _ = try RadrootsBackgroundTransferRequest(
     53       remoteURL: #require(URL(string: "http://radroots.org/relay.json")), method: .get,
     54       operation: .download(destination: destination)
     55     )
     56   }
     57   #expect(
     58         throws: RadrootsBackgroundTransferError.invalidRequest
     59   ) {
     60     _ = try RadrootsBackgroundTransferRequest(
     61       remoteURL: #require(URL(string: "https://radroots.org/relay.json?token=secret")),
     62       method: .get,
     63       operation: .download(destination: destination)
     64     )
     65   }
     66   let simulatorRequest = try RadrootsBackgroundTransferRequest(
     67     remoteURL: #require(URL(string: "http://127.0.0.1:21100/relay.json")), method: .get,
     68     operation: .download(destination: destination),
     69     networkPolicy: .simulatorLoopbackHTTP
     70   )
     71   #expect(simulatorRequest.networkPolicy == .simulatorLoopbackHTTP)
     72   #expect(
     73         throws: RadrootsBackgroundTransferError.invalidRequest
     74   ) {
     75     _ = try RadrootsBackgroundTransferRequest(
     76       remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .post,
     77       operation: .download(destination: destination)
     78     )
     79   }
     80   #expect(
     81         throws: RadrootsBackgroundTransferError.invalidRequest
     82   ) {
     83     _ = try RadrootsBackgroundTransferRequest(
     84       remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get,
     85       operation: .download(destination: destination),
     86       headers: ["Accept": "application/json\ntext/plain"]
     87     )
     88   }
     89   #expect(
     90         throws: RadrootsBackgroundTransferError.invalidRequest
     91   ) {
     92     _ = try RadrootsBackgroundTransferRequest(
     93       remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get,
     94       operation: .download(destination: destination),
     95       headers: ["Content-Length": "4"]
     96     )
     97   }
     98 }
     99 
    100 @Test func backgroundTransferUploadRequiresUploadMethod() throws {
    101   let source = try RadrootsBackgroundTransferLocalFile.stagedBlob(
    102     RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12))
    103 
    104   let request = try RadrootsBackgroundTransferRequest(
    105     remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .put,
    106     operation: .upload(source: source)
    107   )
    108 
    109   #expect(request.operation == .upload(source: source))
    110 
    111   #expect(
    112         throws: RadrootsBackgroundTransferError.invalidRequest
    113   ) {
    114     _ = try RadrootsBackgroundTransferRequest(
    115       remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .get,
    116       operation: .upload(source: source)
    117     )
    118   }
    119 }
    120 
    121 @Test func backgroundTransferRequestsEnforceTransferByteBounds() throws {
    122   let destination = RadrootsBackgroundTransferLocalFile.file(
    123     RadrootsFileReference(scope: .cache, relativePath: "bounded.bin")
    124   )
    125   let request = try RadrootsBackgroundTransferRequest(
    126     remoteURL: #require(URL(string: "https://radroots.org/bounded.bin")),
    127     method: .get,
    128     operation: .download(destination: destination),
    129     maximumTransferBytes: 1024
    130   )
    131 
    132   #expect(request.maximumTransferBytes == 1024)
    133   #expect(
    134         throws: RadrootsBackgroundTransferError.invalidRequest
    135   ) {
    136     _ = try RadrootsBackgroundTransferRequest(
    137       remoteURL: #require(URL(string: "https://radroots.org/unbounded.bin")),
    138       method: .get,
    139       operation: .download(destination: destination),
    140       maximumTransferBytes: 0
    141     )
    142   }
    143   #expect(
    144         throws: RadrootsBackgroundTransferError.invalidRequest
    145   ) {
    146     _ = try RadrootsBackgroundTransferRequest(
    147       remoteURL: #require(URL(string: "https://radroots.org/blob.bin")),
    148       method: .get,
    149       operation: .download(
    150         destination: .stagedBlob(try RadrootsStagedBlobReference(blobID: "download", sizeBytes: 1))
    151       )
    152     )
    153   }
    154 }
    155 
    156 @Test func backgroundTransferProgressAndSnapshotValidateBounds() throws {
    157   let request = try testDownloadRequest(identifier: "field.transfer.snapshot")
    158   let progress = try RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10)
    159   let snapshot = try RadrootsBackgroundTransferSnapshot(
    160         request: request, state: .running, progress: progress, failure: .platformFailure,
    161     updatedAt: Date(timeIntervalSince1970: 1)
    162   )
    163 
    164   #expect(snapshot.identifier == request.identifier)
    165   #expect(snapshot.state == .running)
    166   #expect(snapshot.progress == progress)
    167     #expect(snapshot.failure == .platformFailure)
    168   #expect(snapshot.response == nil)
    169   #expect(!snapshot.possibleRemoteOrphan)
    170 
    171   #expect(
    172         throws: RadrootsBackgroundTransferError.invalidRequest
    173   ) { _ = try RadrootsBackgroundTransferProgress(bytesTransferred: 10, totalBytesExpected: 5) }
    174   #expect(
    175         throws: RadrootsBackgroundTransferError.invalidRequest
    176   ) {
    177     _ = try RadrootsBackgroundTransferSnapshot(
    178       request: request, updatedAt: Date(timeIntervalSinceReferenceDate: .infinity))
    179   }
    180 }
    181 
    182 @Test func backgroundTransferPersistenceRedactsSecretsAndValidatesDecode() async throws {
    183   let roots = try testBackgroundTransferRoots()
    184   let store = RadrootsAppleBackgroundTransferStore(roots: roots)
    185   let request = try RadrootsBackgroundTransferRequest(
    186     identifier: RadrootsBackgroundTransferIdentifier("field.transfer.redacted"),
    187     remoteURL: #require(URL(string: "https://radroots.org/upload")),
    188     method: .put,
    189     operation: .upload(
    190       source: .file(RadrootsFileReference(scope: .cache, relativePath: "upload.png"))),
    191     headers: ["Authorization": "Nostr secret-token"],
    192     metadata: ["purpose": "blossom_upload", "sha256": String(repeating: "a", count: 64)]
    193   )
    194     try await store.saveSnapshot(
    195         RadrootsBackgroundTransferSnapshot(
    196             request: request,
    197             state: .failed,
    198             failure: .verificationRejected
    199         )
    200     )
    201 
    202   let persistedURL = roots.dataRoot.appendingPathComponent("background_transfers/transfers.json")
    203   let persisted = try String(contentsOf: persistedURL, encoding: .utf8)
    204   #expect(!request.debugDescription.contains("secret-token"))
    205   #expect(
    206     !RadrootsBackgroundTransferHandle(request: request).debugDescription.contains("secret-token"))
    207   let snapshotDebugDescription = try RadrootsBackgroundTransferSnapshot(request: request)
    208     .debugDescription
    209   #expect(!snapshotDebugDescription.contains("secret-token"))
    210   #expect(!persisted.contains("secret-token"))
    211   #expect(!persisted.contains("blossom_upload"))
    212   #expect(!persisted.contains(String(repeating: "a", count: 64)))
    213   #expect(persisted.contains("\"schemaVersion\":1"))
    214     #expect(persisted.contains("background_transfer_verification_rejected"))
    215   let recovered = try #require(try await store.loadSnapshots().first)
    216   #expect(recovered.request.headers.isEmpty)
    217   #expect(recovered.request.metadata.isEmpty)
    218     #expect(recovered.failure == .verificationRejected)
    219 
    220     let malformed = persisted.replacingOccurrences(
    221         of: "background_transfer_verification_rejected",
    222         with: "https://secret.example.invalid/token"
    223     )
    224   try Data(malformed.utf8).write(to: persistedURL, options: .atomic)
    225   await #expect(
    226         throws: RadrootsBackgroundTransferError.persistenceFailure
    227   ) {
    228     _ = try await store.loadSnapshots()
    229   }
    230 }
    231 
    232 @Test func appleBackgroundTransferStoreMigratesLegacyCachePersistence() async throws {
    233   let roots = try testBackgroundTransferRoots()
    234   let legacyURL = roots.cacheRoot.appendingPathComponent("background_transfers/transfers.json")
    235   try FileManager.default.createDirectory(
    236     at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true)
    237   let snapshot = try RadrootsBackgroundTransferSnapshot(
    238     request: testDownloadRequest(identifier: "field.transfer.legacy"))
    239   try JSONEncoder().encode([snapshot]).write(to: legacyURL, options: .atomic)
    240 
    241   let store = RadrootsAppleBackgroundTransferStore(roots: roots)
    242   #expect(try await store.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.legacy"])
    243   #expect(!FileManager.default.fileExists(atPath: legacyURL.path))
    244   #expect(
    245     FileManager.default.fileExists(
    246       atPath: roots.dataRoot.appendingPathComponent("background_transfers/transfers.json").path))
    247 }
    248 
    249 @Test func appleBackgroundTransferStoreRejectsOversizedAndNonRegularPersistence() async throws {
    250   let roots = try testBackgroundTransferRoots()
    251   let persistedURL = roots.dataRoot.appendingPathComponent(
    252     "background_transfers/transfers.json")
    253   try FileManager.default.createDirectory(
    254     at: persistedURL.deletingLastPathComponent(), withIntermediateDirectories: true)
    255   try Data(repeating: 0x41, count: 1024 * 1024 + 1).write(to: persistedURL)
    256   let store = RadrootsAppleBackgroundTransferStore(roots: roots)
    257 
    258   await #expect(throws: RadrootsBackgroundTransferError.self) {
    259     _ = try await store.loadSnapshots()
    260   }
    261 
    262   try FileManager.default.removeItem(at: persistedURL)
    263   #expect(persistedURL.path.withCString { Darwin.mkfifo($0, S_IRUSR | S_IWUSR) } == 0)
    264   await #expect(throws: RadrootsBackgroundTransferError.self) {
    265     _ = try await store.loadSnapshots()
    266   }
    267 }
    268 
    269 @Test func appleBackgroundTransferStoreFailsClosedWhileProtectedDataIsLocked() async throws {
    270   let roots = try testBackgroundTransferRoots()
    271   let store = RadrootsAppleBackgroundTransferStore(
    272     roots: roots, protectedData: RadrootsProtectedDataProvider { .locked })
    273 
    274   await #expect(
    275         throws: RadrootsBackgroundTransferError.persistenceFailure
    276   ) {
    277     _ = try await store.loadSnapshots()
    278   }
    279 }
    280 
    281 @Test func appleBackgroundTransferFileResolverUsesOnlyFileRootsAndStagedBlobs() throws {
    282   let roots = try testBackgroundTransferRoots()
    283   let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
    284   let file = RadrootsBackgroundTransferLocalFile.file(
    285     RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json"))
    286   let blob = try RadrootsStagedBlobReference(blobID: "blob-1", sizeBytes: 2)
    287 
    288   #expect(
    289     try resolver.resolve(file)
    290       == roots.dataRoot.appendingPathComponent("exports/diagnostics.json").standardizedFileURL)
    291   #expect(
    292     try resolver.resolve(.stagedBlob(blob))
    293       == roots.stagedBlobsRoot.appendingPathComponent("blob-1").standardizedFileURL)
    294 
    295   try FileManager.default.createDirectory(at: roots.dataRoot, withIntermediateDirectories: true)
    296   try FileManager.default.createDirectory(
    297     at: roots.dataRoot.appendingPathComponent("exports", isDirectory: true),
    298     withIntermediateDirectories: false)
    299   try Data("governed".utf8).write(
    300     to: roots.dataRoot.appendingPathComponent("exports/diagnostics.json"))
    301   #expect(try resolver.read(file, maximumBytes: 64) == Data("governed".utf8))
    302   let outside = roots.dataRoot.deletingLastPathComponent().appendingPathComponent("outside.bin")
    303   try Data("outside".utf8).write(to: outside)
    304   let symlink = roots.dataRoot.appendingPathComponent("escape.bin")
    305   try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: outside)
    306   #expect(
    307         throws: RadrootsBackgroundTransferError.invalidRequest
    308   ) {
    309     _ = try resolver.resolve(.file(RadrootsFileReference(scope: .data, relativePath: "escape.bin")))
    310   }
    311   #expect(throws: RadrootsBackgroundTransferError.self) {
    312     _ = try resolver.read(
    313       .file(RadrootsFileReference(scope: .data, relativePath: "escape.bin")),
    314       maximumBytes: 64)
    315   }
    316 
    317     #expect(throws: RadrootsAppleFileError.invalidRequest) {
    318     _ = try resolver.resolve(
    319       .file(RadrootsFileReference(scope: .data, relativePath: "/tmp/escape")))
    320   }
    321   #expect(
    322         throws: RadrootsAppleFileError.invalidRequest
    323   ) {
    324     _ = try resolver.resolve(
    325       .stagedBlob(RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1)))
    326   }
    327 }
    328 
    329 @Test func appleBackgroundTransferStorePersistsAndRecoversSnapshots() async throws {
    330   let roots = try testBackgroundTransferRoots()
    331   let store = RadrootsAppleBackgroundTransferStore(roots: roots)
    332   let first = try RadrootsBackgroundTransferSnapshot(
    333     request: testDownloadRequest(identifier: "field.transfer.b"),
    334     updatedAt: Date(timeIntervalSince1970: 2)
    335   )
    336   let second = try RadrootsBackgroundTransferSnapshot(
    337     request: testDownloadRequest(identifier: "field.transfer.a"), state: .running,
    338     updatedAt: Date(timeIntervalSince1970: 3)
    339   )
    340 
    341   try await store.saveSnapshot(first)
    342   try await store.saveSnapshot(second)
    343 
    344   let recoveredStore = RadrootsAppleBackgroundTransferStore(roots: roots)
    345   #expect(
    346     try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == [
    347       "field.transfer.a", "field.transfer.b",
    348     ])
    349 
    350   try await recoveredStore.removeSnapshot(for: second.identifier)
    351   #expect(
    352     try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.b"])
    353 
    354   try await recoveredStore.removeAllSnapshots()
    355   #expect(try await recoveredStore.loadSnapshots().isEmpty)
    356 }
    357 
    358 @Test func appleBackgroundTransferStoreSerializesConcurrentWriters() async throws {
    359   let roots = try testBackgroundTransferRoots()
    360   let store = RadrootsAppleBackgroundTransferStore(roots: roots)
    361 
    362   try await withThrowingTaskGroup(of: Void.self) { group in
    363     for index in 0..<32 {
    364       group.addTask {
    365         let request = try testDownloadRequest(identifier: "field.transfer.concurrent-\(index)")
    366         try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request))
    367       }
    368     }
    369     try await group.waitForAll()
    370   }
    371 
    372   #expect(try await store.loadSnapshots().count == 32)
    373 }
    374 
    375 @Test func appleBackgroundTransferStorePersistsBoundedResponseAcrossRestart() async throws {
    376   let roots = try testBackgroundTransferRoots()
    377   let store = RadrootsAppleBackgroundTransferStore(roots: roots)
    378   let body = Data(
    379     #"{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#.utf8)
    380   let request = try RadrootsBackgroundTransferRequest(
    381     identifier: RadrootsBackgroundTransferIdentifier("field.transfer.descriptor"),
    382     remoteURL: #require(URL(string: "https://blossom.radroots.org/upload")), method: .put,
    383     operation: .upload(
    384       source: .file(RadrootsFileReference(scope: .cache, relativePath: "prepared.png"))),
    385     responsePolicy: .boundedJSON(maximumBodyBytes: 1024)
    386   )
    387   let response = try RadrootsBackgroundTransferResponse(
    388     statusCode: 200, mediaType: "application/json", contentEncoding: "identity", body: body)
    389   try await store.saveSnapshot(
    390     RadrootsBackgroundTransferSnapshot(request: request, state: .completed, response: response))
    391 
    392   let recovered = try #require(
    393     try await RadrootsAppleBackgroundTransferStore(roots: roots).loadSnapshots().first)
    394   #expect(recovered.response?.body == body)
    395   #expect(recovered.response?.mediaType == "application/json")
    396   #expect(recovered.response?.contentEncoding == "identity")
    397 }
    398 
    399 @Test func unavailableBackgroundTransferThrowsTypedErrors() async throws {
    400     let transfer = RadrootsUnavailableBackgroundTransfer()
    401   let request = try testDownloadRequest(identifier: "field.transfer.unavailable")
    402 
    403   await #expect(
    404         throws: RadrootsBackgroundTransferError.unavailable
    405   ) {
    406     _ = try await transfer.enqueue(request)
    407   }
    408   await #expect(
    409         throws: RadrootsBackgroundTransferError.unavailable
    410   ) {
    411     try await transfer.cancel(request.identifier)
    412   }
    413   await #expect(
    414         throws: RadrootsBackgroundTransferError.unavailable
    415   ) {
    416     _ = try await transfer.snapshot(for: request.identifier)
    417   }
    418   await #expect(
    419         throws: RadrootsBackgroundTransferError.unavailable
    420   ) {
    421     _ = try await transfer.snapshots()
    422   }
    423 }
    424 
    425 private func testDownloadRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest {
    426   try RadrootsBackgroundTransferRequest(
    427     identifier: RadrootsBackgroundTransferIdentifier(identifier),
    428     remoteURL: URL(string: "https://radroots.org/\(identifier).json")!,
    429     method: .get,
    430     operation: .download(
    431       destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")))
    432   )
    433 }
    434 
    435 private func testBackgroundTransferRoots() throws -> RadrootsAppleFileRoots {
    436   let unresolvedRoot = FileManager.default.temporaryDirectory.appendingPathComponent(
    437     "radroots-background-transfer-\(UUID().uuidString)", isDirectory: true
    438   )
    439   try FileManager.default.createDirectory(at: unresolvedRoot, withIntermediateDirectories: false)
    440   guard let resolvedPointer = unresolvedRoot.path.withCString({ Darwin.realpath($0, nil) }) else {
    441         throw RadrootsBackgroundTransferError.persistenceFailure
    442   }
    443   defer { Darwin.free(resolvedPointer) }
    444   let root = URL(fileURLWithPath: String(cString: resolvedPointer), isDirectory: true)
    445   return try RadrootsAppleFileRoots(
    446     appIdentifier: "org.radroots.tests",
    447     dataRoot: root.appendingPathComponent("data", isDirectory: true),
    448     cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
    449     temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)
    450   )
    451 }