@otakit/capacitor-updater 2.3.2 → 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.
Files changed (57) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
  22. package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
  23. package/dist/esm/definitions.d.ts +3 -3
  24. package/dist/esm/definitions.d.ts.map +1 -1
  25. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  26. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  27. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  28. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  29. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  30. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  31. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  32. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  33. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  34. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  35. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  36. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  37. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  38. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  40. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  41. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  44. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  45. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  46. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  47. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  48. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  50. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  51. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  52. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  53. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  54. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  56. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  57. 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
+ }