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 }