Add anonymous OOBE gateway grants
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Provide App Attest-bound, one-time onboarding AI access without creating accounts, with durable replay protection and production deployment safeguards.
This commit is contained in:
Rocky
2026-08-21 22:55:46 +08:00
parent edd0d9feca
commit 0d236f57fb
32 changed files with 2202 additions and 61 deletions
@@ -36,6 +36,15 @@ class AppConfigTest : FunSpec({
config.environment shouldBe Environment.PRODUCTION
config.database.username shouldBe "test"
config.database.migrationUsername shouldBe "test_migrator"
config.integrity.allowDevelopmentAppAttest shouldBe false
}
test("production can explicitly allow development App Attest builds") {
val config = validProductionConfig().apply {
put("app.integrity.allowDevelopmentAppAttest", "true")
}
AppConfig.from(config).integrity.allowDevelopmentAppAttest shouldBe true
}
test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
@@ -378,6 +378,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/integrity/challenges",
"/v1/integrity/attest",
"/v1/integrity/assert",
"/v1/oobe/grants",
"/v1/oobe/grants/refresh",
"/v1/gateway/catalog",
"/v1/gateway/grants",
"/v1/gateway/grants/refresh",
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
runner shouldContain "Flyway history was not exactly successful V1-V17"
runner shouldContain "Flyway history was not exactly successful V1-V26"
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
runner shouldContain "active smaller credit rates did not match the V10 contract"
runner shouldContain "first ledger page omitted nextCursor"
@@ -72,6 +72,23 @@ class AppAttestCryptoTest : FunSpec({
}
}
test("production can explicitly allow development App Attest builds") {
val fixture = AppAttestFixture()
val developmentAaguid = "appattestdevelop".toByteArray(Charsets.US_ASCII)
val crypto = fixture.crypto(
nonce = fixture.expectedNonce(developmentAaguid),
allowDevelopment = true,
)
val material = crypto.validateAttestation(
fixture.attestationObject(aaguid = developmentAaguid),
fixture.keyId,
fixture.challenge,
)
material.publicKey shouldBe fixture.keyPair.public.encoded
}
test("assertion verifies ECDSA and requires a strictly increasing counter") {
val fixture = AppAttestFixture()
val hash = sha256ForTest("cost-request".toByteArray())
@@ -139,12 +156,16 @@ private class AppAttestFixture {
sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)),
)
fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto =
fun crypto(
nonce: ByteArray = expectedNonce(),
allowDevelopment: Boolean = false,
): LibraryAppAttestCrypto =
LibraryAppAttestCrypto(
IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
appAttestPolicy = IntegrityPolicy.ENFORCE,
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
allowDevelopmentAppAttest = allowDevelopment,
),
AppAttestCertificateValidator {
ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce)
@@ -189,8 +210,8 @@ private class AppAttestFixture {
.EncodeToBytes()
}
private fun expectedNonce(): ByteArray =
sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge))
fun expectedNonce(aaguid: ByteArray = productionAaguid()): ByteArray =
sha256ForTest(attestationAuthData(rpIdHash, aaguid) + sha256ForTest(challenge))
private fun productionAaguid(): ByteArray =
"appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7)
@@ -0,0 +1,264 @@
package com.osglab.account.features.oobe
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayModelProfile
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayReasoningEffort
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewaySubjectType
import com.osglab.account.features.gateway.models.GatewayTaskExecutionPolicy
import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.GatewayThinkingMode
import com.osglab.account.features.gateway.models.GatewayToolsMode
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
import com.osglab.account.features.gateway.models.OobeFeature
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.services.GatewayAccessDeniedException
import com.osglab.account.features.gateway.services.GatewayService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.time.Instant
class OobeGatewayServiceTest : StringSpec({
"executes each fixed OOBE feature once without touching credits or account audit" {
val credits = CountingCredits()
val oobe = FakeOobeExecutionRepository()
val service = service(credits, oobe)
OobeFeature.entries.forEachIndexed { index, feature ->
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-feature-$index"), DISCARD)
}
credits.calls shouldBe 0
oobe.consumed.map(OobeRequestClaim::feature).toSet() shouldBe OobeFeature.entries.toSet()
}
"rejects a fifth call and a repeated feature without paid fallback" {
val credits = CountingCredits()
val oobe = FakeOobeExecutionRepository()
val service = service(credits, oobe)
OobeFeature.entries.forEachIndexed { index, feature ->
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-once-$index"), DISCARD)
}
shouldThrow<OobeFeatureAlreadyUsedException> {
service.execute(OOBE_PRINCIPAL, request(OobeFeature.ASK_AI, "oobe-fifth-call"), DISCARD)
}
credits.calls shouldBe 0
}
"releases the feature claim when the provider fails" {
val credits = CountingCredits()
val oobe = FakeOobeExecutionRepository()
val service = service(credits, oobe, fail = true)
shouldThrow<ProviderFailure> {
service.execute(OOBE_PRINCIPAL, request(OobeFeature.VOICE_INPUT, "oobe-provider-fail"), DISCARD)
}
oobe.released.map(OobeRequestClaim::feature) shouldBe listOf(OobeFeature.VOICE_INPUT)
credits.calls shouldBe 0
}
"enforces token boundary and exact feature mapping" {
val credits = CountingCredits()
val service = service(credits, FakeOobeExecutionRepository())
shouldThrow<IllegalArgumentException> {
service.execute(
OOBE_PRINCIPAL,
request(OobeFeature.ASK_AI, "oobe-wrong-map").copy(
executionPolicy = policy(GatewayTaskKind.CLIPBOARD_TRANSFORM),
),
DISCARD,
)
}
shouldThrow<GatewayAccessDeniedException> {
service.execute(
ACCOUNT_PRINCIPAL,
request(OobeFeature.ASK_AI, "account-oobe-feature"),
DISCARD,
)
}
credits.calls shouldBe 0
}
})
private fun service(
credits: CountingCredits,
oobe: OobeRepository,
fail: Boolean = false,
): GatewayService = GatewayService(
catalog = ProviderCatalog(listOf(FakeOobeProvider(fail))),
credits = credits,
grants = GatewayGrantPort { _, _ -> error("account grant lookup must not run for OOBE") },
usageRecords = NoAccountUsage,
oobeRequests = oobe,
)
private class FakeOobeProvider(private val fail: Boolean) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "oobe-test-provider",
capabilities = setOf(GatewayCapability.POLISH, GatewayCapability.AI),
streaming = false,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
if (fail) throw ProviderFailure()
return ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 2,
inputUnits = 1,
outputUnits = 1,
)
}
}
private class CountingCredits : CreditReservationPort {
var calls = 0
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation {
calls += 1
error("credits must not be called")
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
calls += 1
error("credits must not be called")
}
override suspend fun release(reservationId: String) {
calls += 1
error("credits must not be called")
}
}
private class FakeOobeExecutionRepository : OobeRepository {
private val claimedFeatures = mutableSetOf<OobeFeature>()
val consumed = mutableListOf<OobeRequestClaim>()
val released = mutableListOf<OobeRequestClaim>()
override suspend fun claim(
request: OobeProviderRequest,
expiresAt: Instant,
now: Instant,
): OobeRequestClaim? {
if (!claimedFeatures.add(request.feature)) return null
return OobeRequestClaim(request.subjectId, request.feature, request.requestId)
}
override suspend fun markStarted(claim: OobeRequestClaim) = Unit
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) {
consumed += claim
}
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
claimedFeatures -= claim.feature
released += claim
}
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = Unit
override suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject = error("not used")
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh = error("not used")
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult = error("not used")
override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? =
error("not used")
}
private object NoAccountUsage : GatewayUsagePort {
override suspend fun claim(metadata: ProviderRequestMetadata) = error("account audit must not be called")
override suspend fun markStarted(accountId: String, requestId: String) = error("account audit must not be called")
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = error("account audit must not be called")
override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) =
error("account audit must not be called")
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) =
error("account audit must not be called")
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) =
error("account audit must not be called")
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
}
private fun request(feature: OobeFeature, requestId: String): TextProviderRequest {
val mapping = OobeContract.policy(feature)
return TextProviderRequest(
requestId = requestId,
capability = mapping.capability,
executionPolicy = policy(mapping.taskKind),
input = "hello",
context = null,
maxOutputTokens = 1,
temperature = 0.0,
stream = false,
requestPurpose = GatewayRequestPurpose.OOBE,
oobeFeature = feature,
)
}
private fun policy(taskKind: GatewayTaskKind) = GatewayTaskExecutionPolicy(
taskKind = taskKind,
modelProfile = GatewayModelProfile.LOW_LATENCY,
thinking = GatewayThinkingMode.DISABLED,
reasoningEffort = null as GatewayReasoningEffort?,
webSearch = GatewayWebSearchMode.DISABLED,
tools = GatewayToolsMode.DISABLED,
allowEmptyContentRetry = false,
maxOutputTokens = 1,
)
private val OOBE_PRINCIPAL = GatewayPrincipal(
userId = "20000000-0000-0000-0000-000000000001",
grantId = "30000000-0000-0000-0000-000000000001",
scopes = OobeContract.scopes,
subjectType = GatewaySubjectType.OOBE,
)
private val ACCOUNT_PRINCIPAL = GatewayPrincipal(
userId = "40000000-0000-0000-0000-000000000001",
scopes = OobeContract.scopes,
)
private val DISCARD = ProviderOutput {}
private class ProviderFailure : RuntimeException()
@@ -0,0 +1,299 @@
package com.osglab.account.features.oobe
import com.nimbusds.jwt.SignedJWT
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.MACSigner
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import com.osglab.account.features.gateway.models.GatewaySubjectType
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.integrity.AppAttestChallenge
import com.osglab.account.features.integrity.AppAttestChallengePurpose
import com.osglab.account.features.integrity.AppAttestCrypto
import com.osglab.account.features.integrity.AppAttestKeyStatus
import com.osglab.account.features.integrity.AppAttestRepository
import com.osglab.account.features.integrity.AppAttestService
import com.osglab.account.features.integrity.AppAttestRejectedException
import com.osglab.account.features.integrity.AttestedKeyMaterial
import com.osglab.account.features.integrity.ConsumedChallenge
import com.osglab.account.features.integrity.StoredAppAttestKey
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.util.Base64
import java.util.UUID
class OobeGrantServiceTest : StringSpec({
"canonical assertion is server-owned and binds all fixed permissions" {
val challenge = ByteArray(32) { it.toByte() }
val payload = OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID).decodeToString()
payload shouldBe """
osg-app-attest-v1
purpose=oobe-gateway-grant
challenge=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
key_id=$KEY_ID
installation_id=$INSTALLATION_ID
scopes=ai,polish
features=ask_ai,clipboard_reply,clipboard_translate,voice_input
grant_ttl_seconds=1800
access_ttl_seconds=300
""".trimIndent()
}
"issues a distinct short-lived OOBE token after the canonical assertion" {
val clock = MutableClock(NOW)
val challenge = ByteArray(32) { 7 }
val expectedHash = sha256(
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
)
val repository = FakeOobeRepository()
val service = service(repository, expectedHash, clock)
val tokens = service.create(request(challenge))
val jwt = SignedJWT.parse(tokens.accessToken).jwtClaimsSet
jwt.getStringClaim("typ") shouldBe "oobe_gateway_access"
jwt.subject.startsWith("oobe:") shouldBe true
jwt.getStringListClaim("scp").shouldContainExactlyInAnyOrder("polish", "ai")
jwt.getStringListClaim("features").shouldContainExactlyInAnyOrder(
"voice_input",
"clipboard_translate",
"clipboard_reply",
"ask_ai",
)
Duration.between(jwt.issueTime.toInstant(), jwt.expirationTime.toInstant()) shouldBe
Duration.ofMinutes(5)
Duration.between(NOW, Instant.parse(tokens.refreshExpiresAt)) shouldBe Duration.ofMinutes(30)
service.authenticate(tokens.accessToken)?.subjectType shouldBe GatewaySubjectType.OOBE
val overScoped = SignedJWT(
JWSHeader(JWSAlgorithm.HS256),
JWTClaimsSet.Builder(jwt)
.claim("scp", listOf("ai", "agent", "polish"))
.build(),
).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize()
service.authenticate(overScoped) shouldBe null
val accountTyped = SignedJWT(
JWSHeader(JWSAlgorithm.HS256),
JWTClaimsSet.Builder(jwt)
.claim("typ", "gateway_access")
.build(),
).apply { sign(MACSigner(ByteArray(32) { 1 })) }.serialize()
service.authenticate(accountTyped) shouldBe null
clock.now = NOW.plus(Duration.ofMinutes(5))
service.authenticate(tokens.accessToken) shouldBe null
}
"rejects an assertion generated for a different installation payload" {
val challenge = ByteArray(32) { 9 }
val signedHash = sha256(
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
)
val service = service(FakeOobeRepository(), signedHash, MutableClock(NOW))
shouldThrow<AppAttestRejectedException> {
service.create(request(challenge).copy(installationId = UUID.randomUUID().toString()))
}
}
"refresh cannot extend the original grant TTL" {
val clock = MutableClock(NOW)
val challenge = ByteArray(32) { 5 }
val expectedHash = sha256(
OobeContract.canonicalAssertionPayload(challenge, KEY_ID, INSTALLATION_ID),
)
val service = service(FakeOobeRepository(), expectedHash, clock)
val created = service.create(request(challenge))
clock.now = NOW.plus(Duration.ofMinutes(29))
val refreshed = service.refresh(created.refreshToken, "oobe-refresh-1")
refreshed.refreshExpiresAt shouldBe created.refreshExpiresAt
refreshed.accessExpiresAt shouldBe NOW.plus(Duration.ofMinutes(30)).toString()
}
})
private fun service(
repository: OobeRepository,
expectedHash: ByteArray,
clock: Clock,
): OobeGrantService {
val appAttestRepository = FakeAppAttestRepository()
val appAttest = AppAttestService(
repository = appAttestRepository,
crypto = HashCheckingAppAttestCrypto(expectedHash),
config = IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
appAttestPolicy = IntegrityPolicy.ENFORCE,
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
),
clock = clock,
)
return OobeGrantService(
repository = repository,
appAttest = appAttest,
settings = OobeTokenSettings(
issuer = "osg-test",
audience = "osg-gateway-test",
accessTokenHmacSecret = ByteArray(32) { 1 },
refreshTokenHmacSecret = ByteArray(32) { 2 },
),
clock = clock,
)
}
private class HashCheckingAppAttestCrypto(
private val expectedHash: ByteArray,
) : AppAttestCrypto {
override suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
): AttestedKeyMaterial = error("not used")
override suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long {
if (!MessageDigest.isEqual(clientDataHash, expectedHash)) {
throw AppAttestRejectedException("canonical payload mismatch")
}
return lastCounter + 1
}
}
private class FakeAppAttestRepository : AppAttestRepository {
private var counter = 0L
override suspend fun createChallenge(challenge: AppAttestChallenge) = Unit
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge = ConsumedChallenge.Valid
override suspend fun saveKey(key: StoredAppAttestKey): Boolean = true
override suspend fun findKey(keyId: String): StoredAppAttestKey =
StoredAppAttestKey(
keyId = keyId,
publicKey = byteArrayOf(1),
receipt = byteArrayOf(1),
counter = counter,
accountId = null,
status = AppAttestKeyStatus.ACTIVE,
)
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
): Boolean {
if (expectedCounter != counter || newCounter <= counter) return false
counter = newCounter
return true
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean = true
}
private class FakeOobeRepository : OobeRepository {
private val subjects = mutableMapOf<Pair<String, String>, OobeSubject>()
private val grants = mutableMapOf<String, OobeGrant>()
private val refreshes = mutableMapOf<String, StoredOobeRefresh>()
override suspend fun findOrCreateSubject(
keyId: String,
installationHash: String,
subjectId: String,
now: Instant,
): OobeSubject = subjects.getOrPut(keyId to installationHash) {
OobeSubject(subjectId, keyId, installationHash)
}
override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh {
grants[grant.grant.id] = grant.grant
return StoredOobeRefresh(
grant.grant,
grant.refreshTokenId,
grant.refreshFamilyId,
grant.refreshExpiresAt,
).also { refreshes[grant.refreshTokenHash] = it }
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): OobeRefreshRotationResult {
val current = refreshes[currentTokenHash] ?: return OobeRefreshRotationResult.Invalid
if (!current.expiresAt.isAfter(now) || !current.grant.expiresAt.isAfter(now)) {
return OobeRefreshRotationResult.Invalid
}
val replacement = StoredOobeRefresh(
grant = current.grant,
tokenId = newTokenId,
familyId = current.familyId,
expiresAt = minOf(newExpiresAt, current.grant.expiresAt),
)
refreshes[newTokenHash] = replacement
return OobeRefreshRotationResult.Rotated(replacement)
}
override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? =
grants[grantId]?.takeIf { it.subjectId == subjectId && it.expiresAt.isAfter(now) }
override suspend fun claim(
request: OobeProviderRequest,
expiresAt: Instant,
now: Instant,
): OobeRequestClaim? = error("not used")
override suspend fun markStarted(claim: OobeRequestClaim) = error("not used")
override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) = error("not used")
override suspend fun release(claim: OobeRequestClaim, errorCode: String) = error("not used")
override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = error("not used")
}
private class MutableClock(var now: Instant) : Clock() {
override fun getZone(): ZoneId = ZoneId.of("UTC")
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = now
}
private fun request(challenge: ByteArray) = CreateOobeGrantRequest(
challengeId = UUID.randomUUID().toString(),
challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challenge),
keyId = KEY_ID,
installationId = INSTALLATION_ID,
assertion = Base64.getEncoder().encodeToString(byteArrayOf(1)),
)
private fun sha256(value: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(value)
private val NOW = Instant.parse("2026-08-21T00:00:00Z")
private val INSTALLATION_ID = UUID.fromString("10000000-0000-0000-0000-000000000001").toString()
private val KEY_ID = Base64.getEncoder().encodeToString(ByteArray(32) { 3 })
@@ -0,0 +1,207 @@
package com.osglab.account.features.oobe
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.OobeFeature
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.sql.DriverManager
import java.time.Duration
import java.time.Instant
import java.util.UUID
class OobeRepositoryIntegrationTest : FunSpec({
test("anonymous feature claim is atomic, consumed once, and independent from accounts") {
withOobeDatabase { config, databaseFactory ->
val repository = ExposedOobeRepository(databaseFactory)
val now = Instant.parse("2026-08-21T01:00:00Z")
val subject = repository.findOrCreateSubject(
keyId = "integration-key",
installationHash = "a".repeat(64),
subjectId = UUID.randomUUID().toString(),
now = now,
)
val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plus(Duration.ofMinutes(30)))
repository.createGrant(
NewOobeGrant(
grant = grant,
refreshTokenId = UUID.randomUUID().toString(),
refreshFamilyId = UUID.randomUUID().toString(),
refreshTokenHash = "b".repeat(64),
refreshExpiresAt = grant.expiresAt,
),
now,
)
val claims = coroutineScope {
(1..12).map { index ->
async(Dispatchers.Default) {
repository.claim(
providerRequest(subject.id, grant.id, "concurrent-oobe-$index"),
now.plus(Duration.ofMinutes(15)),
now,
)
}
}.awaitAll()
}
val winningClaim = claims.filterNotNull().single()
repository.markStarted(winningClaim)
repository.consume(
winningClaim,
ProviderUsage(UsageMeter.LLM_TOKEN, 2, inputUnits = 1, outputUnits = 1),
)
repository.claim(
providerRequest(subject.id, grant.id, "repeat-after-success"),
now.plus(Duration.ofMinutes(15)),
now,
) shouldBe null
databaseCount(config, "accounts") shouldBe 0
databaseCount(config, "credit_ledger") shouldBe 0
databaseCount(config, "devicecheck_trial_claims") shouldBe 0
val accountId = UUID.randomUUID()
insertAccount(config, accountId, now)
val trial = CreditService(
transactions = ExposedBillingTransactionRunner(databaseFactory.database),
referralRewards = ReferralRewardConfig(
inviterCredits = 1_000,
inviteeCredits = 1_000,
),
).grantSignupTrial(
userId = accountId,
credits = 1_000,
idempotencyKey = signupTrialIdempotencyKey(accountId),
)
trial.balance shouldBe 1_000
}
}
test("provider failure releases the feature for a retry") {
withOobeDatabase { _, databaseFactory ->
val repository = ExposedOobeRepository(databaseFactory)
val now = Instant.parse("2026-08-21T02:00:00Z")
val subject = repository.findOrCreateSubject(
"release-key",
"c".repeat(64),
UUID.randomUUID().toString(),
now,
)
val grant = OobeGrant(UUID.randomUUID().toString(), subject.id, now.plusSeconds(1_800))
repository.createGrant(
NewOobeGrant(
grant,
UUID.randomUUID().toString(),
UUID.randomUUID().toString(),
"d".repeat(64),
grant.expiresAt,
),
now,
)
val first = repository.claim(
providerRequest(subject.id, grant.id, "failure-first"),
now.plusSeconds(900),
now,
)
first shouldNotBe null
repository.markStarted(requireNotNull(first))
repository.release(first, "provider_failure")
repository.claim(
providerRequest(subject.id, grant.id, "failure-retry"),
now.plusSeconds(900),
now,
) shouldNotBe null
}
}
})
private fun providerRequest(subjectId: String, grantId: String, requestId: String) =
OobeProviderRequest(
subjectId = subjectId,
grantId = grantId,
feature = OobeFeature.ASK_AI,
requestId = requestId,
providerId = "integration-provider",
capability = GatewayCapability.AI,
purpose = GatewayRequestPurpose.OOBE,
)
private suspend fun withOobeDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
OobeMySqlContainer("mysql:8.4")
.withDatabaseName("osg_oobe_test")
.withUsername("test")
.withPassword("test")
.also(OobeMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 12,
)
val databaseFactory = DatabaseFactory(config)
try {
databaseFactory.database
block(config, databaseFactory)
} finally {
databaseFactory.close()
mysql?.stop()
}
}
private fun databaseCount(config: DatabaseConfig, table: String): Long =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.createStatement().use { statement ->
statement.executeQuery("SELECT COUNT(*) FROM $table").use { rows ->
rows.next()
rows.getLong(1)
}
}
}
private fun insertAccount(config: DatabaseConfig, accountId: UUID, now: Instant) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES (?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
statement.setString(1, accountId.toString())
statement.setString(2, "oobe-signup-$accountId")
statement.setTimestamp(3, java.sql.Timestamp.from(now))
statement.setTimestamp(4, java.sql.Timestamp.from(now))
statement.executeUpdate()
}
}
}
private class OobeMySqlContainer(image: String) : MySQLContainer<OobeMySqlContainer>(image)