@otakit/capacitor-updater 2.3.3 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +262 -173
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- package/package.json +7 -3
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import UpdaterPlugin
|
|
4
|
+
|
|
5
|
+
final class BundleCryptoTests: XCTestCase {
|
|
6
|
+
private var directory: URL!
|
|
7
|
+
private let ciphertext = "7b6aa276a9dba36ef929f2e5c5801b0cf7b3e314bf2f1e5c5e0e9df1681b658e2e78da91c3ffffb9dfebd748620c76d3e455d9b60791"
|
|
8
|
+
private var key: Data { Data(0..<32) }
|
|
9
|
+
private var nonce: String { Data(0..<12).base64EncodedString() }
|
|
10
|
+
private func hex(_ value: String) -> Data {
|
|
11
|
+
var result = Data()
|
|
12
|
+
var start = value.startIndex
|
|
13
|
+
while start < value.endIndex {
|
|
14
|
+
let end = value.index(start, offsetBy: 2)
|
|
15
|
+
result.append(UInt8(value[start..<end], radix: 16)!)
|
|
16
|
+
start = end
|
|
17
|
+
}
|
|
18
|
+
return result
|
|
19
|
+
}
|
|
20
|
+
override func setUpWithError() throws {
|
|
21
|
+
directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
22
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
23
|
+
}
|
|
24
|
+
override func tearDownWithError() throws { try? FileManager.default.removeItem(at: directory) }
|
|
25
|
+
func testDecryptsCLICompatibleCiphertextAfterAuthentication() throws {
|
|
26
|
+
let input = directory.appendingPathComponent("encrypted")
|
|
27
|
+
let output = directory.appendingPathComponent("plain")
|
|
28
|
+
try hex(ciphertext).write(to: input)
|
|
29
|
+
try BundleCrypto.decryptFile(dek: key, nonceB64: nonce, input: input, output: output, availableBytes: 1024 * 1024 * 1024)
|
|
30
|
+
XCTAssertEqual(try String(contentsOf: output, encoding: .utf8), "<html>authenticated OTA fixture</html>")
|
|
31
|
+
}
|
|
32
|
+
#if targetEnvironment(simulator)
|
|
33
|
+
func testSimulatorDecryptsWithDefaultMemoryEstimate() throws {
|
|
34
|
+
let input = directory.appendingPathComponent("encrypted")
|
|
35
|
+
let output = directory.appendingPathComponent("plain")
|
|
36
|
+
try hex(ciphertext).write(to: input)
|
|
37
|
+
try BundleCrypto.decryptFile(dek: key, nonceB64: nonce, input: input, output: output)
|
|
38
|
+
XCTAssertEqual(try String(contentsOf: output, encoding: .utf8), "<html>authenticated OTA fixture</html>")
|
|
39
|
+
}
|
|
40
|
+
#endif
|
|
41
|
+
func testTamperedBodyTagAndWrongKeyCannotPublishPlaintext() throws {
|
|
42
|
+
let input = directory.appendingPathComponent("encrypted")
|
|
43
|
+
let output = directory.appendingPathComponent("plain")
|
|
44
|
+
for index in [0, hex(ciphertext).count - 1, -1] {
|
|
45
|
+
var bytes = hex(ciphertext)
|
|
46
|
+
var key = key
|
|
47
|
+
if index >= 0 { bytes[index] ^= 1 } else { key[0] ^= 1 }
|
|
48
|
+
try bytes.write(to: input)
|
|
49
|
+
try Data([42]).write(to: output)
|
|
50
|
+
XCTAssertThrowsError(try BundleCrypto.decryptFile(dek: key, nonceB64: nonce, input: input, output: output, availableBytes: 1024 * 1024 * 1024))
|
|
51
|
+
XCTAssertEqual(try Data(contentsOf: output), Data([42]))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
func testLargeSparseFileIsRejectedBeforeAllocatingOrWriting() throws {
|
|
55
|
+
let input = directory.appendingPathComponent("encrypted")
|
|
56
|
+
let output = directory.appendingPathComponent("absent.zip")
|
|
57
|
+
try Data().write(to: input)
|
|
58
|
+
let file = try FileHandle(forWritingTo: input)
|
|
59
|
+
try file.truncate(atOffset: 200 * 1024 * 1024)
|
|
60
|
+
try file.close()
|
|
61
|
+
XCTAssertThrowsError(try BundleCrypto.decryptFile(dek: key, nonceB64: nonce, input: input, output: output, availableBytes: 80 * 1024 * 1024)) { error in
|
|
62
|
+
XCTAssertTrue(error.localizedDescription.hasPrefix("insufficient_memory_for_encrypted_bundle"))
|
|
63
|
+
}
|
|
64
|
+
XCTAssertFalse(FileManager.default.fileExists(atPath: output.path))
|
|
65
|
+
}
|
|
66
|
+
func testBudgetIncludesReserveAndAvoidsOverflow() throws {
|
|
67
|
+
let reserve: Int64 = 32 * 1024 * 1024
|
|
68
|
+
try BundleCrypto.requireMemoryBudget(encryptedBytes: 100, availableBytes: reserve + 400)
|
|
69
|
+
XCTAssertThrowsError(try BundleCrypto.requireMemoryBudget(encryptedBytes: 101, availableBytes: reserve + 400))
|
|
70
|
+
XCTAssertThrowsError(try BundleCrypto.requireMemoryBudget(encryptedBytes: 100, availableBytes: .min))
|
|
71
|
+
XCTAssertThrowsError(try BundleCrypto.requireMemoryBudget(encryptedBytes: 129 * 1024 * 1024, availableBytes: .max))
|
|
72
|
+
}
|
|
73
|
+
func testTruncatedInputCannotCreateOutput() throws {
|
|
74
|
+
let input = directory.appendingPathComponent("encrypted")
|
|
75
|
+
let output = directory.appendingPathComponent("absent.zip")
|
|
76
|
+
try Data(repeating: 0, count: 16).write(to: input)
|
|
77
|
+
XCTAssertThrowsError(try BundleCrypto.decryptFile(dek: key, nonceB64: nonce, input: input, output: output, availableBytes: .max))
|
|
78
|
+
XCTAssertFalse(FileManager.default.fileExists(atPath: output.path))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import UpdaterPlugin
|
|
4
|
+
|
|
5
|
+
final class BundlePersistenceTests: XCTestCase {
|
|
6
|
+
private var fixture: CoordinatorFixture!
|
|
7
|
+
|
|
8
|
+
override func setUpWithError() throws { fixture = try CoordinatorFixture() }
|
|
9
|
+
override func tearDownWithError() throws { try fixture.cleanup() }
|
|
10
|
+
|
|
11
|
+
func testFailedActivationCommitSurvivesRestartAndCanRetry() throws {
|
|
12
|
+
try fixture.installHealthy("A")
|
|
13
|
+
try fixture.stage("B")
|
|
14
|
+
try fixture.withReadOnlyState {
|
|
15
|
+
XCTAssertThrowsError(try fixture.coordinator.prepareApplyStaged(
|
|
16
|
+
isCompatibleRuntime: { _ in true }, isBundleUsable: fixture.isUsable
|
|
17
|
+
))
|
|
18
|
+
XCTAssertEqual(fixture.store.getBundle(id: "B")?.status, .trial)
|
|
19
|
+
}
|
|
20
|
+
let reopened = fixture.reopenStore()
|
|
21
|
+
XCTAssertEqual(reopened.getCurrentBundle().id, "A")
|
|
22
|
+
XCTAssertEqual(reopened.getFallbackBundle().id, "A")
|
|
23
|
+
XCTAssertEqual(reopened.getStagedBundleId(), "B")
|
|
24
|
+
let coordinator = UpdaterCoordinator(store: reopened)
|
|
25
|
+
let startup = try coordinator.normalizeStartupState(isBundleUsable: fixture.isUsable)
|
|
26
|
+
XCTAssertNil(startup.eventPayload)
|
|
27
|
+
XCTAssertEqual(startup.activationPath, reopened.bundleDirectory(for: "A").path)
|
|
28
|
+
XCTAssertTrue(try coordinator.prepareApplyStaged(
|
|
29
|
+
isCompatibleRuntime: { _ in true }, isBundleUsable: fixture.isUsable
|
|
30
|
+
).didApply)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func testFailedReadinessCommitKeepsFallbackAndCanRetryExactlyOnce() throws {
|
|
34
|
+
try fixture.installHealthy("A")
|
|
35
|
+
try fixture.apply("B")
|
|
36
|
+
try fixture.withReadOnlyState {
|
|
37
|
+
XCTAssertThrowsError(try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId))
|
|
38
|
+
XCTAssertEqual(fixture.store.getCurrentBundle().status, .success)
|
|
39
|
+
XCTAssertEqual(fixture.reopenStore().getFallbackBundle().id, "A")
|
|
40
|
+
XCTAssertTrue(fixture.indexExists("A"))
|
|
41
|
+
}
|
|
42
|
+
let ready = try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId)
|
|
43
|
+
XCTAssertEqual(ready.eventPayload?.action, .applied)
|
|
44
|
+
fixture.coordinator.cleanupBundles(ready.cleanupBundleIds)
|
|
45
|
+
XCTAssertFalse(fixture.indexExists("A"))
|
|
46
|
+
XCTAssertTrue(fixture.indexExists("B"))
|
|
47
|
+
XCTAssertNil(try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId).eventPayload)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func testFailedRollbackCommitKeepsBothBundlesAndCanRetry() throws {
|
|
51
|
+
try fixture.installHealthy("A")
|
|
52
|
+
try fixture.apply("B")
|
|
53
|
+
let trial = try XCTUnwrap(fixture.trial)
|
|
54
|
+
try fixture.withReadOnlyState {
|
|
55
|
+
XCTAssertThrowsError(try fixture.coordinator.prepareRollback(
|
|
56
|
+
expectedTrial: trial, reason: "notify_timeout", isBundleUsable: fixture.isUsable
|
|
57
|
+
))
|
|
58
|
+
let reopened = fixture.reopenStore()
|
|
59
|
+
XCTAssertEqual(reopened.getCurrentBundle().id, "B")
|
|
60
|
+
XCTAssertEqual(reopened.getCurrentBundle().status, .trial)
|
|
61
|
+
XCTAssertEqual(reopened.getFallbackBundle().id, "A")
|
|
62
|
+
XCTAssertNil(reopened.getLastFailedBundle())
|
|
63
|
+
XCTAssertTrue(fixture.indexExists("A"))
|
|
64
|
+
XCTAssertTrue(fixture.indexExists("B"))
|
|
65
|
+
}
|
|
66
|
+
let rollback = try fixture.coordinator.prepareRollback(
|
|
67
|
+
expectedTrial: trial, reason: "notify_timeout", isBundleUsable: fixture.isUsable
|
|
68
|
+
)
|
|
69
|
+
fixture.coordinator.cleanupBundles(rollback.cleanupBundleIds)
|
|
70
|
+
XCTAssertTrue(rollback.didRollback)
|
|
71
|
+
let reopened = fixture.reopenStore()
|
|
72
|
+
XCTAssertEqual(reopened.getCurrentBundle().id, "A")
|
|
73
|
+
XCTAssertEqual(reopened.getLastFailedBundle()?.id, "B")
|
|
74
|
+
XCTAssertEqual(reopened.getLastFailedBundle()?.status, .error)
|
|
75
|
+
XCTAssertTrue(fixture.indexExists("A"))
|
|
76
|
+
XCTAssertFalse(fixture.indexExists("B"))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func testLegacyPreferencesMigrateOnFirstWrite() throws {
|
|
80
|
+
fixture.defaults.set("A", forKey: "otakit_current_bundle_id")
|
|
81
|
+
fixture.defaults.set("A", forKey: "otakit_fallback_bundle_id")
|
|
82
|
+
fixture.defaults.set("B", forKey: "otakit_staged_bundle_id")
|
|
83
|
+
XCTAssertEqual(fixture.store.getCurrentBundleId(), "A")
|
|
84
|
+
try fixture.store.setStagedBundleId("C")
|
|
85
|
+
fixture.defaults.set("obsolete", forKey: "otakit_current_bundle_id")
|
|
86
|
+
let reopened = fixture.reopenStore()
|
|
87
|
+
XCTAssertEqual(reopened.getCurrentBundleId(), "A")
|
|
88
|
+
XCTAssertEqual(reopened.getFallbackBundleId(), "A")
|
|
89
|
+
XCTAssertEqual(reopened.getStagedBundleId(), "C")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func testCorruptStateCannotAuthorizeMutationOrCleanup() throws {
|
|
93
|
+
try fixture.installHealthy("A")
|
|
94
|
+
try fixture.apply("B")
|
|
95
|
+
try Data("{truncated".utf8).write(to: fixture.root.appendingPathComponent("otakit-state.json"))
|
|
96
|
+
XCTAssertThrowsError(try fixture.coordinator.normalizeStartupState(isBundleUsable: fixture.isUsable))
|
|
97
|
+
XCTAssertThrowsError(try fixture.store.setCurrentBundleId("C"))
|
|
98
|
+
fixture.coordinator.cleanupBundles(["A", "B"])
|
|
99
|
+
XCTAssertTrue(fixture.indexExists("A"))
|
|
100
|
+
XCTAssertTrue(fixture.indexExists("B"))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import UpdaterPlugin
|
|
4
|
+
|
|
5
|
+
final class CheckFailureTests: XCTestCase {
|
|
6
|
+
func testFailedCheckIsReportedOnceAndStillThrowsOriginalError() async throws {
|
|
7
|
+
var reports: [CheckFailure] = []
|
|
8
|
+
let original = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut,
|
|
9
|
+
userInfo: [NSLocalizedDescriptionKey: "https://secret.test?token=secret"])
|
|
10
|
+
do {
|
|
11
|
+
let _: String = try await CheckFailure.observe({ throw original }, report: { reports.append($0) })
|
|
12
|
+
XCTFail("Expected original error")
|
|
13
|
+
} catch { XCTAssertTrue((error as NSError) === original) }
|
|
14
|
+
XCTAssertEqual(reports.count, 1)
|
|
15
|
+
XCTAssertEqual(reports.first?.phase, "check")
|
|
16
|
+
XCTAssertEqual(reports.first?.detail, "manifest_network_\(NSURLErrorTimedOut)")
|
|
17
|
+
XCTAssertFalse(reports[0].detail.contains("secret"))
|
|
18
|
+
}
|
|
19
|
+
func testSuccessfulNoUpdateAndCancellationDoNotEmitErrors() async throws {
|
|
20
|
+
var reports: [CheckFailure] = []
|
|
21
|
+
let result: String? = try await CheckFailure.observe({ nil }, report: { reports.append($0) })
|
|
22
|
+
XCTAssertNil(result)
|
|
23
|
+
let manifest = try await CheckFailure.observe({ "manifest" }, report: { reports.append($0) })
|
|
24
|
+
XCTAssertEqual(manifest, "manifest")
|
|
25
|
+
do {
|
|
26
|
+
let _: String = try await CheckFailure.observe({ throw CancellationError() }, report: { reports.append($0) })
|
|
27
|
+
XCTFail("Expected cancellation")
|
|
28
|
+
} catch { XCTAssertTrue(error is CancellationError) }
|
|
29
|
+
XCTAssertTrue(reports.isEmpty)
|
|
30
|
+
XCTAssertNil(CheckFailure.from(URLError(.cancelled)))
|
|
31
|
+
}
|
|
32
|
+
func testHTTPFailureIncludesOnlyStatus() {
|
|
33
|
+
let error = CheckFailure.from(ManifestClientError.httpStatus(503))
|
|
34
|
+
XCTAssertEqual(error?.detail, "manifest_http_503")
|
|
35
|
+
XCTAssertEqual(error?.phase, "check")
|
|
36
|
+
}
|
|
37
|
+
func testRealVerifierExpiryAndUnknownKeyRemainTerminalAndAreDistinguishable() async throws {
|
|
38
|
+
for expired in [true, false] {
|
|
39
|
+
var reports: [CheckFailure] = []
|
|
40
|
+
let signature = ManifestSignature(kid: "private-key-label", sig: "invalid-signature", iat: 0,
|
|
41
|
+
exp: expired ? 0 : Int(Date().timeIntervalSince1970) + 600)
|
|
42
|
+
do {
|
|
43
|
+
try await CheckFailure.observe({
|
|
44
|
+
try ManifestVerifier.verify(appId: "app-a", channel: nil, version: "1.0", sha256: "hash", size: 1,
|
|
45
|
+
runtimeVersion: nil, strategy: "zip", forceImmediate: false, encryption: nil, signature: signature, trustedKeys: [])
|
|
46
|
+
}, report: { reports.append($0) })
|
|
47
|
+
XCTFail("Invalid signature accepted")
|
|
48
|
+
} catch { XCTAssertTrue(error is ManifestVerifierError) }
|
|
49
|
+
XCTAssertEqual(reports.count, 1)
|
|
50
|
+
XCTAssertEqual(reports.first?.phase, "signature")
|
|
51
|
+
XCTAssertEqual(reports.first?.detail, expired ? "signature_expired" : "signature_unknown_key")
|
|
52
|
+
XCTAssertFalse(reports[0].detail.contains("private-key-label"))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import UpdaterPlugin
|
|
4
|
+
|
|
5
|
+
final class DeltaCacheIntegrityTests: XCTestCase {
|
|
6
|
+
private var directory: URL!
|
|
7
|
+
private var cache: URL!
|
|
8
|
+
private let good = Data([1,2,3])
|
|
9
|
+
override func setUpWithError() throws {
|
|
10
|
+
directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
11
|
+
cache = directory.appendingPathComponent("cache")
|
|
12
|
+
try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true)
|
|
13
|
+
}
|
|
14
|
+
override func tearDownWithError() throws { try? FileManager.default.removeItem(at: directory) }
|
|
15
|
+
private func entry() throws -> ManifestFileEntry {
|
|
16
|
+
let source = directory.appendingPathComponent("source")
|
|
17
|
+
try good.write(to: source)
|
|
18
|
+
return ManifestFileEntry(path: "index.html", sha256: try HashUtils.sha256(fileURL: source), size: 3, url: "https://example.test/file")
|
|
19
|
+
}
|
|
20
|
+
private func download() throws -> URL {
|
|
21
|
+
let file = directory.appendingPathComponent(UUID().uuidString)
|
|
22
|
+
try good.write(to: file)
|
|
23
|
+
return file
|
|
24
|
+
}
|
|
25
|
+
func testCorruptedCacheIsRepairedBeforeAssembly() async throws {
|
|
26
|
+
let entry = try entry()
|
|
27
|
+
let cached = cache.appendingPathComponent(entry.sha256)
|
|
28
|
+
try Data([9,9,9]).write(to: cached)
|
|
29
|
+
var requests = 0
|
|
30
|
+
let assembler = DeltaAssembler(cacheDirectory: cache, downloader: Downloader(), fetch: { _ in
|
|
31
|
+
requests += 1; return try self.download()
|
|
32
|
+
})
|
|
33
|
+
let output = directory.appendingPathComponent("assembled")
|
|
34
|
+
try assembler.validate([entry], expectedFilesHash: DeltaAssembler.computeFilesHash([entry]))
|
|
35
|
+
try await assembler.assemble(entries: [entry], into: output)
|
|
36
|
+
XCTAssertEqual(try Data(contentsOf: output.appendingPathComponent("index.html")), good)
|
|
37
|
+
XCTAssertEqual(try Data(contentsOf: cached), good)
|
|
38
|
+
XCTAssertEqual(requests, 1)
|
|
39
|
+
}
|
|
40
|
+
func testValidCacheNeedsNoNetwork() async throws {
|
|
41
|
+
let entry = try entry()
|
|
42
|
+
try good.write(to: cache.appendingPathComponent(entry.sha256))
|
|
43
|
+
let assembler = DeltaAssembler(cacheDirectory: cache, downloader: Downloader(), fetch: { _ in
|
|
44
|
+
XCTFail("Network used for valid cache"); throw URLError(.notConnectedToInternet)
|
|
45
|
+
})
|
|
46
|
+
let output = directory.appendingPathComponent("assembled")
|
|
47
|
+
try await assembler.assemble(entries: [entry], into: output)
|
|
48
|
+
XCTAssertEqual(try Data(contentsOf: output.appendingPathComponent("index.html")), good)
|
|
49
|
+
}
|
|
50
|
+
func testDamagedCacheAndOfflineFailureCannotProduceBundle() async throws {
|
|
51
|
+
let entry = try entry()
|
|
52
|
+
try Data([9]).write(to: cache.appendingPathComponent(entry.sha256))
|
|
53
|
+
let assembler = DeltaAssembler(cacheDirectory: cache, downloader: Downloader(), fetch: { _ in throw URLError(.notConnectedToInternet) })
|
|
54
|
+
let output = directory.appendingPathComponent("assembled")
|
|
55
|
+
do { try await assembler.assemble(entries: [entry], into: output); XCTFail("Expected offline failure") }
|
|
56
|
+
catch { XCTAssertEqual((error as NSError).code, NSURLErrorNotConnectedToInternet) }
|
|
57
|
+
XCTAssertFalse(FileManager.default.fileExists(atPath: output.appendingPathComponent("index.html").path))
|
|
58
|
+
}
|
|
59
|
+
func testCorruptConcurrentWriterCannotBypassVerification() async throws {
|
|
60
|
+
let entry = try entry()
|
|
61
|
+
let assembler = DeltaAssembler(cacheDirectory: cache, downloader: Downloader(), fetch: { _ in
|
|
62
|
+
try Data([9]).write(to: self.cache.appendingPathComponent(entry.sha256))
|
|
63
|
+
return try self.download()
|
|
64
|
+
})
|
|
65
|
+
let output = directory.appendingPathComponent("assembled")
|
|
66
|
+
try await assembler.assemble(entries: [entry], into: output)
|
|
67
|
+
XCTAssertEqual(try Data(contentsOf: output.appendingPathComponent("index.html")), good)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
import Foundation
|
|
3
|
+
import UIKit
|
|
4
|
+
import WebKit
|
|
5
|
+
import XCTest
|
|
6
|
+
@testable import UpdaterPlugin
|
|
7
|
+
|
|
8
|
+
final class DocumentReadyBridgeTests: XCTestCase {
|
|
9
|
+
@MainActor
|
|
10
|
+
func testOldWebViewDocumentCannotConfirmReplacementUsingCapacitorBridge() async throws {
|
|
11
|
+
let fixture = try CoordinatorFixture()
|
|
12
|
+
defer { try? fixture.cleanup() }
|
|
13
|
+
try fixture.installHealthy("A")
|
|
14
|
+
let oldTrial = try XCTUnwrap(fixture.trial)
|
|
15
|
+
|
|
16
|
+
let configuration = WKWebViewConfiguration()
|
|
17
|
+
let controller = configuration.userContentController
|
|
18
|
+
let recorder = ReadyMessageRecorder()
|
|
19
|
+
controller.add(recorder, name: "bridge")
|
|
20
|
+
let capacitorBundle = Bundle(for: CAPBridgeViewController.self)
|
|
21
|
+
let nativeBridgeURL = try XCTUnwrap(capacitorBundle.url(forResource: "native-bridge", withExtension: "js"))
|
|
22
|
+
let nativeBridge = try String(contentsOf: nativeBridgeURL, encoding: .utf8)
|
|
23
|
+
controller.addUserScript(WKUserScript(source: nativeBridge, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
24
|
+
controller.addUserScript(WKUserScript(source: "window.otherPluginMarker = 42;", injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
|
25
|
+
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 320, height: 480), configuration: configuration)
|
|
26
|
+
let navigation = ReadyNavigationRecorder()
|
|
27
|
+
webView.navigationDelegate = navigation
|
|
28
|
+
let window: UIWindow
|
|
29
|
+
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
|
30
|
+
window = UIWindow(windowScene: scene)
|
|
31
|
+
} else {
|
|
32
|
+
window = UIWindow(frame: webView.frame)
|
|
33
|
+
}
|
|
34
|
+
let host = UIViewController()
|
|
35
|
+
host.view.addSubview(webView)
|
|
36
|
+
window.rootViewController = host
|
|
37
|
+
window.makeKeyAndVisible()
|
|
38
|
+
defer { window.isHidden = true; webView.removeFromSuperview() }
|
|
39
|
+
let readinessBridge = DocumentReadyBridge()
|
|
40
|
+
try readinessBridge.install(on: webView, activationId: oldTrial.activationId)
|
|
41
|
+
let readyJS = "Capacitor.nativePromise('OtaKit', 'notifyAppReady', {}).catch(() => {});"
|
|
42
|
+
let html = "<html><script>\(readyJS)</script></html>"
|
|
43
|
+
|
|
44
|
+
let firstReady = expectation(description: "Original document sends readiness")
|
|
45
|
+
recorder.onReady = { firstReady.fulfill() }
|
|
46
|
+
webView.loadHTMLString(html, baseURL: URL(string: "https://localhost"))
|
|
47
|
+
// Cold simulator WebKit startup is separate from the updater's readiness budget.
|
|
48
|
+
await fulfillment(of: [firstReady], timeout: 30)
|
|
49
|
+
guard recorder.activationId != nil else {
|
|
50
|
+
XCTFail("Initial WebView load: \(navigation.status); bridge messages: \(recorder.lastMessage)")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
XCTAssertEqual(recorder.activationId, oldTrial.activationId)
|
|
54
|
+
|
|
55
|
+
try fixture.apply("B")
|
|
56
|
+
let newTrial = try XCTUnwrap(fixture.trial)
|
|
57
|
+
try readinessBridge.install(on: webView, activationId: newTrial.activationId)
|
|
58
|
+
XCTAssertEqual(controller.userScripts.count, 3, "Keep other plugins and replace only our script")
|
|
59
|
+
let staleReady = expectation(description: "Old document calls after preparing replacement")
|
|
60
|
+
recorder.onReady = { staleReady.fulfill() }
|
|
61
|
+
_ = try await webView.evaluateJavaScript(readyJS + "'sent';")
|
|
62
|
+
await fulfillment(of: [staleReady], timeout: 30)
|
|
63
|
+
XCTAssertEqual(recorder.activationId, oldTrial.activationId)
|
|
64
|
+
XCTAssertNil(try fixture.coordinator.prepareNotifyAppReady(activationId: recorder.activationId).eventPayload)
|
|
65
|
+
XCTAssertEqual(fixture.store.getCurrentBundle().status, .trial)
|
|
66
|
+
XCTAssertTrue(fixture.indexExists("A"))
|
|
67
|
+
|
|
68
|
+
let replacementReady = expectation(description: "Replacement document gets its own activation")
|
|
69
|
+
recorder.onReady = { replacementReady.fulfill() }
|
|
70
|
+
webView.loadHTMLString(html, baseURL: URL(string: "https://localhost"))
|
|
71
|
+
await fulfillment(of: [replacementReady], timeout: 30)
|
|
72
|
+
XCTAssertEqual(recorder.activationId, newTrial.activationId)
|
|
73
|
+
XCTAssertNotNil(try fixture.coordinator.prepareNotifyAppReady(activationId: recorder.activationId).eventPayload)
|
|
74
|
+
let marker = try await webView.evaluateJavaScript("window.otherPluginMarker") as? Int
|
|
75
|
+
XCTAssertEqual(marker, 42)
|
|
76
|
+
webView.stopLoading()
|
|
77
|
+
controller.removeScriptMessageHandler(forName: "bridge")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private final class ReadyMessageRecorder: NSObject, WKScriptMessageHandler {
|
|
82
|
+
var activationId: String?
|
|
83
|
+
var onReady: (() -> Void)?
|
|
84
|
+
var lastMessage = "none"
|
|
85
|
+
|
|
86
|
+
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
87
|
+
lastMessage = String(describing: message.body)
|
|
88
|
+
guard let body = message.body as? [String: Any],
|
|
89
|
+
body["methodName"] as? String == "notifyAppReady" else { return }
|
|
90
|
+
activationId = (body["options"] as? [String: Any])?["_otakitActivationId"] as? String
|
|
91
|
+
onReady?()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private final class ReadyNavigationRecorder: NSObject, WKNavigationDelegate {
|
|
96
|
+
var status = "not started"
|
|
97
|
+
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { status = "started" }
|
|
98
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { status = "finished" }
|
|
99
|
+
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { status = "failed: \(error)" }
|
|
100
|
+
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { status = "provisional failure: \(error)" }
|
|
101
|
+
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { status = "content process terminated" }
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import UpdaterPlugin
|
|
4
|
+
|
|
5
|
+
final class DownloadIntegrityTests: XCTestCase {
|
|
6
|
+
func testMismatchIncludesHashesAndByteCountsWithoutPaths() throws {
|
|
7
|
+
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
8
|
+
try Data([1, 2, 3]).write(to: file)
|
|
9
|
+
defer { try? FileManager.default.removeItem(at: file) }
|
|
10
|
+
let expected = String(repeating: "0", count: 64)
|
|
11
|
+
let actual = try HashUtils.sha256(fileURL: file)
|
|
12
|
+
XCTAssertThrowsError(try HashUtils.verifyDownload(fileURL: file, expectedSha256: expected, expectedBytes: 10, kind: "bundle")) { error in
|
|
13
|
+
let detail = error.localizedDescription
|
|
14
|
+
XCTAssertTrue(detail.contains("hash mismatch"))
|
|
15
|
+
XCTAssertTrue(detail.contains("expectedSha256=\(expected)"))
|
|
16
|
+
XCTAssertTrue(detail.contains("actualSha256=\(actual)"))
|
|
17
|
+
XCTAssertTrue(detail.contains("expectedBytes=10; receivedBytes=3"))
|
|
18
|
+
XCTAssertFalse(detail.contains(file.path))
|
|
19
|
+
XCTAssertLessThan(detail.count, 500)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
func testSizeMismatchFailsEvenWithMatchingHash() throws {
|
|
23
|
+
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
24
|
+
try Data([1, 2, 3]).write(to: file)
|
|
25
|
+
defer { try? FileManager.default.removeItem(at: file) }
|
|
26
|
+
let hash = try HashUtils.sha256(fileURL: file)
|
|
27
|
+
XCTAssertThrowsError(try HashUtils.verifyDownload(fileURL: file, expectedSha256: hash, expectedBytes: 4, kind: "bundle"))
|
|
28
|
+
try HashUtils.verifyDownload(fileURL: file, expectedSha256: hash.uppercased(), expectedBytes: 3, kind: "bundle")
|
|
29
|
+
try HashUtils.verifyDownload(fileURL: file, expectedSha256: hash, expectedBytes: nil, kind: "bundle")
|
|
30
|
+
}
|
|
31
|
+
func testInvalidExpectedHashIsRedacted() throws {
|
|
32
|
+
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
33
|
+
try Data([1, 2, 3]).write(to: file)
|
|
34
|
+
defer { try? FileManager.default.removeItem(at: file) }
|
|
35
|
+
XCTAssertThrowsError(try HashUtils.verifyDownload(fileURL: file, expectedSha256: "https://secret.test?token=secret", expectedBytes: nil, kind: "bundle")) { error in
|
|
36
|
+
XCTAssertTrue(error.localizedDescription.contains("expectedSha256=invalid"))
|
|
37
|
+
XCTAssertFalse(error.localizedDescription.contains("secret"))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
import XCTest
|
|
4
|
+
@testable import UpdaterPlugin
|
|
5
|
+
|
|
6
|
+
final class DownloadRetryTests: XCTestCase {
|
|
7
|
+
func testOnlyActualHTTP403And410RefreshTheManifest() {
|
|
8
|
+
XCTAssertTrue(DownloadRetry.isExpiredURLFailure(DownloadHTTPError(status: 403, retryAfter: nil)))
|
|
9
|
+
XCTAssertTrue(DownloadRetry.isExpiredURLFailure(DownloadHTTPError(status: 410, retryAfter: nil)))
|
|
10
|
+
XCTAssertFalse(DownloadRetry.isExpiredURLFailure(DownloadHTTPError(status: 503, retryAfter: nil)))
|
|
11
|
+
XCTAssertFalse(DownloadRetry.isExpiredURLFailure(NSError(domain: "OtaKit", code: 1,
|
|
12
|
+
userInfo: [NSLocalizedDescriptionKey: "hash mismatch; actualSha256=abc403def410; receivedBytes=40300"])))
|
|
13
|
+
XCTAssertFalse(DownloadRetry.isExpiredURLFailure(NSError(domain: "OtaKit", code: 403,
|
|
14
|
+
userInfo: [NSLocalizedDescriptionKey: "forbidden local path or expired key"])))
|
|
15
|
+
}
|
|
16
|
+
func testTransientHTTPAndDisconnectedBodyRetryFreshDownloads() async throws {
|
|
17
|
+
let server = try DownloadHTTPServer(responses: [
|
|
18
|
+
.http(status: 503), .truncated, .success
|
|
19
|
+
])
|
|
20
|
+
let url = try await server.start()
|
|
21
|
+
defer { server.stop() }
|
|
22
|
+
var delays: [TimeInterval] = []
|
|
23
|
+
let downloader = Downloader(allowInsecureUrls: true,
|
|
24
|
+
retry: DownloadRetry(random: { 0.5 }), sleep: { delays.append($0) })
|
|
25
|
+
let downloaded = try await downloader.download(from: url)
|
|
26
|
+
defer { try? FileManager.default.removeItem(at: downloaded) }
|
|
27
|
+
XCTAssertEqual(try Data(contentsOf: downloaded), Data([1, 2, 3]))
|
|
28
|
+
XCTAssertEqual(server.requestCount, 3)
|
|
29
|
+
XCTAssertEqual(delays, [1, 2])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func testRetryAfterIsHonoredAndLongDelayIsNotRetriedEarly() async throws {
|
|
33
|
+
let server = try DownloadHTTPServer(responses: [.http(status: 429, retryAfter: "12"), .success])
|
|
34
|
+
let url = try await server.start()
|
|
35
|
+
defer { server.stop() }
|
|
36
|
+
var delays: [TimeInterval] = []
|
|
37
|
+
let downloader = Downloader(allowInsecureUrls: true,
|
|
38
|
+
retry: DownloadRetry(random: { 0.5 }), sleep: { delays.append($0) })
|
|
39
|
+
let downloaded = try await downloader.download(from: url)
|
|
40
|
+
try FileManager.default.removeItem(at: downloaded)
|
|
41
|
+
XCTAssertEqual(delays, [12])
|
|
42
|
+
XCTAssertNil(DownloadRetry().delay(for: DownloadHTTPError(status: 503, retryAfter: "120"), attempt: 1))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func testExhaustionStopsAfterThreeRequests() async throws {
|
|
46
|
+
let server = try DownloadHTTPServer(responses: [.http(status: 504)])
|
|
47
|
+
let url = try await server.start()
|
|
48
|
+
defer { server.stop() }
|
|
49
|
+
var delays: [TimeInterval] = []
|
|
50
|
+
let downloader = Downloader(allowInsecureUrls: true, sleep: { delays.append($0) })
|
|
51
|
+
do {
|
|
52
|
+
_ = try await downloader.download(from: url)
|
|
53
|
+
XCTFail("Expected terminal HTTP error")
|
|
54
|
+
} catch let error as DownloadHTTPError { XCTAssertEqual(error.status, 504) }
|
|
55
|
+
XCTAssertEqual(server.requestCount, 3)
|
|
56
|
+
XCTAssertEqual(delays.count, 2)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func testPermanentHTTPDoesNotRetry() async throws {
|
|
60
|
+
let server = try DownloadHTTPServer(responses: [.http(status: 403)])
|
|
61
|
+
let url = try await server.start()
|
|
62
|
+
defer { server.stop() }
|
|
63
|
+
let downloader = Downloader(allowInsecureUrls: true, sleep: { _ in XCTFail("Unexpected retry") })
|
|
64
|
+
do { _ = try await downloader.download(from: url); XCTFail("Expected HTTP 403") }
|
|
65
|
+
catch let error as DownloadHTTPError { XCTAssertEqual(error.status, 403) }
|
|
66
|
+
XCTAssertEqual(server.requestCount, 1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func testUnsolicitedPartialResponseIsRejected() async throws {
|
|
70
|
+
let server = try DownloadHTTPServer(responses: [.http(status: 206)])
|
|
71
|
+
let url = try await server.start()
|
|
72
|
+
defer { server.stop() }
|
|
73
|
+
do {
|
|
74
|
+
_ = try await Downloader(allowInsecureUrls: true).download(from: url)
|
|
75
|
+
XCTFail("A whole-object request must not accept a partial response")
|
|
76
|
+
} catch let error as DownloadHTTPError { XCTAssertEqual(error.status, 206) }
|
|
77
|
+
XCTAssertEqual(server.requestCount, 1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func testCancellationDuringBackoffStopsFurtherRequests() async throws {
|
|
81
|
+
let server = try DownloadHTTPServer(responses: [.http(status: 503)])
|
|
82
|
+
let url = try await server.start()
|
|
83
|
+
defer { server.stop() }
|
|
84
|
+
let waiting = expectation(description: "Retry backoff begins")
|
|
85
|
+
let downloader = Downloader(allowInsecureUrls: true, sleep: { _ in
|
|
86
|
+
waiting.fulfill()
|
|
87
|
+
try await Task.sleep(nanoseconds: 30_000_000_000)
|
|
88
|
+
})
|
|
89
|
+
let download = Task { try await downloader.download(from: url) }
|
|
90
|
+
await fulfillment(of: [waiting], timeout: 10)
|
|
91
|
+
download.cancel()
|
|
92
|
+
do { _ = try await download.value; XCTFail("Expected cancellation") }
|
|
93
|
+
catch { XCTAssertTrue(error is CancellationError) }
|
|
94
|
+
XCTAssertEqual(server.requestCount, 1)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func testPolicyRejectsCertificateDiskIntegrityAndCancelledErrors() {
|
|
98
|
+
let retry = DownloadRetry()
|
|
99
|
+
for code in [NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasBadDate,
|
|
100
|
+
NSURLErrorCancelled, NSURLErrorCannotWriteToFile] {
|
|
101
|
+
XCTAssertNil(retry.delay(for: NSError(domain: NSURLErrorDomain, code: code), attempt: 1))
|
|
102
|
+
}
|
|
103
|
+
XCTAssertNil(retry.delay(for: CocoaError(.fileWriteOutOfSpace), attempt: 1))
|
|
104
|
+
XCTAssertNil(retry.delay(for: NSError(domain: "OtaKit", code: 1,
|
|
105
|
+
userInfo: [NSLocalizedDescriptionKey: "hash mismatch"]), attempt: 1))
|
|
106
|
+
for status in [400, 401, 403, 404, 410] {
|
|
107
|
+
XCTAssertNil(retry.delay(for: DownloadHTTPError(status: status, retryAfter: nil), attempt: 1))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func testRetryAfterDateAndOverflow() {
|
|
112
|
+
XCTAssertEqual(DownloadRetry.retryAfter("Thu, 01 Jan 1970 00:00:12 GMT", now: Date(timeIntervalSince1970: 0)), 12)
|
|
113
|
+
XCTAssertEqual(DownloadRetry.retryAfter("Thu, 01 Jan 1970 00:00:12 GMT", now: Date(timeIntervalSince1970: 20)), 0)
|
|
114
|
+
XCTAssertNil(DownloadRetry.retryAfter("invalid", now: Date()))
|
|
115
|
+
XCTAssertNil(DownloadRetry.retryAfter("-1", now: Date()))
|
|
116
|
+
let huge = String(repeating: "9", count: 500)
|
|
117
|
+
XCTAssertNil(DownloadRetry().delay(for: DownloadHTTPError(status: 429, retryAfter: huge), attempt: 1))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Real loopback HTTP responses, including a body disconnected before Content-Length.
|
|
122
|
+
final class DownloadHTTPServer {
|
|
123
|
+
struct Response {
|
|
124
|
+
let status: Int
|
|
125
|
+
let retryAfter: String?
|
|
126
|
+
let body: Data
|
|
127
|
+
let advertisedLength: Int
|
|
128
|
+
static func http(status: Int, retryAfter: String? = nil) -> Response {
|
|
129
|
+
Response(status: status, retryAfter: retryAfter, body: Data(), advertisedLength: 0)
|
|
130
|
+
}
|
|
131
|
+
static let success = Response(status: 200, retryAfter: nil, body: Data([1, 2, 3]), advertisedLength: 3)
|
|
132
|
+
static let truncated = Response(status: 200, retryAfter: nil, body: Data([9, 9]), advertisedLength: 100)
|
|
133
|
+
}
|
|
134
|
+
private let listener: NWListener
|
|
135
|
+
private let responses: [Response]
|
|
136
|
+
private let lock = NSLock()
|
|
137
|
+
private var requests = 0
|
|
138
|
+
var requestCount: Int { lock.lock(); defer { lock.unlock() }; return requests }
|
|
139
|
+
init(responses: [Response]) throws {
|
|
140
|
+
self.responses = responses
|
|
141
|
+
let parameters = NWParameters.tcp
|
|
142
|
+
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
|
|
143
|
+
listener = try NWListener(using: parameters)
|
|
144
|
+
}
|
|
145
|
+
func start() async throws -> URL {
|
|
146
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
147
|
+
listener.stateUpdateHandler = { [weak self] state in
|
|
148
|
+
guard let self else { return }
|
|
149
|
+
switch state {
|
|
150
|
+
case .ready:
|
|
151
|
+
self.listener.stateUpdateHandler = nil
|
|
152
|
+
continuation.resume(returning: URL(string: "http://127.0.0.1:\(self.listener.port!.rawValue)/object")!)
|
|
153
|
+
case .failed(let error):
|
|
154
|
+
self.listener.stateUpdateHandler = nil
|
|
155
|
+
continuation.resume(throwing: error)
|
|
156
|
+
default: break
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
listener.newConnectionHandler = { [weak self] connection in
|
|
160
|
+
connection.start(queue: .global())
|
|
161
|
+
self?.receive(connection, previous: Data())
|
|
162
|
+
}
|
|
163
|
+
listener.start(queue: .global())
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
func stop() { listener.cancel() }
|
|
167
|
+
private func receive(_ connection: NWConnection, previous: Data) {
|
|
168
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, complete, error in
|
|
169
|
+
guard let self else { connection.cancel(); return }
|
|
170
|
+
var request = previous
|
|
171
|
+
if let data { request.append(data) }
|
|
172
|
+
guard request.range(of: Data("\r\n\r\n".utf8)) != nil else {
|
|
173
|
+
if complete || error != nil { connection.cancel() }
|
|
174
|
+
else { self.receive(connection, previous: request) }
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
self.lock.lock()
|
|
178
|
+
let response = self.responses[min(self.requests, self.responses.count - 1)]
|
|
179
|
+
self.requests += 1
|
|
180
|
+
self.lock.unlock()
|
|
181
|
+
var header = "HTTP/1.1 \(response.status) Test\r\nContent-Length: \(response.advertisedLength)\r\nConnection: close\r\nCache-Control: no-store\r\n"
|
|
182
|
+
if let retryAfter = response.retryAfter { header += "Retry-After: \(retryAfter)\r\n" }
|
|
183
|
+
var bytes = Data((header + "\r\n").utf8)
|
|
184
|
+
bytes.append(response.body)
|
|
185
|
+
connection.send(content: bytes, contentContext: .finalMessage, isComplete: true,
|
|
186
|
+
completion: .contentProcessed { _ in connection.cancel() })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|