@dvai-bridge/ios 4.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 (60) hide show
  1. package/DVAIBridge.podspec +120 -0
  2. package/LICENSE +51 -0
  3. package/Package.swift +104 -0
  4. package/README.md +199 -0
  5. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
  6. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
  7. package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
  8. package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
  9. package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
  10. package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
  11. package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
  12. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
  13. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
  14. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
  15. package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
  16. package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
  17. package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
  18. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
  19. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
  20. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
  21. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
  22. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
  23. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
  24. package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
  25. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
  26. package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
  27. package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
  28. package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
  29. package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
  30. package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
  31. package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
  32. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
  33. package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
  34. package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
  35. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
  36. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
  37. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
  38. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
  39. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
  40. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
  41. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
  42. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
  43. package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
  44. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
  45. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
  46. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
  47. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
  48. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
  49. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
  50. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
  51. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
  52. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
  53. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
  54. package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
  55. package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
  56. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
  57. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
  58. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
  59. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
  60. package/package.json +19 -0
@@ -0,0 +1,658 @@
1
+ /*
2
+ * Tests for the iOS offline JWT license validator.
3
+ *
4
+ * Mirrors `packages/dvai-bridge-core/src/__tests__/license.test.ts` —
5
+ * any difference in coverage between the two files is a real bug, not
6
+ * a stylistic choice. The wire format (JWT header + ES256 payload +
7
+ * P-256 signature) is identical across SDKs; the tests verify both
8
+ * happy paths and every documented failure mode against `validate()`
9
+ * (never throws) and `validateAndAssert()` (throws on
10
+ * free-prod / free-expired).
11
+ *
12
+ * Each test sets `DVAI_FORCE_PROD=1` so the dev-mode auto-bypass doesn't
13
+ * mask validation failures, and cleans up in `tearDown` so a test that
14
+ * wants to exercise the bypass branch can do so.
15
+ *
16
+ * All tests use a freshly-generated test ES256 keypair so they don't
17
+ * depend on the placeholder key in `PublicKeys.swift`. The injected
18
+ * `publicKeys` option on `LicenseValidatorOptions` makes that possible
19
+ * without touching the production registry.
20
+ */
21
+ import XCTest
22
+ import JWTKit
23
+ @testable import DVAIBridge
24
+
25
+ final class LicenseValidatorTests: XCTestCase {
26
+ /// Test-only ES256 keypair. Generated once per test invocation so
27
+ /// the signing operations stay fast. The matching public JWK is
28
+ /// injected into the validator via `LicenseValidatorOptions.publicKeys`.
29
+ private static let testKid = "test-kid-2026"
30
+ private var privateKey: ES256PrivateKey!
31
+ private var publicJwk: DvaiPublicKeyJwk!
32
+ private var publicKeys: [String: DvaiPublicKeyJwk]!
33
+
34
+ override func setUp() async throws {
35
+ try await super.setUp()
36
+ privateKey = ES256PrivateKey()
37
+ let params = privateKey.parameters!
38
+ publicJwk = DvaiPublicKeyJwk(
39
+ kty: "EC",
40
+ crv: "P-256",
41
+ // JWTKit's ECDSAParameters yields base64-standard-encoded
42
+ // x / y. The DvaiPublicKeyJwk we feed back into the
43
+ // validator goes through the same JWTKit path
44
+ // (`ECDSA.PublicKey<P256>(parameters:)`), which calls
45
+ // `base64URLDecodedData()` — JWTKit's base64URLDecodedData
46
+ // accepts both base64-standard and base64url, so this works.
47
+ x: params.x,
48
+ y: params.y,
49
+ alg: "ES256",
50
+ use: "sig",
51
+ kid: Self.testKid
52
+ )
53
+ publicKeys = [Self.testKid: publicJwk]
54
+ }
55
+
56
+ override func tearDown() async throws {
57
+ unsetenv("DVAI_FORCE_PROD")
58
+ unsetenv("DVAI_FORCE_DEV")
59
+ unsetenv("DVAI_LICENSE_TOKEN")
60
+ unsetenv("DVAI_LICENSE_PATH")
61
+ unsetenv("DVAI_AUDIENCE")
62
+ try await super.tearDown()
63
+ }
64
+
65
+ // MARK: - Mint helper
66
+
67
+ /// Mint a license JWT for tests.
68
+ private func mintLicense(
69
+ aud: [String] = ["*"],
70
+ platforms: [String] = ["ios", "android", "node", "web"],
71
+ tier: String = "commercial",
72
+ licensee: String = "Test Co",
73
+ exp: Int64? = nil,
74
+ iss: String = "DVAI-Bridge",
75
+ kid: String? = nil,
76
+ privateKeyOverride: ES256PrivateKey? = nil
77
+ ) async throws -> String {
78
+ let now = Int64(Date().timeIntervalSince1970)
79
+ let expValue = exp ?? (now + 30 * 24 * 3600) // +30 days
80
+ let payload = DvaiLicensePayload(
81
+ iss: iss,
82
+ sub: "test-license",
83
+ aud: aud,
84
+ tier: tier,
85
+ platforms: platforms,
86
+ licensee: licensee,
87
+ iat: now,
88
+ exp: expValue
89
+ )
90
+ let keys = JWTKeyCollection()
91
+ let keyKid = JWKIdentifier(string: kid ?? Self.testKid)
92
+ await keys.add(ecdsa: privateKeyOverride ?? privateKey, kid: keyKid)
93
+ var header = JWTHeader()
94
+ header.alg = "ES256"
95
+ header.typ = "JWT"
96
+ header.kid = keyKid.string
97
+ return try await keys.sign(payload, kid: keyKid, header: header)
98
+ }
99
+
100
+ // MARK: - Happy path
101
+
102
+ func testAcceptsWellFormedCommercialToken() async throws {
103
+ setenv("DVAI_FORCE_PROD", "1", 1)
104
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
105
+ let token = try await mintLicense(
106
+ aud: ["acme.com"],
107
+ platforms: ["ios"],
108
+ licensee: "Acme Inc"
109
+ )
110
+ let v = LicenseValidator(options: LicenseValidatorOptions(token: token, publicKeys: publicKeys))
111
+ let status = await v.validate()
112
+ guard case .commercial(let licensee, let expiresAt, let platform, let matched) = status else {
113
+ return XCTFail("expected .commercial, got \(status)")
114
+ }
115
+ XCTAssertEqual(licensee, "Acme Inc")
116
+ XCTAssertEqual(matched, "acme.com")
117
+ XCTAssertEqual(platform, .ios)
118
+ XCTAssertGreaterThan(expiresAt, Int64(Date().timeIntervalSince1970))
119
+ }
120
+
121
+ func testMatchesWildcardSubdomainAudience() async throws {
122
+ setenv("DVAI_FORCE_PROD", "1", 1)
123
+ setenv("DVAI_AUDIENCE", "app.acme.com", 1)
124
+ let token = try await mintLicense(aud: ["*.acme.com"], platforms: ["ios"])
125
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
126
+ token: token, publicKeys: publicKeys
127
+ )).validate()
128
+ guard case .commercial(_, _, _, let matched) = status else {
129
+ return XCTFail("expected .commercial, got \(status)")
130
+ }
131
+ XCTAssertEqual(matched, "*.acme.com")
132
+ }
133
+
134
+ func testMatchesStarAudienceForTrial() async throws {
135
+ setenv("DVAI_FORCE_PROD", "1", 1)
136
+ // Intentionally no DVAI_AUDIENCE — bundle id may also be nil in
137
+ // the test host. Either way, "*" must match.
138
+ unsetenv("DVAI_AUDIENCE")
139
+ let token = try await mintLicense(aud: ["*"], platforms: ["ios"], tier: "trial")
140
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
141
+ token: token, publicKeys: publicKeys
142
+ )).validate()
143
+ if case .trial = status {
144
+ // pass
145
+ } else {
146
+ XCTFail("expected .trial, got \(status)")
147
+ }
148
+ }
149
+
150
+ func testMatchesApexOfWildcardEntry() async throws {
151
+ setenv("DVAI_FORCE_PROD", "1", 1)
152
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
153
+ let token = try await mintLicense(aud: ["*.acme.com"], platforms: ["ios"])
154
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
155
+ token: token, publicKeys: publicKeys
156
+ )).validate()
157
+ if case .commercial = status {
158
+ // pass
159
+ } else {
160
+ XCTFail("expected .commercial, got \(status)")
161
+ }
162
+ }
163
+
164
+ // MARK: - Failure modes (each must collapse to a free-* status, never throw)
165
+
166
+ func testFreeProdOnTamperedSignature() async throws {
167
+ setenv("DVAI_FORCE_PROD", "1", 1)
168
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
169
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
170
+ let parts = token.split(separator: ".", omittingEmptySubsequences: false)
171
+ XCTAssertEqual(parts.count, 3)
172
+ // Flip a byte in the payload segment to break the signature.
173
+ var payloadSeg = String(parts[1])
174
+ payloadSeg = String(payloadSeg.dropLast(2)) + "XX"
175
+ let corrupted = "\(parts[0]).\(payloadSeg).\(parts[2])"
176
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
177
+ token: corrupted, publicKeys: publicKeys
178
+ )).validate()
179
+ guard case .freeProd(let reason) = status else {
180
+ return XCTFail("expected .freeProd, got \(status)")
181
+ }
182
+ let lower = reason.lowercased()
183
+ XCTAssertTrue(
184
+ lower.contains("signature") || lower.contains("verification") || lower.contains("malformed") || lower.contains("parseable"),
185
+ "reason should mention signature/verification/malformed: \(reason)"
186
+ )
187
+ }
188
+
189
+ func testFreeExpiredWhenExpInPast() async throws {
190
+ setenv("DVAI_FORCE_PROD", "1", 1)
191
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
192
+ let pastSeconds = Int64(Date().timeIntervalSince1970) - 3600
193
+ let token = try await mintLicense(
194
+ aud: ["acme.com"],
195
+ platforms: ["ios"],
196
+ licensee: "Expired Co",
197
+ exp: pastSeconds
198
+ )
199
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
200
+ token: token, publicKeys: publicKeys
201
+ )).validate()
202
+ guard case .freeExpired(let licensee, let expiredAt) = status else {
203
+ return XCTFail("expected .freeExpired, got \(status)")
204
+ }
205
+ XCTAssertEqual(licensee, "Expired Co")
206
+ XCTAssertLessThan(expiredAt, Int64(Date().timeIntervalSince1970))
207
+ }
208
+
209
+ func testFreeProdOnAudienceMismatch() async throws {
210
+ setenv("DVAI_FORCE_PROD", "1", 1)
211
+ setenv("DVAI_AUDIENCE", "widget.io", 1)
212
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
213
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
214
+ token: token, publicKeys: publicKeys
215
+ )).validate()
216
+ guard case .freeProd(let reason) = status else {
217
+ return XCTFail("expected .freeProd, got \(status)")
218
+ }
219
+ XCTAssertTrue(reason.contains("audience"), "reason: \(reason)")
220
+ XCTAssertTrue(reason.contains("widget.io"), "reason: \(reason)")
221
+ }
222
+
223
+ func testFreeProdOnPlatformMismatch() async throws {
224
+ setenv("DVAI_FORCE_PROD", "1", 1)
225
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
226
+ let token = try await mintLicense(
227
+ aud: ["acme.com"],
228
+ platforms: ["android", "node"] // not ios
229
+ )
230
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
231
+ token: token, publicKeys: publicKeys
232
+ )).validate()
233
+ guard case .freeProd(let reason) = status else {
234
+ return XCTFail("expected .freeProd, got \(status)")
235
+ }
236
+ XCTAssertTrue(reason.contains("platform"), "reason: \(reason)")
237
+ XCTAssertTrue(reason.contains("ios"), "reason: \(reason)")
238
+ }
239
+
240
+ func testFreeProdOnUnknownKid() async throws {
241
+ setenv("DVAI_FORCE_PROD", "1", 1)
242
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
243
+ let token = try await mintLicense(
244
+ aud: ["acme.com"],
245
+ platforms: ["ios"],
246
+ kid: "unknown-kid-2099"
247
+ )
248
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
249
+ token: token, publicKeys: publicKeys
250
+ )).validate()
251
+ guard case .freeProd(let reason) = status else {
252
+ return XCTFail("expected .freeProd, got \(status)")
253
+ }
254
+ XCTAssertTrue(reason.contains("unknown-kid-2099"), "reason: \(reason)")
255
+ XCTAssertTrue(reason.contains("registry"), "reason: \(reason)")
256
+ }
257
+
258
+ func testRefusesPlaceholderKidUnlessOptedIn() async throws {
259
+ setenv("DVAI_FORCE_PROD", "1", 1)
260
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
261
+ let token = try await mintLicense(
262
+ aud: ["acme.com"],
263
+ platforms: ["ios"],
264
+ kid: DVAI_PLACEHOLDER_KID
265
+ )
266
+ // Register our test key under the placeholder kid so the
267
+ // signature would otherwise verify — the refusal must come from
268
+ // the placeholder-key check, not from "no such key".
269
+ let registry = [
270
+ DVAI_PLACEHOLDER_KID: DvaiPublicKeyJwk(
271
+ kty: publicJwk.kty,
272
+ crv: publicJwk.crv,
273
+ x: publicJwk.x,
274
+ y: publicJwk.y,
275
+ alg: publicJwk.alg,
276
+ use: publicJwk.use,
277
+ kid: DVAI_PLACEHOLDER_KID
278
+ )
279
+ ]
280
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
281
+ token: token, publicKeys: registry
282
+ )).validate()
283
+ guard case .freeProd(let reason) = status else {
284
+ return XCTFail("expected .freeProd, got \(status)")
285
+ }
286
+ XCTAssertTrue(reason.contains("placeholder"), "reason: \(reason)")
287
+ }
288
+
289
+ func testAcceptsPlaceholderKidWhenOptedIn() async throws {
290
+ setenv("DVAI_FORCE_PROD", "1", 1)
291
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
292
+ let token = try await mintLicense(
293
+ aud: ["acme.com"],
294
+ platforms: ["ios"],
295
+ kid: DVAI_PLACEHOLDER_KID
296
+ )
297
+ let registry = [
298
+ DVAI_PLACEHOLDER_KID: DvaiPublicKeyJwk(
299
+ kty: publicJwk.kty,
300
+ crv: publicJwk.crv,
301
+ x: publicJwk.x,
302
+ y: publicJwk.y,
303
+ alg: publicJwk.alg,
304
+ use: publicJwk.use,
305
+ kid: DVAI_PLACEHOLDER_KID
306
+ )
307
+ ]
308
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
309
+ token: token,
310
+ publicKeys: registry,
311
+ allowPlaceholderKey: true
312
+ )).validate()
313
+ if case .commercial = status {
314
+ // pass
315
+ } else {
316
+ XCTFail("expected .commercial with allowPlaceholderKey, got \(status)")
317
+ }
318
+ }
319
+
320
+ func testRejectsAlgNoneAndAlgHS256() async throws {
321
+ setenv("DVAI_FORCE_PROD", "1", 1)
322
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
323
+ // Hand-craft an alg=none token. The validator MUST refuse before
324
+ // ever trying to verify a signature, because a malicious token
325
+ // with alg=none would otherwise be accepted on the empty
326
+ // signature segment.
327
+ let header = """
328
+ {"alg":"none","typ":"JWT","kid":"\(Self.testKid)"}
329
+ """
330
+ let payload = """
331
+ {"iss":"DVAI-Bridge","sub":"x","aud":["acme.com"],"tier":"commercial","platforms":["ios"],"licensee":"Evil Co","iat":\(Int(Date().timeIntervalSince1970)),"exp":\(Int(Date().timeIntervalSince1970) + 3600)}
332
+ """
333
+ let h64 = Self.base64Url(header.data(using: .utf8)!)
334
+ let p64 = Self.base64Url(payload.data(using: .utf8)!)
335
+ let noneToken = "\(h64).\(p64)."
336
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
337
+ token: noneToken, publicKeys: publicKeys
338
+ )).validate()
339
+ guard case .freeProd(let reason) = status else {
340
+ return XCTFail("expected .freeProd, got \(status)")
341
+ }
342
+ XCTAssertTrue(reason.contains("ES256"), "reason: \(reason)")
343
+ }
344
+
345
+ func testFreeProdOnMalformedTokenSegments() async {
346
+ setenv("DVAI_FORCE_PROD", "1", 1)
347
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
348
+ token: "not.a.valid.jwt", publicKeys: publicKeys
349
+ )).validate()
350
+ if case .freeProd = status {
351
+ // pass
352
+ } else {
353
+ XCTFail("expected .freeProd, got \(status)")
354
+ }
355
+ }
356
+
357
+ func testFreeProdWhenNoTokenAndNoDiscovery() async {
358
+ setenv("DVAI_FORCE_PROD", "1", 1)
359
+ // No token, no path, and the bundle has no dvai-license.jwt
360
+ // resource bundled in the test target — auto-discovery should
361
+ // turn up empty.
362
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
363
+ publicKeys: publicKeys
364
+ )).validate()
365
+ guard case .freeProd(let reason) = status else {
366
+ return XCTFail("expected .freeProd, got \(status)")
367
+ }
368
+ XCTAssertTrue(reason.contains("no license token found"), "reason: \(reason)")
369
+ }
370
+
371
+ // MARK: - Dev mode bypass
372
+
373
+ func testReturnsFreeDevWhenDVAIForceDevSet() async {
374
+ setenv("DVAI_FORCE_DEV", "1", 1)
375
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
376
+ publicKeys: publicKeys
377
+ )).validate()
378
+ if case .freeDev = status {
379
+ // pass
380
+ } else {
381
+ XCTFail("expected .freeDev, got \(status)")
382
+ }
383
+ }
384
+
385
+ func testReturnsFreeDevInDebugBuildOrSimulator() async {
386
+ // DVAI_FORCE_PROD is intentionally NOT set here. On a typical
387
+ // test run the build is DEBUG and/or the simulator, so the
388
+ // dev-mode bypass should fire. The validator's detectDevMode()
389
+ // returns isDev=true for any of: DVAI_FORCE_DEV, #if DEBUG,
390
+ // #if targetEnvironment(simulator).
391
+ unsetenv("DVAI_FORCE_PROD")
392
+ unsetenv("DVAI_FORCE_DEV")
393
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
394
+ publicKeys: publicKeys
395
+ )).validate()
396
+ // In a DEBUG test build this is .freeDev. In a Release-config
397
+ // run of the tests (unusual but valid), we expect .freeProd
398
+ // because there's no license token in the test bundle either.
399
+ // Accept either — the substantive check is that no signature
400
+ // verification ran (no crash on missing publicKeys).
401
+ switch status {
402
+ case .freeDev, .freeProd:
403
+ // pass
404
+ break
405
+ default:
406
+ XCTFail("expected .freeDev or .freeProd, got \(status)")
407
+ }
408
+ }
409
+
410
+ // MARK: - File discovery
411
+
412
+ func testLoadsFromExplicitPath() async throws {
413
+ setenv("DVAI_FORCE_PROD", "1", 1)
414
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
415
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
416
+ let tmp = try createTempLicenseFile(contents: token)
417
+ defer { try? FileManager.default.removeItem(at: tmp) }
418
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
419
+ path: tmp.path, publicKeys: publicKeys
420
+ )).validate()
421
+ if case .commercial = status {
422
+ // pass
423
+ } else {
424
+ XCTFail("expected .commercial, got \(status)")
425
+ }
426
+ }
427
+
428
+ func testLoadsFromDVAILicenseTokenEnvVar() async throws {
429
+ setenv("DVAI_FORCE_PROD", "1", 1)
430
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
431
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
432
+ setenv("DVAI_LICENSE_TOKEN", token, 1)
433
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
434
+ publicKeys: publicKeys
435
+ )).validate()
436
+ if case .commercial = status {
437
+ // pass
438
+ } else {
439
+ XCTFail("expected .commercial, got \(status)")
440
+ }
441
+ }
442
+
443
+ func testLoadsFromDVAILicensePathEnvVar() async throws {
444
+ setenv("DVAI_FORCE_PROD", "1", 1)
445
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
446
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
447
+ let tmp = try createTempLicenseFile(contents: token)
448
+ defer { try? FileManager.default.removeItem(at: tmp) }
449
+ setenv("DVAI_LICENSE_PATH", tmp.path, 1)
450
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
451
+ publicKeys: publicKeys
452
+ )).validate()
453
+ if case .commercial = status {
454
+ // pass
455
+ } else {
456
+ XCTFail("expected .commercial, got \(status)")
457
+ }
458
+ }
459
+
460
+ func testFreeProdWhenExplicitPathDoesNotExist() async {
461
+ setenv("DVAI_FORCE_PROD", "1", 1)
462
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
463
+ path: "/nonexistent/path/dvai-license.jwt",
464
+ publicKeys: publicKeys
465
+ )).validate()
466
+ if case .freeProd = status {
467
+ // pass
468
+ } else {
469
+ XCTFail("expected .freeProd, got \(status)")
470
+ }
471
+ }
472
+
473
+ func testInlineTokenWinsOverPath() async throws {
474
+ setenv("DVAI_FORCE_PROD", "1", 1)
475
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
476
+ let inline = try await mintLicense(
477
+ aud: ["acme.com"],
478
+ platforms: ["ios"],
479
+ licensee: "Inline Co"
480
+ )
481
+ let status = await LicenseValidator(options: LicenseValidatorOptions(
482
+ token: inline,
483
+ path: "/nonexistent/path/dvai-license.jwt",
484
+ publicKeys: publicKeys
485
+ )).validate()
486
+ guard case .commercial(let licensee, _, _, _) = status else {
487
+ return XCTFail("expected .commercial, got \(status)")
488
+ }
489
+ XCTAssertEqual(licensee, "Inline Co")
490
+ }
491
+
492
+ // MARK: - validateAndAssert (BSL 1.1 enforcement)
493
+
494
+ func testValidateAndAssertReturnsCommercialWithoutThrowing() async throws {
495
+ setenv("DVAI_FORCE_PROD", "1", 1)
496
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
497
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
498
+ let status = try await LicenseValidator(options: LicenseValidatorOptions(
499
+ token: token, publicKeys: publicKeys
500
+ )).validateAndAssert()
501
+ if case .commercial = status {
502
+ // pass
503
+ } else {
504
+ XCTFail("expected .commercial, got \(status)")
505
+ }
506
+ }
507
+
508
+ func testValidateAndAssertReturnsTrialWithoutThrowing() async throws {
509
+ setenv("DVAI_FORCE_PROD", "1", 1)
510
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
511
+ let token = try await mintLicense(
512
+ aud: ["acme.com"],
513
+ platforms: ["ios"],
514
+ tier: "trial"
515
+ )
516
+ let status = try await LicenseValidator(options: LicenseValidatorOptions(
517
+ token: token, publicKeys: publicKeys
518
+ )).validateAndAssert()
519
+ if case .trial = status {
520
+ // pass
521
+ } else {
522
+ XCTFail("expected .trial, got \(status)")
523
+ }
524
+ }
525
+
526
+ func testValidateAndAssertReturnsFreeDevWithoutThrowing() async throws {
527
+ setenv("DVAI_FORCE_DEV", "1", 1)
528
+ let status = try await LicenseValidator(options: LicenseValidatorOptions(
529
+ publicKeys: publicKeys
530
+ )).validateAndAssert()
531
+ if case .freeDev = status {
532
+ // pass
533
+ } else {
534
+ XCTFail("expected .freeDev, got \(status)")
535
+ }
536
+ }
537
+
538
+ func testValidateAndAssertThrowsOnMissingLicense() async {
539
+ setenv("DVAI_FORCE_PROD", "1", 1)
540
+ let v = LicenseValidator(options: LicenseValidatorOptions(publicKeys: publicKeys))
541
+ do {
542
+ _ = try await v.validateAndAssert()
543
+ XCTFail("should have thrown")
544
+ } catch let error as LicenseRequiredError {
545
+ if case .freeProd = error.status {
546
+ // pass
547
+ } else {
548
+ XCTFail("expected status .freeProd, got \(error.status)")
549
+ }
550
+ XCTAssertTrue(error.message.contains("Commercial License Required"), "message: \(error.message)")
551
+ XCTAssertTrue(error.message.contains("dvai-license.jwt"), "message: \(error.message)")
552
+ XCTAssertTrue(error.message.contains("DVAI_LICENSE_PATH"), "message: \(error.message)")
553
+ } catch {
554
+ XCTFail("expected LicenseRequiredError, got \(error)")
555
+ }
556
+ }
557
+
558
+ func testValidateAndAssertThrowsOnExpiredLicense() async throws {
559
+ setenv("DVAI_FORCE_PROD", "1", 1)
560
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
561
+ let pastSeconds = Int64(Date().timeIntervalSince1970) - 3600
562
+ let token = try await mintLicense(
563
+ aud: ["acme.com"],
564
+ platforms: ["ios"],
565
+ licensee: "Expired Co",
566
+ exp: pastSeconds
567
+ )
568
+ let v = LicenseValidator(options: LicenseValidatorOptions(
569
+ token: token, publicKeys: publicKeys
570
+ ))
571
+ do {
572
+ _ = try await v.validateAndAssert()
573
+ XCTFail("should have thrown")
574
+ } catch let error as LicenseRequiredError {
575
+ if case .freeExpired = error.status {
576
+ // pass
577
+ } else {
578
+ XCTFail("expected status .freeExpired, got \(error.status)")
579
+ }
580
+ XCTAssertTrue(error.message.contains("Expired Co"), "message: \(error.message)")
581
+ } catch {
582
+ XCTFail("expected LicenseRequiredError, got \(error)")
583
+ }
584
+ }
585
+
586
+ func testValidateAndAssertThrowsOnTamperedToken() async throws {
587
+ setenv("DVAI_FORCE_PROD", "1", 1)
588
+ setenv("DVAI_AUDIENCE", "acme.com", 1)
589
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
590
+ let parts = token.split(separator: ".", omittingEmptySubsequences: false)
591
+ let corrupted = "\(parts[0]).\(String(parts[1]).dropLast(2))XX.\(parts[2])"
592
+ let v = LicenseValidator(options: LicenseValidatorOptions(
593
+ token: corrupted, publicKeys: publicKeys
594
+ ))
595
+ do {
596
+ _ = try await v.validateAndAssert()
597
+ XCTFail("should have thrown")
598
+ } catch is LicenseRequiredError {
599
+ // pass
600
+ } catch {
601
+ XCTFail("expected LicenseRequiredError, got \(error)")
602
+ }
603
+ }
604
+
605
+ func testValidateAndAssertThrowsOnAudienceMismatch() async throws {
606
+ setenv("DVAI_FORCE_PROD", "1", 1)
607
+ setenv("DVAI_AUDIENCE", "widget.io", 1)
608
+ let token = try await mintLicense(aud: ["acme.com"], platforms: ["ios"])
609
+ let v = LicenseValidator(options: LicenseValidatorOptions(
610
+ token: token, publicKeys: publicKeys
611
+ ))
612
+ do {
613
+ _ = try await v.validateAndAssert()
614
+ XCTFail("should have thrown")
615
+ } catch is LicenseRequiredError {
616
+ // pass
617
+ } catch {
618
+ XCTFail("expected LicenseRequiredError, got \(error)")
619
+ }
620
+ }
621
+
622
+ func testValidateAndAssertDoesNotThrowInDevMode() async throws {
623
+ // The dev-mode bypass short-circuits BEFORE any token
624
+ // verification, so a developer in DEBUG / DVAI_FORCE_DEV never
625
+ // sees a license error — even with a syntactically invalid token.
626
+ setenv("DVAI_FORCE_DEV", "1", 1)
627
+ let v = LicenseValidator(options: LicenseValidatorOptions(
628
+ token: "not-even-a-jwt", publicKeys: publicKeys
629
+ ))
630
+ let status = try await v.validateAndAssert()
631
+ if case .freeDev = status {
632
+ // pass
633
+ } else {
634
+ XCTFail("expected .freeDev, got \(status)")
635
+ }
636
+ }
637
+
638
+ // MARK: - Helpers
639
+
640
+ /// Write `contents` to a unique temp file and return its URL.
641
+ private func createTempLicenseFile(contents: String) throws -> URL {
642
+ let dir = FileManager.default.temporaryDirectory
643
+ .appendingPathComponent("dvai-license-tests-\(UUID().uuidString)", isDirectory: true)
644
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
645
+ let file = dir.appendingPathComponent("dvai-license.jwt")
646
+ try contents.write(to: file, atomically: true, encoding: .utf8)
647
+ return file
648
+ }
649
+
650
+ /// Base64url-encode raw bytes (no padding, `-`/`_`).
651
+ private static func base64Url(_ data: Data) -> String {
652
+ var s = data.base64EncodedString()
653
+ s = s.replacingOccurrences(of: "+", with: "-")
654
+ s = s.replacingOccurrences(of: "/", with: "_")
655
+ while s.hasSuffix("=") { s.removeLast() }
656
+ return s
657
+ }
658
+ }