@dvai-bridge/android 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.
@@ -1,539 +1,539 @@
1
- package co.deepvoiceai.bridge.license
2
-
3
- import android.content.Context
4
- import com.nimbusds.jose.JOSEObjectType
5
- import com.nimbusds.jose.JWSAlgorithm
6
- import com.nimbusds.jose.JWSHeader
7
- import com.nimbusds.jose.crypto.ECDSASigner
8
- import com.nimbusds.jose.jwk.Curve
9
- import com.nimbusds.jose.jwk.ECKey
10
- import com.nimbusds.jose.jwk.gen.ECKeyGenerator
11
- import com.nimbusds.jwt.JWTClaimsSet
12
- import com.nimbusds.jwt.SignedJWT
13
- import kotlinx.coroutines.test.runTest
14
- import org.junit.Assert.assertEquals
15
- import org.junit.Assert.assertNotNull
16
- import org.junit.Assert.assertTrue
17
- import org.junit.Assert.fail
18
- import org.junit.Before
19
- import org.junit.BeforeClass
20
- import org.junit.Test
21
- import org.junit.runner.RunWith
22
- import org.robolectric.RobolectricTestRunner
23
- import org.robolectric.RuntimeEnvironment
24
- import org.robolectric.annotation.Config
25
- import java.util.Date
26
- import java.util.UUID
27
-
28
- /**
29
- * Tests for the JWT-based license validator on Android.
30
- *
31
- * Direct port of `packages/dvai-bridge-core/src/__tests__/license.test.ts` —
32
- * same scenarios, same expectations, same coverage of failure modes.
33
- *
34
- * Two APIs are tested:
35
- * - `validate()` — never throws; returns `LicenseStatus`.
36
- * - `validateAndAssert()` — throws `LicenseRequiredError` for
37
- * `FreeProd` / `FreeExpired`. This is the
38
- * BSL 1.1 enforcement entry point used by
39
- * `DVAIBridge.start()`.
40
- *
41
- * Runs under Robolectric so `android.content.Context` (audience detection,
42
- * discovery) and `android.util.Base64` (JWT header decode) are available
43
- * without an emulator. The test keypair is generated once per class so we
44
- * don't re-derive on every case.
45
- *
46
- * Every test that exercises a non-dev-mode code path passes
47
- * `hostBuildConfigDebug = false` explicitly via [prodValidator]. Robolectric
48
- * applications default to `ApplicationInfo.FLAG_DEBUGGABLE` set, which
49
- * would otherwise collapse every test into a `FreeDev` bypass.
50
- */
51
- @RunWith(RobolectricTestRunner::class)
52
- @Config(sdk = [34])
53
- class LicenseValidatorTest {
54
-
55
- companion object {
56
- private const val TEST_KID = "test-kid-2026"
57
- private lateinit var ecJwk: ECKey
58
- private lateinit var publicKeys: Map<String, DvaiPublicKeyJwk>
59
-
60
- @BeforeClass
61
- @JvmStatic
62
- fun generateTestKeyPair() {
63
- // ES256 / P-256 keypair, mirrors the JS test setup.
64
- ecJwk = ECKeyGenerator(Curve.P_256)
65
- .keyID(TEST_KID)
66
- .generate()
67
- val pub = ecJwk.toPublicJWK()
68
- publicKeys = mapOf(
69
- TEST_KID to DvaiPublicKeyJwk(
70
- kty = "EC",
71
- crv = "P-256",
72
- x = pub.x.toString(),
73
- y = pub.y.toString(),
74
- alg = "ES256",
75
- use = "sig",
76
- kid = TEST_KID,
77
- ),
78
- )
79
- }
80
- }
81
-
82
- private lateinit var context: Context
83
-
84
- @Before
85
- fun setup() {
86
- context = RuntimeEnvironment.getApplication()
87
- }
88
-
89
- /**
90
- * Build a [LicenseValidator] with production-mode defaults so tests
91
- * exercise the license-required branches. Robolectric defaults
92
- * `ApplicationInfo.FLAG_DEBUGGABLE` to set, which would otherwise
93
- * collapse every test into a `FreeDev` bypass; pass
94
- * `hostBuildConfigDebug = false` explicitly to override.
95
- *
96
- * Dev-mode tests construct [LicenseValidator] directly with
97
- * `hostBuildConfigDebug = true`.
98
- */
99
- private fun prodValidator(
100
- token: String? = null,
101
- path: String? = null,
102
- registry: Map<String, DvaiPublicKeyJwk> = publicKeys,
103
- allowPlaceholderKey: Boolean = false,
104
- ): LicenseValidator = LicenseValidator(
105
- context = context,
106
- token = token,
107
- path = path,
108
- hostBuildConfigDebug = false,
109
- publicKeys = registry,
110
- allowPlaceholderKey = allowPlaceholderKey,
111
- )
112
-
113
- /** Mint a license JWT for tests. Mirrors `mintLicense` in the JS suite. */
114
- private fun mintLicense(
115
- aud: List<String> = listOf("*"),
116
- platforms: List<String> = listOf("node", "web", "ios", "android"),
117
- tier: String = "commercial",
118
- licensee: String = "Test Co",
119
- expSecondsFromNow: Long = 30L * 24 * 3600,
120
- absoluteExpSeconds: Long? = null,
121
- iss: String = "DVAI-Bridge",
122
- kid: String = TEST_KID,
123
- signWith: ECKey = ecJwk,
124
- ): String {
125
- val nowSec = System.currentTimeMillis() / 1000
126
- val exp = absoluteExpSeconds ?: (nowSec + expSecondsFromNow)
127
- val claims = JWTClaimsSet.Builder()
128
- .issuer(iss)
129
- .subject("test-license")
130
- .audience(aud)
131
- .issueTime(Date(nowSec * 1000))
132
- .expirationTime(Date(exp * 1000))
133
- .claim("tier", tier)
134
- .claim("licensee", licensee)
135
- .claim("platforms", platforms)
136
- .build()
137
- val header = JWSHeader.Builder(JWSAlgorithm.ES256)
138
- .type(JOSEObjectType.JWT)
139
- .keyID(kid)
140
- .build()
141
- val signed = SignedJWT(header, claims)
142
- signed.sign(ECDSASigner(signWith))
143
- return signed.serialize()
144
- }
145
-
146
- /* ------------------------------------------------------------------ */
147
- /* Happy path */
148
- /* ------------------------------------------------------------------ */
149
-
150
- @Test
151
- fun `accepts a well-formed commercial token and reports licensee + expiry`() = runTest {
152
- val token = mintLicense(
153
- aud = listOf(context.packageName),
154
- platforms = listOf("android"),
155
- licensee = "Acme Inc",
156
- )
157
- val status = prodValidator(token = token).validate()
158
- assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
159
- val c = status as LicenseStatus.Commercial
160
- assertEquals("Acme Inc", c.licensee)
161
- assertEquals(context.packageName, c.audienceMatched)
162
- assertEquals(DvaiPlatform.ANDROID, c.platform)
163
- assertTrue(c.expiresAt > System.currentTimeMillis() / 1000)
164
- }
165
-
166
- @Test
167
- fun `matches wildcard subdomain audience entries`() = runTest {
168
- // Android audience is the package name; package names use dot-
169
- // separated reverse-domain notation so the same `*.acme.com` rule
170
- // applies to `app.acme.com`-shaped package names.
171
- val pkg = context.packageName
172
- val parts = pkg.split(".")
173
- if (parts.size < 2) return@runTest // can't build a wildcard from a 1-segment package
174
- val suffix = parts.drop(1).joinToString(".")
175
- val token = mintLicense(
176
- aud = listOf("*.$suffix"),
177
- platforms = listOf("android"),
178
- )
179
- val status = prodValidator(token = token).validate()
180
- assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
181
- assertEquals("*.$suffix", (status as LicenseStatus.Commercial).audienceMatched)
182
- }
183
-
184
- @Test
185
- fun `matches star audience for any-domain trial licenses`() = runTest {
186
- val token = mintLicense(
187
- aud = listOf("*"),
188
- platforms = listOf("android"),
189
- tier = "trial",
190
- )
191
- val status = prodValidator(token = token).validate()
192
- assertTrue("expected Trial, got $status", status is LicenseStatus.Trial)
193
- }
194
-
195
- /* ------------------------------------------------------------------ */
196
- /* Failure modes — must collapse to a free-* status, never throw */
197
- /* ------------------------------------------------------------------ */
198
-
199
- @Test
200
- fun `returns FreeProd when the token has been tampered with`() = runTest {
201
- val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
202
- // Flip bytes in the payload segment to break the signature.
203
- val parts = token.split(".")
204
- val corrupted = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
205
- val status = prodValidator(token = corrupted).validate()
206
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
207
- val reason = (status as LicenseStatus.FreeProd).reason.lowercase()
208
- assertTrue(
209
- "reason was: $reason",
210
- reason.contains("signature") || reason.contains("verification") ||
211
- reason.contains("parseable") || reason.contains("claim"),
212
- )
213
- }
214
-
215
- @Test
216
- fun `returns FreeExpired when exp is in the past`() = runTest {
217
- val pastSeconds = System.currentTimeMillis() / 1000 - 3600
218
- val token = mintLicense(
219
- aud = listOf(context.packageName),
220
- platforms = listOf("android"),
221
- licensee = "Expired Co",
222
- absoluteExpSeconds = pastSeconds,
223
- )
224
- val status = prodValidator(token = token).validate()
225
- assertTrue("expected FreeExpired, got $status", status is LicenseStatus.FreeExpired)
226
- val e = status as LicenseStatus.FreeExpired
227
- assertEquals("Expired Co", e.licensee)
228
- assertTrue(e.expiredAt < System.currentTimeMillis() / 1000)
229
- }
230
-
231
- @Test
232
- fun `returns FreeProd when audience does not match`() = runTest {
233
- val token = mintLicense(
234
- aud = listOf("com.different.app"),
235
- platforms = listOf("android"),
236
- )
237
- val status = prodValidator(token = token).validate()
238
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
239
- val reason = (status as LicenseStatus.FreeProd).reason
240
- assertTrue(reason, reason.contains("audience"))
241
- assertTrue(reason, reason.contains(context.packageName))
242
- }
243
-
244
- @Test
245
- fun `returns FreeProd when the runtime platform is not in the platforms claim`() = runTest {
246
- val token = mintLicense(
247
- aud = listOf(context.packageName),
248
- platforms = listOf("ios", "web"), // not android
249
- )
250
- val status = prodValidator(token = token).validate()
251
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
252
- val reason = (status as LicenseStatus.FreeProd).reason
253
- assertTrue(reason, reason.contains("platform"))
254
- assertTrue(reason, reason.contains("android"))
255
- }
256
-
257
- @Test
258
- fun `returns FreeProd when the kid in the header is not in the registry`() = runTest {
259
- val token = mintLicense(
260
- aud = listOf(context.packageName),
261
- platforms = listOf("android"),
262
- kid = "unknown-kid-2099",
263
- )
264
- val status = prodValidator(token = token).validate()
265
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
266
- val reason = (status as LicenseStatus.FreeProd).reason
267
- assertTrue(reason, reason.contains("unknown-kid-2099"))
268
- assertTrue(reason, reason.contains("registry"))
269
- }
270
-
271
- @Test
272
- fun `refuses the placeholder kid unless allowPlaceholderKey is set`() = runTest {
273
- // Mint with the placeholder kid (using OUR key, registered against
274
- // the placeholder kid in the local registry). Even though signature
275
- // verification would succeed, the validator must refuse the kid.
276
- val registryWithPlaceholderKid = mapOf(
277
- PLACEHOLDER_KID to publicKeys.values.first().copy(kid = PLACEHOLDER_KID),
278
- )
279
- val token = mintLicense(
280
- aud = listOf(context.packageName),
281
- platforms = listOf("android"),
282
- kid = PLACEHOLDER_KID,
283
- )
284
- val status = prodValidator(
285
- token = token,
286
- registry = registryWithPlaceholderKid,
287
- ).validate()
288
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
289
- assertTrue(
290
- (status as LicenseStatus.FreeProd).reason.contains("placeholder"),
291
- )
292
- }
293
-
294
- @Test
295
- fun `accepts the placeholder kid when allowPlaceholderKey is set`() = runTest {
296
- val registryWithPlaceholderKid = mapOf(
297
- PLACEHOLDER_KID to publicKeys.values.first().copy(kid = PLACEHOLDER_KID),
298
- )
299
- val token = mintLicense(
300
- aud = listOf(context.packageName),
301
- platforms = listOf("android"),
302
- kid = PLACEHOLDER_KID,
303
- )
304
- val status = prodValidator(
305
- token = token,
306
- registry = registryWithPlaceholderKid,
307
- allowPlaceholderKey = true,
308
- ).validate()
309
- assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
310
- }
311
-
312
- @Test
313
- fun `rejects alg=none tokens (algorithm-confusion defense)`() = runTest {
314
- // Build a hand-crafted alg=none token (header + payload + empty sig).
315
- val headerJson = """{"alg":"none","typ":"JWT"}"""
316
- val payloadJson = """{"iss":"DVAI-Bridge","sub":"x","aud":["${context.packageName}"],""" +
317
- """"tier":"commercial","platforms":["android"],"licensee":"Evil Co",""" +
318
- """"iat":${System.currentTimeMillis() / 1000},"exp":${System.currentTimeMillis() / 1000 + 3600}}"""
319
- val encoder = java.util.Base64.getUrlEncoder().withoutPadding()
320
- val header = encoder.encodeToString(headerJson.toByteArray())
321
- val payload = encoder.encodeToString(payloadJson.toByteArray())
322
- val noneToken = "$header.$payload."
323
- val status = prodValidator(token = noneToken).validate()
324
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
325
- assertTrue((status as LicenseStatus.FreeProd).reason.contains("ES256"))
326
- }
327
-
328
- @Test
329
- fun `returns FreeProd when token is malformed`() = runTest {
330
- val status = prodValidator(token = "not.a.valid.jwt").validate() // 4 segments
331
- assertTrue(status is LicenseStatus.FreeProd)
332
- }
333
-
334
- @Test
335
- fun `returns FreeProd when no token is provided AND no auto-discovery succeeds`() = runTest {
336
- // No token, no path — and the Robolectric app has no bundled asset
337
- // or raw resource named dvai-license.jwt, and filesDir is empty.
338
- val status = prodValidator().validate()
339
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
340
- assertTrue(
341
- (status as LicenseStatus.FreeProd).reason.contains("no license token found"),
342
- )
343
- }
344
-
345
- /* ------------------------------------------------------------------ */
346
- /* Dev mode bypass */
347
- /* ------------------------------------------------------------------ */
348
-
349
- @Test
350
- fun `returns FreeDev when hostBuildConfigDebug is true`() = runTest {
351
- val status = LicenseValidator(
352
- context = context,
353
- hostBuildConfigDebug = true,
354
- publicKeys = publicKeys,
355
- ).validate()
356
- assertTrue("expected FreeDev, got $status", status is LicenseStatus.FreeDev)
357
- assertTrue(
358
- (status as LicenseStatus.FreeDev).reason.contains("BuildConfig.DEBUG"),
359
- )
360
- }
361
-
362
- @Test
363
- fun `returns FreeDev when host omits BuildConfig and FLAG_DEBUGGABLE is set`() = runTest {
364
- // Robolectric's default ApplicationInfo carries FLAG_DEBUGGABLE — the
365
- // fallback heuristic should pick that up and return FreeDev when the
366
- // host didn't pass an explicit hostBuildConfigDebug.
367
- val status = LicenseValidator(
368
- context = context,
369
- // hostBuildConfigDebug intentionally null
370
- publicKeys = publicKeys,
371
- ).validate()
372
- assertTrue("expected FreeDev, got $status", status is LicenseStatus.FreeDev)
373
- }
374
-
375
- @Test
376
- fun `explicit hostBuildConfigDebug=false overrides FLAG_DEBUGGABLE`() = runTest {
377
- // Robolectric has FLAG_DEBUGGABLE set, but the host explicitly says
378
- // "not a debug build" — production mode wins, validator falls through
379
- // to discovery and (since no token is configured) returns FreeProd.
380
- val status = LicenseValidator(
381
- context = context,
382
- hostBuildConfigDebug = false,
383
- publicKeys = publicKeys,
384
- ).validate()
385
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
386
- }
387
-
388
- /* ------------------------------------------------------------------ */
389
- /* Token discovery */
390
- /* ------------------------------------------------------------------ */
391
-
392
- @Test
393
- fun `loads from an explicit path`() = runTest {
394
- val tmp = java.io.File.createTempFile("dvai-license-", ".jwt")
395
- val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
396
- tmp.writeText(token)
397
-
398
- val status = prodValidator(path = tmp.absolutePath).validate()
399
- assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
400
- tmp.delete()
401
- }
402
-
403
- @Test
404
- fun `returns FreeProd when explicit path does not exist`() = runTest {
405
- val status = prodValidator(
406
- path = "/nonexistent/path/dvai-license.jwt-${UUID.randomUUID()}",
407
- ).validate()
408
- assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
409
- }
410
-
411
- @Test
412
- fun `loads from filesDir when no explicit source is given`() = runTest {
413
- val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
414
- val file = java.io.File(context.filesDir, "dvai-license.jwt")
415
- try {
416
- file.writeText(token)
417
- val status = prodValidator().validate()
418
- assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
419
- } finally {
420
- file.delete()
421
- }
422
- }
423
-
424
- @Test
425
- fun `inline token wins over path when both are set`() = runTest {
426
- val inlineToken = mintLicense(
427
- aud = listOf(context.packageName),
428
- platforms = listOf("android"),
429
- licensee = "Inline Co",
430
- )
431
- val status = prodValidator(
432
- token = inlineToken,
433
- path = "/nonexistent/path/dvai-license.jwt",
434
- ).validate()
435
- assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
436
- assertEquals("Inline Co", (status as LicenseStatus.Commercial).licensee)
437
- }
438
-
439
- /* ------------------------------------------------------------------ */
440
- /* validateAndAssert — BSL 1.1 enforcement */
441
- /* ------------------------------------------------------------------ */
442
-
443
- @Test
444
- fun `validateAndAssert returns status without throwing for commercial licenses`() = runTest {
445
- val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
446
- val status = prodValidator(token = token).validateAndAssert()
447
- assertTrue(status is LicenseStatus.Commercial)
448
- }
449
-
450
- @Test
451
- fun `validateAndAssert returns status without throwing for trial licenses`() = runTest {
452
- val token = mintLicense(
453
- aud = listOf(context.packageName),
454
- platforms = listOf("android"),
455
- tier = "trial",
456
- )
457
- val status = prodValidator(token = token).validateAndAssert()
458
- assertTrue(status is LicenseStatus.Trial)
459
- }
460
-
461
- @Test
462
- fun `validateAndAssert returns status without throwing for FreeDev`() = runTest {
463
- val status = LicenseValidator(
464
- context = context,
465
- hostBuildConfigDebug = true,
466
- publicKeys = publicKeys,
467
- ).validateAndAssert()
468
- assertTrue(status is LicenseStatus.FreeDev)
469
- }
470
-
471
- @Test
472
- fun `validateAndAssert throws LicenseRequiredError when no license found in production`() = runTest {
473
- try {
474
- prodValidator().validateAndAssert()
475
- fail("should have thrown")
476
- } catch (e: LicenseRequiredError) {
477
- assertTrue(e.status is LicenseStatus.FreeProd)
478
- assertTrue("message: ${e.message}", e.message!!.contains("Commercial License Required"))
479
- assertTrue("message: ${e.message}", e.message!!.contains("dvai-license.jwt"))
480
- assertTrue("message: ${e.message}", e.message!!.contains("DVAI_LICENSE_PATH"))
481
- }
482
- }
483
-
484
- @Test
485
- fun `validateAndAssert throws with status FreeExpired for expired tokens`() = runTest {
486
- val past = System.currentTimeMillis() / 1000 - 3600
487
- val token = mintLicense(
488
- aud = listOf(context.packageName),
489
- platforms = listOf("android"),
490
- licensee = "Expired Co",
491
- absoluteExpSeconds = past,
492
- )
493
- try {
494
- prodValidator(token = token).validateAndAssert()
495
- fail("should have thrown")
496
- } catch (e: LicenseRequiredError) {
497
- assertTrue(e.status is LicenseStatus.FreeExpired)
498
- assertTrue("message: ${e.message}", e.message!!.contains("Expired Co"))
499
- }
500
- }
501
-
502
- @Test
503
- fun `validateAndAssert throws for tampered tokens in production`() = runTest {
504
- val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
505
- val parts = token.split(".")
506
- val corrupted = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
507
- try {
508
- prodValidator(token = corrupted).validateAndAssert()
509
- fail("should have thrown")
510
- } catch (e: LicenseRequiredError) {
511
- assertNotNull(e.status)
512
- }
513
- }
514
-
515
- @Test
516
- fun `validateAndAssert throws for audience-mismatched tokens in production`() = runTest {
517
- val token = mintLicense(
518
- aud = listOf("com.different.app"),
519
- platforms = listOf("android"),
520
- )
521
- try {
522
- prodValidator(token = token).validateAndAssert()
523
- fail("should have thrown")
524
- } catch (_: LicenseRequiredError) {
525
- // expected
526
- }
527
- }
528
-
529
- @Test
530
- fun `validateAndAssert does NOT throw in dev mode even when license is invalid`() = runTest {
531
- val status = LicenseValidator(
532
- context = context,
533
- token = "not-even-a-jwt",
534
- hostBuildConfigDebug = true,
535
- publicKeys = publicKeys,
536
- ).validateAndAssert()
537
- assertTrue(status is LicenseStatus.FreeDev)
538
- }
539
- }
1
+ package co.deepvoiceai.bridge.license
2
+
3
+ import android.content.Context
4
+ import com.nimbusds.jose.JOSEObjectType
5
+ import com.nimbusds.jose.JWSAlgorithm
6
+ import com.nimbusds.jose.JWSHeader
7
+ import com.nimbusds.jose.crypto.ECDSASigner
8
+ import com.nimbusds.jose.jwk.Curve
9
+ import com.nimbusds.jose.jwk.ECKey
10
+ import com.nimbusds.jose.jwk.gen.ECKeyGenerator
11
+ import com.nimbusds.jwt.JWTClaimsSet
12
+ import com.nimbusds.jwt.SignedJWT
13
+ import kotlinx.coroutines.test.runTest
14
+ import org.junit.Assert.assertEquals
15
+ import org.junit.Assert.assertNotNull
16
+ import org.junit.Assert.assertTrue
17
+ import org.junit.Assert.fail
18
+ import org.junit.Before
19
+ import org.junit.BeforeClass
20
+ import org.junit.Test
21
+ import org.junit.runner.RunWith
22
+ import org.robolectric.RobolectricTestRunner
23
+ import org.robolectric.RuntimeEnvironment
24
+ import org.robolectric.annotation.Config
25
+ import java.util.Date
26
+ import java.util.UUID
27
+
28
+ /**
29
+ * Tests for the JWT-based license validator on Android.
30
+ *
31
+ * Direct port of `packages/dvai-bridge-core/src/__tests__/license.test.ts` —
32
+ * same scenarios, same expectations, same coverage of failure modes.
33
+ *
34
+ * Two APIs are tested:
35
+ * - `validate()` — never throws; returns `LicenseStatus`.
36
+ * - `validateAndAssert()` — throws `LicenseRequiredError` for
37
+ * `FreeProd` / `FreeExpired`. This is the
38
+ * BSL 1.1 enforcement entry point used by
39
+ * `DVAIBridge.start()`.
40
+ *
41
+ * Runs under Robolectric so `android.content.Context` (audience detection,
42
+ * discovery) and `android.util.Base64` (JWT header decode) are available
43
+ * without an emulator. The test keypair is generated once per class so we
44
+ * don't re-derive on every case.
45
+ *
46
+ * Every test that exercises a non-dev-mode code path passes
47
+ * `hostBuildConfigDebug = false` explicitly via [prodValidator]. Robolectric
48
+ * applications default to `ApplicationInfo.FLAG_DEBUGGABLE` set, which
49
+ * would otherwise collapse every test into a `FreeDev` bypass.
50
+ */
51
+ @RunWith(RobolectricTestRunner::class)
52
+ @Config(sdk = [34])
53
+ class LicenseValidatorTest {
54
+
55
+ companion object {
56
+ private const val TEST_KID = "test-kid-2026"
57
+ private lateinit var ecJwk: ECKey
58
+ private lateinit var publicKeys: Map<String, DvaiPublicKeyJwk>
59
+
60
+ @BeforeClass
61
+ @JvmStatic
62
+ fun generateTestKeyPair() {
63
+ // ES256 / P-256 keypair, mirrors the JS test setup.
64
+ ecJwk = ECKeyGenerator(Curve.P_256)
65
+ .keyID(TEST_KID)
66
+ .generate()
67
+ val pub = ecJwk.toPublicJWK()
68
+ publicKeys = mapOf(
69
+ TEST_KID to DvaiPublicKeyJwk(
70
+ kty = "EC",
71
+ crv = "P-256",
72
+ x = pub.x.toString(),
73
+ y = pub.y.toString(),
74
+ alg = "ES256",
75
+ use = "sig",
76
+ kid = TEST_KID,
77
+ ),
78
+ )
79
+ }
80
+ }
81
+
82
+ private lateinit var context: Context
83
+
84
+ @Before
85
+ fun setup() {
86
+ context = RuntimeEnvironment.getApplication()
87
+ }
88
+
89
+ /**
90
+ * Build a [LicenseValidator] with production-mode defaults so tests
91
+ * exercise the license-required branches. Robolectric defaults
92
+ * `ApplicationInfo.FLAG_DEBUGGABLE` to set, which would otherwise
93
+ * collapse every test into a `FreeDev` bypass; pass
94
+ * `hostBuildConfigDebug = false` explicitly to override.
95
+ *
96
+ * Dev-mode tests construct [LicenseValidator] directly with
97
+ * `hostBuildConfigDebug = true`.
98
+ */
99
+ private fun prodValidator(
100
+ token: String? = null,
101
+ path: String? = null,
102
+ registry: Map<String, DvaiPublicKeyJwk> = publicKeys,
103
+ allowPlaceholderKey: Boolean = false,
104
+ ): LicenseValidator = LicenseValidator(
105
+ context = context,
106
+ token = token,
107
+ path = path,
108
+ hostBuildConfigDebug = false,
109
+ publicKeys = registry,
110
+ allowPlaceholderKey = allowPlaceholderKey,
111
+ )
112
+
113
+ /** Mint a license JWT for tests. Mirrors `mintLicense` in the JS suite. */
114
+ private fun mintLicense(
115
+ aud: List<String> = listOf("*"),
116
+ platforms: List<String> = listOf("node", "web", "ios", "android"),
117
+ tier: String = "commercial",
118
+ licensee: String = "Test Co",
119
+ expSecondsFromNow: Long = 30L * 24 * 3600,
120
+ absoluteExpSeconds: Long? = null,
121
+ iss: String = "DVAI-Bridge",
122
+ kid: String = TEST_KID,
123
+ signWith: ECKey = ecJwk,
124
+ ): String {
125
+ val nowSec = System.currentTimeMillis() / 1000
126
+ val exp = absoluteExpSeconds ?: (nowSec + expSecondsFromNow)
127
+ val claims = JWTClaimsSet.Builder()
128
+ .issuer(iss)
129
+ .subject("test-license")
130
+ .audience(aud)
131
+ .issueTime(Date(nowSec * 1000))
132
+ .expirationTime(Date(exp * 1000))
133
+ .claim("tier", tier)
134
+ .claim("licensee", licensee)
135
+ .claim("platforms", platforms)
136
+ .build()
137
+ val header = JWSHeader.Builder(JWSAlgorithm.ES256)
138
+ .type(JOSEObjectType.JWT)
139
+ .keyID(kid)
140
+ .build()
141
+ val signed = SignedJWT(header, claims)
142
+ signed.sign(ECDSASigner(signWith))
143
+ return signed.serialize()
144
+ }
145
+
146
+ /* ------------------------------------------------------------------ */
147
+ /* Happy path */
148
+ /* ------------------------------------------------------------------ */
149
+
150
+ @Test
151
+ fun `accepts a well-formed commercial token and reports licensee + expiry`() = runTest {
152
+ val token = mintLicense(
153
+ aud = listOf(context.packageName),
154
+ platforms = listOf("android"),
155
+ licensee = "Acme Inc",
156
+ )
157
+ val status = prodValidator(token = token).validate()
158
+ assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
159
+ val c = status as LicenseStatus.Commercial
160
+ assertEquals("Acme Inc", c.licensee)
161
+ assertEquals(context.packageName, c.audienceMatched)
162
+ assertEquals(DvaiPlatform.ANDROID, c.platform)
163
+ assertTrue(c.expiresAt > System.currentTimeMillis() / 1000)
164
+ }
165
+
166
+ @Test
167
+ fun `matches wildcard subdomain audience entries`() = runTest {
168
+ // Android audience is the package name; package names use dot-
169
+ // separated reverse-domain notation so the same `*.acme.com` rule
170
+ // applies to `app.acme.com`-shaped package names.
171
+ val pkg = context.packageName
172
+ val parts = pkg.split(".")
173
+ if (parts.size < 2) return@runTest // can't build a wildcard from a 1-segment package
174
+ val suffix = parts.drop(1).joinToString(".")
175
+ val token = mintLicense(
176
+ aud = listOf("*.$suffix"),
177
+ platforms = listOf("android"),
178
+ )
179
+ val status = prodValidator(token = token).validate()
180
+ assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
181
+ assertEquals("*.$suffix", (status as LicenseStatus.Commercial).audienceMatched)
182
+ }
183
+
184
+ @Test
185
+ fun `matches star audience for any-domain trial licenses`() = runTest {
186
+ val token = mintLicense(
187
+ aud = listOf("*"),
188
+ platforms = listOf("android"),
189
+ tier = "trial",
190
+ )
191
+ val status = prodValidator(token = token).validate()
192
+ assertTrue("expected Trial, got $status", status is LicenseStatus.Trial)
193
+ }
194
+
195
+ /* ------------------------------------------------------------------ */
196
+ /* Failure modes — must collapse to a free-* status, never throw */
197
+ /* ------------------------------------------------------------------ */
198
+
199
+ @Test
200
+ fun `returns FreeProd when the token has been tampered with`() = runTest {
201
+ val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
202
+ // Flip bytes in the payload segment to break the signature.
203
+ val parts = token.split(".")
204
+ val corrupted = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
205
+ val status = prodValidator(token = corrupted).validate()
206
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
207
+ val reason = (status as LicenseStatus.FreeProd).reason.lowercase()
208
+ assertTrue(
209
+ "reason was: $reason",
210
+ reason.contains("signature") || reason.contains("verification") ||
211
+ reason.contains("parseable") || reason.contains("claim"),
212
+ )
213
+ }
214
+
215
+ @Test
216
+ fun `returns FreeExpired when exp is in the past`() = runTest {
217
+ val pastSeconds = System.currentTimeMillis() / 1000 - 3600
218
+ val token = mintLicense(
219
+ aud = listOf(context.packageName),
220
+ platforms = listOf("android"),
221
+ licensee = "Expired Co",
222
+ absoluteExpSeconds = pastSeconds,
223
+ )
224
+ val status = prodValidator(token = token).validate()
225
+ assertTrue("expected FreeExpired, got $status", status is LicenseStatus.FreeExpired)
226
+ val e = status as LicenseStatus.FreeExpired
227
+ assertEquals("Expired Co", e.licensee)
228
+ assertTrue(e.expiredAt < System.currentTimeMillis() / 1000)
229
+ }
230
+
231
+ @Test
232
+ fun `returns FreeProd when audience does not match`() = runTest {
233
+ val token = mintLicense(
234
+ aud = listOf("com.different.app"),
235
+ platforms = listOf("android"),
236
+ )
237
+ val status = prodValidator(token = token).validate()
238
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
239
+ val reason = (status as LicenseStatus.FreeProd).reason
240
+ assertTrue(reason, reason.contains("audience"))
241
+ assertTrue(reason, reason.contains(context.packageName))
242
+ }
243
+
244
+ @Test
245
+ fun `returns FreeProd when the runtime platform is not in the platforms claim`() = runTest {
246
+ val token = mintLicense(
247
+ aud = listOf(context.packageName),
248
+ platforms = listOf("ios", "web"), // not android
249
+ )
250
+ val status = prodValidator(token = token).validate()
251
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
252
+ val reason = (status as LicenseStatus.FreeProd).reason
253
+ assertTrue(reason, reason.contains("platform"))
254
+ assertTrue(reason, reason.contains("android"))
255
+ }
256
+
257
+ @Test
258
+ fun `returns FreeProd when the kid in the header is not in the registry`() = runTest {
259
+ val token = mintLicense(
260
+ aud = listOf(context.packageName),
261
+ platforms = listOf("android"),
262
+ kid = "unknown-kid-2099",
263
+ )
264
+ val status = prodValidator(token = token).validate()
265
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
266
+ val reason = (status as LicenseStatus.FreeProd).reason
267
+ assertTrue(reason, reason.contains("unknown-kid-2099"))
268
+ assertTrue(reason, reason.contains("registry"))
269
+ }
270
+
271
+ @Test
272
+ fun `refuses the placeholder kid unless allowPlaceholderKey is set`() = runTest {
273
+ // Mint with the placeholder kid (using OUR key, registered against
274
+ // the placeholder kid in the local registry). Even though signature
275
+ // verification would succeed, the validator must refuse the kid.
276
+ val registryWithPlaceholderKid = mapOf(
277
+ PLACEHOLDER_KID to publicKeys.values.first().copy(kid = PLACEHOLDER_KID),
278
+ )
279
+ val token = mintLicense(
280
+ aud = listOf(context.packageName),
281
+ platforms = listOf("android"),
282
+ kid = PLACEHOLDER_KID,
283
+ )
284
+ val status = prodValidator(
285
+ token = token,
286
+ registry = registryWithPlaceholderKid,
287
+ ).validate()
288
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
289
+ assertTrue(
290
+ (status as LicenseStatus.FreeProd).reason.contains("placeholder"),
291
+ )
292
+ }
293
+
294
+ @Test
295
+ fun `accepts the placeholder kid when allowPlaceholderKey is set`() = runTest {
296
+ val registryWithPlaceholderKid = mapOf(
297
+ PLACEHOLDER_KID to publicKeys.values.first().copy(kid = PLACEHOLDER_KID),
298
+ )
299
+ val token = mintLicense(
300
+ aud = listOf(context.packageName),
301
+ platforms = listOf("android"),
302
+ kid = PLACEHOLDER_KID,
303
+ )
304
+ val status = prodValidator(
305
+ token = token,
306
+ registry = registryWithPlaceholderKid,
307
+ allowPlaceholderKey = true,
308
+ ).validate()
309
+ assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
310
+ }
311
+
312
+ @Test
313
+ fun `rejects alg=none tokens (algorithm-confusion defense)`() = runTest {
314
+ // Build a hand-crafted alg=none token (header + payload + empty sig).
315
+ val headerJson = """{"alg":"none","typ":"JWT"}"""
316
+ val payloadJson = """{"iss":"DVAI-Bridge","sub":"x","aud":["${context.packageName}"],""" +
317
+ """"tier":"commercial","platforms":["android"],"licensee":"Evil Co",""" +
318
+ """"iat":${System.currentTimeMillis() / 1000},"exp":${System.currentTimeMillis() / 1000 + 3600}}"""
319
+ val encoder = java.util.Base64.getUrlEncoder().withoutPadding()
320
+ val header = encoder.encodeToString(headerJson.toByteArray())
321
+ val payload = encoder.encodeToString(payloadJson.toByteArray())
322
+ val noneToken = "$header.$payload."
323
+ val status = prodValidator(token = noneToken).validate()
324
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
325
+ assertTrue((status as LicenseStatus.FreeProd).reason.contains("ES256"))
326
+ }
327
+
328
+ @Test
329
+ fun `returns FreeProd when token is malformed`() = runTest {
330
+ val status = prodValidator(token = "not.a.valid.jwt").validate() // 4 segments
331
+ assertTrue(status is LicenseStatus.FreeProd)
332
+ }
333
+
334
+ @Test
335
+ fun `returns FreeProd when no token is provided AND no auto-discovery succeeds`() = runTest {
336
+ // No token, no path — and the Robolectric app has no bundled asset
337
+ // or raw resource named dvai-license.jwt, and filesDir is empty.
338
+ val status = prodValidator().validate()
339
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
340
+ assertTrue(
341
+ (status as LicenseStatus.FreeProd).reason.contains("no license token found"),
342
+ )
343
+ }
344
+
345
+ /* ------------------------------------------------------------------ */
346
+ /* Dev mode bypass */
347
+ /* ------------------------------------------------------------------ */
348
+
349
+ @Test
350
+ fun `returns FreeDev when hostBuildConfigDebug is true`() = runTest {
351
+ val status = LicenseValidator(
352
+ context = context,
353
+ hostBuildConfigDebug = true,
354
+ publicKeys = publicKeys,
355
+ ).validate()
356
+ assertTrue("expected FreeDev, got $status", status is LicenseStatus.FreeDev)
357
+ assertTrue(
358
+ (status as LicenseStatus.FreeDev).reason.contains("BuildConfig.DEBUG"),
359
+ )
360
+ }
361
+
362
+ @Test
363
+ fun `returns FreeDev when host omits BuildConfig and FLAG_DEBUGGABLE is set`() = runTest {
364
+ // Robolectric's default ApplicationInfo carries FLAG_DEBUGGABLE — the
365
+ // fallback heuristic should pick that up and return FreeDev when the
366
+ // host didn't pass an explicit hostBuildConfigDebug.
367
+ val status = LicenseValidator(
368
+ context = context,
369
+ // hostBuildConfigDebug intentionally null
370
+ publicKeys = publicKeys,
371
+ ).validate()
372
+ assertTrue("expected FreeDev, got $status", status is LicenseStatus.FreeDev)
373
+ }
374
+
375
+ @Test
376
+ fun `explicit hostBuildConfigDebug=false overrides FLAG_DEBUGGABLE`() = runTest {
377
+ // Robolectric has FLAG_DEBUGGABLE set, but the host explicitly says
378
+ // "not a debug build" — production mode wins, validator falls through
379
+ // to discovery and (since no token is configured) returns FreeProd.
380
+ val status = LicenseValidator(
381
+ context = context,
382
+ hostBuildConfigDebug = false,
383
+ publicKeys = publicKeys,
384
+ ).validate()
385
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
386
+ }
387
+
388
+ /* ------------------------------------------------------------------ */
389
+ /* Token discovery */
390
+ /* ------------------------------------------------------------------ */
391
+
392
+ @Test
393
+ fun `loads from an explicit path`() = runTest {
394
+ val tmp = java.io.File.createTempFile("dvai-license-", ".jwt")
395
+ val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
396
+ tmp.writeText(token)
397
+
398
+ val status = prodValidator(path = tmp.absolutePath).validate()
399
+ assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
400
+ tmp.delete()
401
+ }
402
+
403
+ @Test
404
+ fun `returns FreeProd when explicit path does not exist`() = runTest {
405
+ val status = prodValidator(
406
+ path = "/nonexistent/path/dvai-license.jwt-${UUID.randomUUID()}",
407
+ ).validate()
408
+ assertTrue("expected FreeProd, got $status", status is LicenseStatus.FreeProd)
409
+ }
410
+
411
+ @Test
412
+ fun `loads from filesDir when no explicit source is given`() = runTest {
413
+ val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
414
+ val file = java.io.File(context.filesDir, "dvai-license.jwt")
415
+ try {
416
+ file.writeText(token)
417
+ val status = prodValidator().validate()
418
+ assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
419
+ } finally {
420
+ file.delete()
421
+ }
422
+ }
423
+
424
+ @Test
425
+ fun `inline token wins over path when both are set`() = runTest {
426
+ val inlineToken = mintLicense(
427
+ aud = listOf(context.packageName),
428
+ platforms = listOf("android"),
429
+ licensee = "Inline Co",
430
+ )
431
+ val status = prodValidator(
432
+ token = inlineToken,
433
+ path = "/nonexistent/path/dvai-license.jwt",
434
+ ).validate()
435
+ assertTrue("expected Commercial, got $status", status is LicenseStatus.Commercial)
436
+ assertEquals("Inline Co", (status as LicenseStatus.Commercial).licensee)
437
+ }
438
+
439
+ /* ------------------------------------------------------------------ */
440
+ /* validateAndAssert — BSL 1.1 enforcement */
441
+ /* ------------------------------------------------------------------ */
442
+
443
+ @Test
444
+ fun `validateAndAssert returns status without throwing for commercial licenses`() = runTest {
445
+ val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
446
+ val status = prodValidator(token = token).validateAndAssert()
447
+ assertTrue(status is LicenseStatus.Commercial)
448
+ }
449
+
450
+ @Test
451
+ fun `validateAndAssert returns status without throwing for trial licenses`() = runTest {
452
+ val token = mintLicense(
453
+ aud = listOf(context.packageName),
454
+ platforms = listOf("android"),
455
+ tier = "trial",
456
+ )
457
+ val status = prodValidator(token = token).validateAndAssert()
458
+ assertTrue(status is LicenseStatus.Trial)
459
+ }
460
+
461
+ @Test
462
+ fun `validateAndAssert returns status without throwing for FreeDev`() = runTest {
463
+ val status = LicenseValidator(
464
+ context = context,
465
+ hostBuildConfigDebug = true,
466
+ publicKeys = publicKeys,
467
+ ).validateAndAssert()
468
+ assertTrue(status is LicenseStatus.FreeDev)
469
+ }
470
+
471
+ @Test
472
+ fun `validateAndAssert throws LicenseRequiredError when no license found in production`() = runTest {
473
+ try {
474
+ prodValidator().validateAndAssert()
475
+ fail("should have thrown")
476
+ } catch (e: LicenseRequiredError) {
477
+ assertTrue(e.status is LicenseStatus.FreeProd)
478
+ assertTrue("message: ${e.message}", e.message!!.contains("Commercial License Required"))
479
+ assertTrue("message: ${e.message}", e.message!!.contains("dvai-license.jwt"))
480
+ assertTrue("message: ${e.message}", e.message!!.contains("DVAI_LICENSE_PATH"))
481
+ }
482
+ }
483
+
484
+ @Test
485
+ fun `validateAndAssert throws with status FreeExpired for expired tokens`() = runTest {
486
+ val past = System.currentTimeMillis() / 1000 - 3600
487
+ val token = mintLicense(
488
+ aud = listOf(context.packageName),
489
+ platforms = listOf("android"),
490
+ licensee = "Expired Co",
491
+ absoluteExpSeconds = past,
492
+ )
493
+ try {
494
+ prodValidator(token = token).validateAndAssert()
495
+ fail("should have thrown")
496
+ } catch (e: LicenseRequiredError) {
497
+ assertTrue(e.status is LicenseStatus.FreeExpired)
498
+ assertTrue("message: ${e.message}", e.message!!.contains("Expired Co"))
499
+ }
500
+ }
501
+
502
+ @Test
503
+ fun `validateAndAssert throws for tampered tokens in production`() = runTest {
504
+ val token = mintLicense(aud = listOf(context.packageName), platforms = listOf("android"))
505
+ val parts = token.split(".")
506
+ val corrupted = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
507
+ try {
508
+ prodValidator(token = corrupted).validateAndAssert()
509
+ fail("should have thrown")
510
+ } catch (e: LicenseRequiredError) {
511
+ assertNotNull(e.status)
512
+ }
513
+ }
514
+
515
+ @Test
516
+ fun `validateAndAssert throws for audience-mismatched tokens in production`() = runTest {
517
+ val token = mintLicense(
518
+ aud = listOf("com.different.app"),
519
+ platforms = listOf("android"),
520
+ )
521
+ try {
522
+ prodValidator(token = token).validateAndAssert()
523
+ fail("should have thrown")
524
+ } catch (_: LicenseRequiredError) {
525
+ // expected
526
+ }
527
+ }
528
+
529
+ @Test
530
+ fun `validateAndAssert does NOT throw in dev mode even when license is invalid`() = runTest {
531
+ val status = LicenseValidator(
532
+ context = context,
533
+ token = "not-even-a-jwt",
534
+ hostBuildConfigDebug = true,
535
+ publicKeys = publicKeys,
536
+ ).validateAndAssert()
537
+ assertTrue(status is LicenseStatus.FreeDev)
538
+ }
539
+ }