@dvai-bridge/ios 4.0.0 → 4.0.2
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/Package.swift +104 -104
- package/ios/Sources/DVAIBridge/BackendKind.swift +23 -23
- package/ios/Sources/DVAIBridge/BoundServer.swift +46 -46
- package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -658
- package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -86
- package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -33
- package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -59
- package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -84
- package/ios/Sources/DVAIBridge/License/Audience.swift +133 -133
- package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -164
- package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -392
- package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -114
- package/ios/Sources/DVAIBridge/License/Types.swift +195 -195
- package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -118
- package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -34
- package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -19
- package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -123
- package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -130
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -137
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -108
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -96
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -69
- package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -53
- package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -18
- package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -11
- package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -32
- package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -41
- package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -40
- package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -19
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -37
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -52
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -33
- package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -658
- package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -69
- package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -25
- package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -45
- package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +385 -359
- package/package.json +3 -4
- package/DVAIBridge.podspec +0 -120
- package/LICENSE +0 -51
- package/README.md +0 -199
|
@@ -1,658 +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
|
-
}
|
|
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
|
+
}
|