Establish secure account and managed AI backend

Provide the production foundation for Apple identity, immutable credits, referrals, integrity checks, managed providers, and hardened Docker deployment.
This commit is contained in:
Rocky
2026-08-16 14:46:23 +08:00
commit 0af35d44f4
124 changed files with 21052 additions and 0 deletions
@@ -0,0 +1,19 @@
package com.osglab.account
import io.kotest.matchers.shouldBe
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
class ApplicationTest {
@Test
fun `liveness endpoint remains independent of external services`() = testApplication {
application {
routing { healthRoutes() }
}
client.get("/health/live").status shouldBe HttpStatusCode.OK
}
}
@@ -0,0 +1,84 @@
package com.osglab.account.common.api
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.common.security.installSessionAuthentication
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.auth.authenticate
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
class ApiContractTest : FunSpec({
test("known API failures use the stable error envelope") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
routing {
get("/invalid") {
throw InvalidRequestException("Invalid input")
}
}
}
val response = client.get("/invalid")
response.status shouldBe HttpStatusCode.BadRequest
response.bodyAsText() shouldBe
"""{"error":{"code":"invalid_request","message":"Invalid input"}}"""
}
}
test("unexpected failures do not disclose exception details") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
routing {
get("/failure") {
error("database-password")
}
}
}
val response = client.get("/failure")
response.status shouldBe HttpStatusCode.InternalServerError
response.bodyAsText() shouldBe
"""{"error":{"code":"internal_error","message":"An internal error occurred"}}"""
}
}
test("authentication challenges use the same error envelope") {
testApplication {
application {
install(ContentNegotiation) { json(Json) }
installApiStatusPages()
installSessionAuthentication { null }
routing {
authenticate(SESSION_AUTH_NAME) {
get("/protected") {
call.respondText("unreachable")
}
}
}
}
val response = client.get("/protected")
response.status shouldBe HttpStatusCode.Unauthorized
response.bodyAsText() shouldBe
"""{"error":{"code":"unauthorized","message":"Authentication required"}}"""
}
}
})
@@ -0,0 +1,53 @@
package com.osglab.account.common.security
import com.osglab.account.config.SessionConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class SecurityPrimitivesTest : FunSpec({
test("refresh token hashes are deterministic without storing the token") {
TokenHash.sha256("secret") shouldBe
"2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"
TokenHash.matches("secret", TokenHash.sha256("secret")) shouldBe true
TokenHash.matches("different", TokenHash.sha256("secret")) shouldBe false
}
test("AES-GCM uses unique nonces and authenticates context") {
val encryptor = FieldEncryptor(ByteArray(32) { 3 })
val first = encryptor.encrypt("apple-refresh-token", "account:1")
val second = encryptor.encrypt("apple-refresh-token", "account:1")
first shouldNotBe second
encryptor.decrypt(first, "account:1") shouldBe "apple-refresh-token"
shouldThrow<FieldDecryptionException> {
encryptor.decrypt(first, "account:2")
}
}
test("session JWT validates issuer audience signature and claims") {
val clock = Clock.fixed(Instant.parse("2026-08-15T12:00:00Z"), ZoneOffset.UTC)
val jwt = SessionJwt(
SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 9 },
accessMinutes = 15,
refreshDays = 30,
),
clock,
)
val accountId = UUID.randomUUID()
val sessionId = UUID.randomUUID()
val issued = jwt.issue(accountId, sessionId)
jwt.verify(issued.value)?.userId shouldBe accountId
jwt.verify(issued.value)?.sessionId shouldBe sessionId
jwt.verify(issued.value + "tampered") shouldBe null
}
})
@@ -0,0 +1,153 @@
package com.osglab.account.config
import io.ktor.server.config.MapApplicationConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import java.util.Base64
class AppConfigTest : FunSpec({
test("test configuration can be injected without Apple client credentials") {
val config = AppConfig.from(validConfig("test"))
config.environment shouldBe Environment.TEST
config.apple.clientCredentialsAvailable shouldBe false
config.encryption.key.size shouldBe 32
}
test("production rejects placeholder secrets") {
val config = validProductionConfig().apply {
put("app.session.secret", "replace-with-secret")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}
}
test("production accepts complete independent configuration") {
val config = AppConfig.from(validProductionConfig())
config.environment shouldBe Environment.PRODUCTION
config.database.username shouldBe "test"
config.database.migrationUsername shouldBe "test_migrator"
}
test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply {
put("app.apple.keyId", "")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "app.apple.keyId"
}
test("production rejects monitor-only integrity configuration") {
val config = validProductionConfig().apply {
put("app.providers.volcengine.apiKey", "volcengine-key")
put("app.providers.deepseek.apiKey", "deepseek-key")
put("app.integrity.enforceDeviceCheck", "false")
put("app.integrity.enforceAppAttest", "false")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "must enforce both DeviceCheck and App Attest"
}
test("production rejects provider endpoints outside the exact host allowlist") {
val config = validProductionConfig().apply {
put("app.providers.deepseek.endpoint", "https://127.0.0.1/v1")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "DeepSeek endpoint"
}
test("production requires separate migration credentials") {
val missingMigrator = validProductionConfig().apply {
put("app.database.migrationUsername", "")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(missingMigrator)
}.message.orEmpty() shouldContain "app.database.migrationUsername"
val reusedPassword = validProductionConfig().apply {
put("app.database.migrationPassword", "database-password")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(reusedPassword)
}.message.orEmpty() shouldContain "passwords must be distinct"
}
test("production requires independent cryptographic secrets") {
val config = validProductionConfig().apply {
put(
"app.antiAbuse.identityHmacKeyBase64",
Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
)
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "must be distinct"
}
test("production requires exact public and App Store URLs") {
val publicUrl = validProductionConfig().apply {
put("app.publicBaseUrl", "https://account.osglab.com.evil.example")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(publicUrl)
}.message.orEmpty() shouldContain "PUBLIC_BASE_URL"
val appStoreUrl = validProductionConfig().apply {
put("app.appStoreUrl", "https://apps.apple.com/app/id0000000000")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(appStoreUrl)
}.message.orEmpty() shouldContain "APP_STORE_URL"
}
})
private fun validConfig(environment: String) = MapApplicationConfig(
"app.environment" to environment,
"app.database.jdbcUrl" to "jdbc:mysql://localhost:3306/test",
"app.database.username" to "test",
"app.database.password" to "database-password",
"app.database.migrationUsername" to "test_migrator",
"app.database.migrationPassword" to "migration-password",
"app.database.maximumPoolSize" to "4",
"app.session.issuer" to "https://issuer.example",
"app.session.audience" to "ios-app",
"app.session.secret" to "01234567890123456789012345678901",
"app.session.accessMinutes" to "15",
"app.session.refreshDays" to "30",
"app.encryption.keyBase64" to Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
"app.antiAbuse.identityHmacKeyBase64" to
Base64.getEncoder().encodeToString(ByteArray(32) { 8 }),
"app.apple.clientId" to "com.example.app",
"app.apple.jwksUrl" to "https://appleid.apple.com/auth/keys",
"app.apple.tokenUrl" to "https://appleid.apple.com/auth/token",
"app.apple.revokeUrl" to "https://appleid.apple.com/auth/revoke",
"app.integrity.enforceDeviceCheck" to "false",
"app.integrity.enforceAppAttest" to "false",
)
private fun validProductionConfig() = validConfig("production").apply {
put("app.publicBaseUrl", "https://account.osglab.com")
put("app.inviteBaseUrl", "https://osglab.com/i")
put("app.appStoreUrl", "https://apps.apple.com/app/id1234567890")
put("app.apple.teamId", APP_ATTEST_TEAM_ID)
put("app.apple.keyId", "APPLE_KEY")
put("app.apple.clientId", APP_ATTEST_BUNDLE_ID)
put("app.apple.privateKeyPem", "private-key-material")
put("app.integrity.appleEnvironment", "production")
put("app.integrity.enforceDeviceCheck", "true")
put("app.integrity.enforceAppAttest", "true")
put("app.providers.volcengine.apiKey", "volcengine-key")
put("app.providers.deepseek.apiKey", "deepseek-key")
}
@@ -0,0 +1,119 @@
package com.osglab.account.config
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.nio.file.Files
import java.nio.file.Path
class DeploymentConsistencyTest : FunSpec({
val root = Path.of(System.getProperty("user.dir"))
test("OpenAPI documents every mounted public route") {
val openApi = root.read("docs/openapi.yaml")
val documentedPaths = Regex("""(?m)^ (/[^:]+):\s*$""")
.findAll(openApi)
.map { it.groupValues[1] }
.toSet()
documentedPaths shouldBe EXPECTED_PUBLIC_PATHS
}
test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml")
compose shouldContain "127.0.0.1:\${ACCOUNT_BIND_PORT:-18080}:8080"
compose shouldContain "external: true"
compose shouldContain "account-egress:"
compose shouldContain "user: \"10001:10001\""
compose shouldContain "read_only: true"
compose shouldContain "cap_drop:"
compose shouldContain "no-new-privileges:true"
compose shouldNotContain "image: mysql"
compose shouldNotContain "3306:3306"
compose shouldNotContain "0.0.0.0:"
}
test("container image remains non-root and read-only compatible") {
val dockerfile = root.read("Dockerfile")
dockerfile shouldContain "USER 10001:10001"
dockerfile shouldContain "ENV HOME=/tmp"
dockerfile shouldNotContain "ENTRYPOINT [\"sh\""
}
test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") {
val openResty = root.read("deploy/openresty-account.conf")
openResty shouldContain "proxy_set_header Upgrade \$http_upgrade;"
openResty shouldContain "proxy_set_header Connection \$connection_upgrade;"
openResty shouldContain "location = /.well-known/apple-app-site-association"
openResty shouldContain "location = /apple-app-site-association"
openResty shouldContain "location ^~ /i/"
Regex("""location \^~ /i/ \{\s+access_log off;""").containsMatchIn(openResty) shouldBe true
openResty shouldNotContain "alias /www/wwwroot/osglab.com/apple-app-site-association"
}
test("CI definition is singular and leaves MySQL lifecycle to Testcontainers") {
val ci = root.read(".github/workflows/ci.yml")
Regex("""(?m)^name: CI$""").findAll(ci).count() shouldBe 1
Regex("""(?m)^jobs:$""").findAll(ci).count() shouldBe 1
ci shouldContain "docker compose -f compose.yaml config --quiet"
ci shouldContain "./gradlew --no-daemon clean test"
ci shouldContain "./gradlew --no-daemon buildFatJar"
ci shouldNotContain "3306:3306"
ci shouldNotContain "TEST_DB_"
}
test("AASA has one runtime template and no deploy-time identifier placeholder") {
val aasa = root.read("src/main/resources/invite/apple-app-site-association.json")
aasa shouldContain "\"{{APPLE_APP_ID}}\""
aasa shouldContain "\"/i/*\""
Files.exists(root.resolve("deploy/apple-app-site-association")) shouldBe false
}
})
private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath))
private val EXPECTED_PUBLIC_PATHS = setOf(
"/health",
"/health/live",
"/health/ready",
"/v1/auth/apple",
"/v1/auth/refresh",
"/v1/auth/logout",
"/v1/account",
"/v1/apple/events",
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
"/v1/credits/reservations",
"/v1/credits/reservations/{reservationId}",
"/v1/credits/reservations/{reservationId}/settle",
"/v1/credits/reservations/{reservationId}/release",
"/v1/credits/reservations/{reservationId}/refund",
"/v1/referrals",
"/v1/referrals/me",
"/v1/referrals/code",
"/v1/referrals/redeem",
"/v1/referrals/bind",
"/v1/referrals/campaigns",
"/v1/integrity/challenges",
"/v1/integrity/attest",
"/v1/integrity/assert",
"/v1/gateway/catalog",
"/v1/gateway/grants",
"/v1/gateway/grants/refresh",
"/v1/gateway/grants/{grantId}",
"/v1/gateway/llm/{capability}",
"/v1/gateway/asr",
"/v1/gateway/asr/sessions",
"/v1/gateway/asr/sessions/{sessionId}/stream",
"/.well-known/apple-app-site-association",
"/apple-app-site-association",
"/i/{code}",
)
@@ -0,0 +1,171 @@
package com.osglab.account.features.account
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.config.AntiAbuseConfig
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AppleTokenExchange
import com.osglab.account.features.auth.AppleClientUnavailableException
import com.osglab.account.features.auth.appleRefreshContext
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class AccountServiceTest : FunSpec({
test("account deletion commits locally before reliably revoking the Apple token") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val events = mutableListOf<String>()
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "a".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = encryptor.encrypt(
"apple-refresh",
appleRefreshContext(accountId),
),
createdAt = Instant.EPOCH,
),
events,
)
val appleClient = RecordingAppleTokenClient(events)
val processor = AppleRevocationOutboxProcessor(repository, appleClient, encryptor)
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
processor,
AccountReauthenticator { _, _ -> "apple-refresh" },
)
service.delete(accountId, REAUTH_PROOF)
appleClient.revokedToken shouldBe "apple-refresh"
repository.deleted shouldBe true
events shouldBe listOf("local-delete", "apple-revoke", "outbox-complete")
}
test("Apple outage never rolls back local deletion and leaves durable outbox work") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val events = mutableListOf<String>()
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "b".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = encryptor.encrypt(
"apple-refresh",
appleRefreshContext(accountId),
),
createdAt = Instant.EPOCH,
),
events,
)
val appleClient = RecordingAppleTokenClient(events, unavailable = true)
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
AppleRevocationOutboxProcessor(repository, appleClient, encryptor),
AccountReauthenticator { _, _ -> "apple-refresh" },
)
service.delete(accountId, REAUTH_PROOF)
repository.deleted shouldBe true
repository.pendingCount shouldBe 1
}
test("account deletion requires recent matching Apple credentials") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "c".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = null,
createdAt = Instant.EPOCH,
),
mutableListOf(),
)
val appleClient = RecordingAppleTokenClient(mutableListOf())
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
AppleRevocationOutboxProcessor(repository, appleClient, encryptor),
AccountReauthenticator { _, _ -> throw UnauthorizedException() },
)
shouldThrow<UnauthorizedException> {
service.delete(accountId, REAUTH_PROOF)
}
repository.deleted shouldBe false
}
})
private val REAUTH_PROOF = AppleReauthenticationProof(
identityToken = "identity-token",
authorizationCode = "authorization-code",
nonce = "nonce",
)
private class RecordingAccountRepository(
private val account: AccountRecord,
private val events: MutableList<String>,
) : AccountRepository {
var deleted = false
private var pending: AppleRevocationOutboxRecord? = null
val pendingCount: Int get() = if (pending == null) 0 else 1
override suspend fun findById(accountId: UUID): AccountRecord? = account
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
tombstoneExpiresAt: Instant,
createRevocation: (String?) -> NewAppleRevocation?,
): Boolean {
deleted = true
events += "local-delete"
val revocation = createRevocation(account.encryptedAppleRefreshToken)
pending = revocation?.let {
AppleRevocationOutboxRecord(it.id, it.encryptedRefreshToken, 0)
}
return true
}
override suspend fun pendingAppleRevocations(
now: Instant,
limit: Int,
): List<AppleRevocationOutboxRecord> = listOfNotNull(pending)
override suspend fun rescheduleAppleRevocation(id: UUID, nextAttemptAt: Instant) = Unit
override suspend fun completeAppleRevocation(id: UUID, completedAt: Instant) {
pending = null
events += "outbox-complete"
}
}
private class RecordingAppleTokenClient(
private val events: MutableList<String>,
private val unavailable: Boolean = false,
) : AppleTokenClient {
var revokedToken: String? = null
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange =
error("Not used by account deletion")
override suspend fun revokeRefreshToken(refreshToken: String) {
if (unavailable) throw AppleClientUnavailableException()
revokedToken = refreshToken
events += "apple-revoke"
}
}
@@ -0,0 +1,14 @@
package com.osglab.account.features.appleevents
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
class AppleEventRepositoryTest : FunSpec({
test("both official and observed Apple account deletion event names terminate accounts") {
isAccountTerminatingAppleEvent("account-delete").shouldBeTrue()
isAccountTerminatingAppleEvent("account-deleted").shouldBeTrue()
isAccountTerminatingAppleEvent("consent-revoked").shouldBeTrue()
isAccountTerminatingAppleEvent("email-enabled").shouldBeFalse()
}
})
@@ -0,0 +1,114 @@
package com.osglab.account.features.appleevents
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import com.osglab.account.features.auth.AppleJwksProvider
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Date
class AppleEventVerifierTest : FunSpec({
val now = Instant.parse("2026-08-16T00:00:00Z")
val trustedKey = newRsaKey("apple-events")
val verifier = AppleEventVerifier(
config = AppleConfig(
teamId = null,
keyId = null,
clientId = "com.example.ios",
privateKeyPem = null,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
),
jwksProvider = object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? =
trustedKey.toPublicJWK().takeIf { keyId == trustedKey.keyID }
},
clock = Clock.fixed(now, ZoneOffset.UTC),
)
test("accepts event fields only after the JWS signature is verified") {
val event = verifier.verify(signedEvent(trustedKey, now))
event shouldBe VerifiedAppleEvent(
eventId = "event-1",
type = "consent-revoked",
appleSubject = "apple-subject",
)
}
test("rejects an attacker-signed payload even when its claims look valid") {
val attackerKey = newRsaKey("apple-events")
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(attackerKey, now))
}
}
test("rejects an unsigned JSON payload") {
shouldThrow<InvalidAppleEventException> {
verifier.verify("""{"events":{"type":"account-delete","sub":"apple-subject"}}""")
}
}
test("rejects an event without expiration or with ambiguous audiences") {
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(trustedKey, now, includeExpiration = false))
}
shouldThrow<InvalidAppleEventException> {
verifier.verify(signedEvent(trustedKey, now, additionalAudience = "other-client"))
}
}
test("event string rendering never exposes the Apple subject") {
VerifiedAppleEvent("event-1", "consent-revoked", "sensitive-apple-subject").toString() shouldBe
"VerifiedAppleEvent(eventId=event-1, type=consent-revoked, appleSubject=[REDACTED])"
}
})
private fun newRsaKey(keyId: String): RSAKey {
val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
return RSAKey.Builder(pair.public as RSAPublicKey)
.privateKey(pair.private as RSAPrivateKey)
.keyID(keyId)
.build()
}
private fun signedEvent(
key: RSAKey,
now: Instant,
includeExpiration: Boolean = true,
additionalAudience: String? = null,
): String {
val claimsBuilder = JWTClaimsSet.Builder()
.issuer("https://appleid.apple.com")
.audience(listOfNotNull("com.example.ios", additionalAudience))
.jwtID("event-1")
.issueTime(Date.from(now))
.claim(
"events",
"""{"type":"consent-revoked","sub":"apple-subject"}""",
)
if (includeExpiration) {
claimsBuilder.expirationTime(Date.from(now.plusSeconds(300)))
}
val claims = claimsBuilder.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(),
claims,
).apply {
sign(RSASSASigner(key.toPrivateKey()))
}.serialize()
}
@@ -0,0 +1,74 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.ECDSAVerifier
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Base64
class AppleClientSecretProviderTest : FunSpec({
test("creates a verifiable short-lived ES256 Apple client secret") {
val now = Instant.parse("2026-08-16T00:00:00Z")
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val privateKeyPem = Base64.getMimeEncoder(64, "\n".toByteArray())
.encodeToString(keyPair.private.encoded)
.let { "-----BEGIN PRIVATE KEY-----\n$it\n-----END PRIVATE KEY-----" }
val config = AppleConfig(
teamId = "TEAM123",
keyId = "KEY123",
clientId = "com.example.ios",
privateKeyPem = privateKeyPem,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
val serialized = AppleClientSecretProvider(
config,
Clock.fixed(now, ZoneOffset.UTC),
).create()
val jwt = SignedJWT.parse(serialized)
jwt.header.algorithm shouldBe JWSAlgorithm.ES256
jwt.header.keyID shouldBe "KEY123"
jwt.verify(ECDSAVerifier(keyPair.public as ECPublicKey)) shouldBe true
jwt.jwtClaimsSet.issuer shouldBe "TEAM123"
jwt.jwtClaimsSet.subject shouldBe "com.example.ios"
jwt.jwtClaimsSet.audience shouldBe listOf("https://appleid.apple.com")
jwt.jwtClaimsSet.issueTime.toInstant() shouldBe now
jwt.jwtClaimsSet.expirationTime.toInstant() shouldBe now.plusSeconds(300)
}
test("rejects an EC key that is not Apple P-256") {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp384r1"))
}.generateKeyPair()
val privateKeyPem = Base64.getMimeEncoder(64, "\n".toByteArray())
.encodeToString(keyPair.private.encoded)
.let { "-----BEGIN PRIVATE KEY-----\n$it\n-----END PRIVATE KEY-----" }
val config = AppleConfig(
teamId = "TEAM123",
keyId = "KEY123",
clientId = "com.example.ios",
privateKeyPem = privateKeyPem,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
shouldThrow<AppleClientUnavailableException> {
AppleClientSecretProvider(config).create()
}
}
})
@@ -0,0 +1,180 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.KeyUse
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.config.AppleConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.security.MessageDigest
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Date
class AppleIdentityTokenVerifierTest : FunSpec({
val now = Instant.parse("2026-08-15T12:00:00Z")
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val jwk = RSAKey.Builder(keyPair.public as RSAPublicKey)
.privateKey(keyPair.private as RSAPrivateKey)
.keyID("apple-key")
.build()
val config = AppleConfig(
teamId = null,
keyId = null,
clientId = "com.example.ios",
privateKeyPem = null,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
val provider = object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? =
jwk.toPublicJWK().takeIf { keyId == "apple-key" }
}
val verifier = AppleIdentityTokenVerifier(
config,
provider,
Clock.fixed(now, ZoneOffset.UTC),
)
test("accepts a correctly signed token with matching nonce") {
val token = identityToken(
jwk,
now,
"com.example.ios",
sha256("nonce-123"),
)
verifier.verify(token, "nonce-123").subject shouldBe "apple-subject"
}
test("rejects a token for another audience") {
val token = identityToken(jwk, now, "other-client", sha256("nonce-123"))
shouldThrow<AppleTokenInvalidException> {
verifier.verify(token, "nonce-123")
}
}
test("rejects ambiguous audiences and a missing expiration") {
val ambiguousAudience = identityToken(
jwk,
now,
"com.example.ios",
sha256("nonce-123"),
additionalAudience = "other-client",
)
val missingExpiration = identityToken(
jwk,
now,
"com.example.ios",
sha256("nonce-123"),
includeExpiration = false,
)
shouldThrow<AppleTokenInvalidException> {
verifier.verify(ambiguousAudience, "nonce-123")
}
shouldThrow<AppleTokenInvalidException> {
verifier.verify(missingExpiration, "nonce-123")
}
}
test("rejects a JWK not designated for signature verification") {
val unsuitableKey = RSAKey.Builder(keyPair.public as RSAPublicKey)
.keyID(jwk.keyID)
.keyUse(KeyUse.ENCRYPTION)
.build()
val unsuitableVerifier = AppleIdentityTokenVerifier(
config,
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? = unsuitableKey
},
Clock.fixed(now, ZoneOffset.UTC),
)
shouldThrow<AppleTokenInvalidException> {
unsuitableVerifier.verify(
identityToken(jwk, now, "com.example.ios", sha256("nonce-123")),
"nonce-123",
)
}
}
test("rejects an expired token or an untrusted issuer") {
val expired = identityToken(
jwk = jwk,
now = now.minusSeconds(600),
audience = "com.example.ios",
nonce = sha256("nonce-123"),
expiresAt = now.minusSeconds(60),
)
val wrongIssuer = identityToken(
jwk = jwk,
now = now,
audience = "com.example.ios",
nonce = sha256("nonce-123"),
issuer = "https://attacker.example",
)
shouldThrow<AppleTokenInvalidException> {
verifier.verify(expired, "nonce-123")
}
shouldThrow<AppleTokenInvalidException> {
verifier.verify(wrongIssuer, "nonce-123")
}
}
test("nonce verification accepts only the SHA-256 claim") {
AppleNonceVerifier.matches("nonce-123", sha256("nonce-123")) shouldBe true
AppleNonceVerifier.matches("nonce-123", "nonce-123") shouldBe false
AppleNonceVerifier.matches("nonce-123", sha256("another-nonce")) shouldBe false
}
test("Apple identity string rendering never exposes the subject") {
AppleIdentity("sensitive-apple-subject").toString() shouldBe
"AppleIdentity(subject=[REDACTED])"
}
})
private fun identityToken(
jwk: RSAKey,
now: Instant,
audience: String,
nonce: String,
issuer: String = "https://appleid.apple.com",
expiresAt: Instant = now.plusSeconds(300),
additionalAudience: String? = null,
includeExpiration: Boolean = true,
): String {
val claimsBuilder = JWTClaimsSet.Builder()
.issuer(issuer)
.audience(listOfNotNull(audience, additionalAudience))
.subject("apple-subject")
.issueTime(Date.from(now))
.claim("nonce", nonce)
if (includeExpiration) {
claimsBuilder.expirationTime(Date.from(expiresAt))
}
val claims = claimsBuilder.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(jwk.keyID).build(),
claims,
).apply {
sign(RSASSASigner(jwk.toPrivateKey()))
}.serialize()
}
private fun sha256(value: String): String =
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
@@ -0,0 +1,123 @@
package com.osglab.account.features.auth
import com.osglab.account.config.AppleConfig
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.forms.FormDataContent
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import java.net.SocketTimeoutException
class AppleTokenClientTest : FunSpec({
test("authorization code exchange uses the replaceable HTTP boundary") {
val engine = MockEngine { request ->
request.url.toString() shouldBe "https://appleid.apple.com/auth/token"
val form = (request.body as FormDataContent).formData
form["client_id"] shouldBe "com.example.ios"
form["client_secret"] shouldBe "signed-client-secret"
form["code"] shouldBe "one-time-code"
form["grant_type"] shouldBe "authorization_code"
respond(
content = """{"refresh_token":"refresh","id_token":"identity"}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
client,
appleTokenClientConfig(),
AppleClientSecretSigner { "signed-client-secret" },
)
apple.exchangeAuthorizationCode("one-time-code") shouldBe
AppleTokenExchange("refresh", "identity")
client.close()
}
test("provider timeout is mapped to a retryable failure") {
val engine = MockEngine {
throw SocketTimeoutException("simulated provider timeout")
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
httpClient = client,
config = appleTokenClientConfig(),
clientSecretProvider = AppleClientSecretSigner { "signed-client-secret" },
requestTimeoutMillis = 10,
)
shouldThrow<AppleTokenEndpointException> {
apple.exchangeAuthorizationCode("one-time-code")
}.retryable shouldBe true
client.close()
}
test("authorization code exchange rejects a response without a refresh token") {
val engine = MockEngine {
respond(
content = """{"id_token":"identity"}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
client,
appleTokenClientConfig(),
AppleClientSecretSigner { "signed-client-secret" },
)
shouldThrow<AppleTokenEndpointException> {
apple.exchangeAuthorizationCode("one-time-code")
}.retryable shouldBe false
client.close()
}
test("rate limiting is retryable without reflecting the provider response") {
val engine = MockEngine {
respond(
content = "upstream detail that must not be reflected",
status = HttpStatusCode.TooManyRequests,
)
}
val client = HttpClient(engine) { install(HttpTimeout) }
val apple = HttpAppleTokenClient(
client,
appleTokenClientConfig(),
AppleClientSecretSigner { "signed-client-secret" },
)
val failure = shouldThrow<AppleTokenEndpointException> {
apple.exchangeAuthorizationCode("one-time-code")
}
failure.retryable shouldBe true
failure.message shouldBe "Apple rejected the authorization code"
client.close()
}
test("token exchange values are redacted from string rendering") {
AppleTokenExchange("refresh-secret", "identity-secret").toString() shouldBe
"AppleTokenExchange(refreshToken=[REDACTED], identityToken=[REDACTED])"
}
})
private fun appleTokenClientConfig() = AppleConfig(
teamId = "TEAM",
keyId = "KEY",
clientId = "com.example.ios",
privateKeyPem = "unused-by-test-signer",
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
@@ -0,0 +1,78 @@
package com.osglab.account.features.auth
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.config.SessionConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class SessionAccessAuthenticatorTest : FunSpec({
test("a signed access token is rejected immediately after its family or account is deleted") {
val now = Instant.parse("2026-08-16T00:00:00Z")
val clock = Clock.fixed(now, ZoneOffset.UTC)
val repository = MutableSessionStateRepository()
val jwt = SessionJwt(
SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 1 },
accessMinutes = 15,
refreshDays = 30,
),
clock,
)
val accountId = UUID.randomUUID()
val familyId = UUID.randomUUID()
val token = jwt.issue(accountId, familyId).value
val authenticator = SessionAccessAuthenticator(jwt, repository, clock)
authenticator.authenticate(token).shouldNotBeNull()
repository.active = false
authenticator.authenticate(token).shouldBeNull()
}
})
private class MutableSessionStateRepository : AuthRepository {
var active = true
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = active
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount = error("Not used")
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) = error("Not used")
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = error("Not used")
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = error("Not used")
override suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean =
error("Not used")
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
@@ -0,0 +1,405 @@
package com.osglab.account.features.auth
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.common.errors.TokenReuseException
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.RefreshTokenGenerator
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.TokenHash
import com.osglab.account.config.AppleConfig
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import com.osglab.account.config.SessionConfig
import com.osglab.account.features.integrity.IntegrityService
import com.osglab.account.features.integrity.IntegrityEvidence
import com.osglab.account.features.integrity.UnavailableAppAttestVerifier
import com.osglab.account.features.integrity.UnavailableDeviceCheckVerifier
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Base64
import java.util.Date
import java.util.UUID
class SessionServiceTest : FunSpec({
test("refresh tokens contain 256 bits of URL-safe randomness") {
val generator = RefreshTokenGenerator()
val first = generator.newRefreshToken()
val second = generator.newRefreshToken()
Base64.getUrlDecoder().decode(first).size shouldBe 32
(first != second) shouldBe true
}
test("Apple sign-in verifies both tokens and persists only protected credentials") {
val now = Instant.parse("2026-08-16T00:00:00Z")
val clock = Clock.fixed(now, ZoneOffset.UTC)
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val key = RSAKey.Builder(keyPair.public as RSAPublicKey)
.privateKey(keyPair.private as RSAPrivateKey)
.keyID("apple-key")
.build()
val nonce = "one-time-nonce"
val identityToken = signedIdentityToken(key, now, nonce)
val accountId = UUID.randomUUID()
val repository = SuccessfulAuthRepository(accountId)
val sessionConfig = sessionConfig()
val encryptor = FieldEncryptor(ByteArray(32) { 4 })
var exchangedCode: String? = null
val service = SessionService(
repository = repository,
appleIdentityVerifier = AppleIdentityTokenVerifier(
appleConfig(),
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? =
key.toPublicJWK().takeIf { keyId == key.keyID }
},
clock,
),
appleTokenClient = object : AppleTokenClient {
override suspend fun exchangeAuthorizationCode(code: String): AppleTokenExchange {
exchangedCode = code
return AppleTokenExchange("apple-refresh", identityToken)
}
override suspend fun revokeRefreshToken(refreshToken: String) = error("Not used")
},
integrityService = monitorOnlyIntegrityService(),
sessionJwt = SessionJwt(sessionConfig, clock),
fieldEncryptor = encryptor,
identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }),
sessionConfig = sessionConfig,
clock = clock,
)
val tokens = service.signInWithApple(
identityToken = identityToken,
authorizationCode = "authorization-code",
nonce = nonce,
integrityEvidence = IntegrityEvidence(),
)
exchangedCode shouldBe "authorization-code"
tokens.accountId shouldBe accountId
repository.refreshTokenHash shouldBe TokenHash.sha256(tokens.refreshToken)
(repository.encryptedAppleSubject == "apple-subject") shouldBe false
(repository.encryptedAppleRefreshToken == "apple-refresh") shouldBe false
encryptor.decrypt(
requireNotNull(repository.encryptedAppleSubject),
appleSubjectContext(requireNotNull(repository.identityFingerprint)),
) shouldBe "apple-subject"
encryptor.decrypt(
requireNotNull(repository.encryptedAppleRefreshToken),
appleRefreshContext(accountId),
) shouldBe "apple-refresh"
SessionJwt(sessionConfig, clock).verify(tokens.accessToken)?.sessionId shouldBe
repository.sessionId
}
test("refresh token reuse is surfaced and no replacement tokens are issued") {
val sessionConfig = sessionConfig()
val service = SessionService(
repository = ReuseDetectingRepository,
appleIdentityVerifier = AppleIdentityTokenVerifier(
appleConfig(),
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? = null
},
),
appleTokenClient = UnavailableAppleTokenClient(),
integrityService = monitorOnlyIntegrityService(),
sessionJwt = SessionJwt(sessionConfig),
fieldEncryptor = FieldEncryptor(ByteArray(32) { 4 }),
identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }),
sessionConfig = sessionConfig,
)
shouldThrow<TokenReuseException> {
service.refresh("already-used-token")
}
}
test("concurrent refresh accepts once and revokes the family on replay") {
val repository = ConcurrentRotationRepository()
val sessionConfig = sessionConfig()
val service = SessionService(
repository = repository,
appleIdentityVerifier = AppleIdentityTokenVerifier(
appleConfig(),
object : AppleJwksProvider {
override suspend fun rsaKey(keyId: String): RSAKey? = null
},
),
appleTokenClient = UnavailableAppleTokenClient(),
integrityService = monitorOnlyIntegrityService(),
sessionJwt = SessionJwt(sessionConfig),
fieldEncryptor = FieldEncryptor(ByteArray(32) { 4 }),
identityFingerprint = IdentityFingerprint(ByteArray(32) { 6 }),
sessionConfig = sessionConfig,
)
val results = coroutineScope {
List(2) {
async { runCatching { service.refresh("same-refresh-token") } }
}.awaitAll()
}
results.count { it.isSuccess } shouldBe 1
results.count { it.exceptionOrNull() is TokenReuseException } shouldBe 1
repository.familyRevoked shouldBe true
}
test("refresh rotation policy rotates only an active unconsumed token") {
val now = Instant.parse("2026-08-16T00:00:00Z")
RefreshRotationPolicy.decide(
revoked = false,
replaced = false,
expiresAt = now.plusSeconds(1),
now = now,
) shouldBe RefreshRotationDecision.ROTATE
RefreshRotationPolicy.decide(
revoked = false,
replaced = false,
expiresAt = now,
now = now,
) shouldBe RefreshRotationDecision.REVOKE_EXPIRED
}
test("refresh rotation policy treats any consumed token as family reuse") {
val now = Instant.parse("2026-08-16T00:00:00Z")
RefreshRotationPolicy.decide(
revoked = true,
replaced = false,
expiresAt = now.plusSeconds(60),
now = now,
) shouldBe RefreshRotationDecision.REVOKE_REUSED_FAMILY
RefreshRotationPolicy.decide(
revoked = false,
replaced = true,
expiresAt = now.plusSeconds(60),
now = now,
) shouldBe RefreshRotationDecision.REVOKE_REUSED_FAMILY
}
})
private class SuccessfulAuthRepository(
private val accountId: UUID,
) : AuthRepository {
val sessionId: UUID = UUID.randomUUID()
var encryptedAppleSubject: String? = null
var identityFingerprint: String? = null
var encryptedAppleRefreshToken: String? = null
var refreshTokenHash: String? = null
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount {
this.identityFingerprint = identityFingerprint
this.encryptedAppleSubject = encryptedAppleSubject
return AuthAccount(accountId, identityFingerprint, false)
}
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) {
encryptedAppleRefreshToken = encryptedToken
}
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession {
this.refreshTokenHash = refreshTokenHash
return CreatedSession(accountId, sessionId, sessionId)
}
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = error("Not used")
override suspend fun revokeSessionFamily(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = error("Not used")
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = true
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
private data object ReuseDetectingRepository : AuthRepository {
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount =
error("Not used")
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
): Unit = error("Not used")
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = error("Not used")
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = RefreshRotationResult.ReuseDetected
override suspend fun revokeSessionFamily(accountId: UUID, sessionId: UUID, now: Instant): Boolean =
error("Not used")
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = false
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
private class ConcurrentRotationRepository : AuthRepository {
private val mutex = Mutex()
private var consumed = false
var familyRevoked = false
private set
override suspend fun rotateRefreshToken(
currentTokenHash: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): RefreshRotationResult = mutex.withLock {
if (consumed) {
familyRevoked = true
RefreshRotationResult.ReuseDetected
} else {
consumed = true
RefreshRotationResult.Rotated(
accountId = UUID.randomUUID(),
sessionId = UUID.randomUUID(),
familyId = UUID.randomUUID(),
)
}
}
override suspend fun findOrCreateAccount(
identityFingerprint: String,
encryptedAppleSubject: String,
now: Instant,
): AuthAccount = error("Not used")
override suspend fun updateAppleRefreshToken(
accountId: UUID,
encryptedToken: String,
now: Instant,
) = error("Not used")
override suspend fun createSession(
accountId: UUID,
refreshTokenHash: String,
expiresAt: Instant,
now: Instant,
): CreatedSession = error("Not used")
override suspend fun revokeSessionFamily(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = error("Not used")
override suspend fun isSessionActive(
accountId: UUID,
sessionId: UUID,
now: Instant,
): Boolean = !familyRevoked
override suspend fun restrictAccountForAntiAbuse(accountId: UUID, now: Instant) = Unit
}
private fun appleConfig() = AppleConfig(
teamId = null,
keyId = null,
clientId = "com.example.ios",
privateKeyPem = null,
jwksUrl = "https://appleid.apple.com/auth/keys",
tokenUrl = "https://appleid.apple.com/auth/token",
revokeUrl = "https://appleid.apple.com/auth/revoke",
)
private fun sessionConfig() = SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 8 },
accessMinutes = 15,
refreshDays = 30,
)
private fun monitorOnlyIntegrityService() = IntegrityService(
IntegrityConfig(IntegrityPolicy.MONITOR, IntegrityPolicy.MONITOR),
UnavailableDeviceCheckVerifier(),
UnavailableAppAttestVerifier(),
)
private fun signedIdentityToken(key: RSAKey, now: Instant, rawNonce: String): String {
val nonce = MessageDigest.getInstance("SHA-256")
.digest(rawNonce.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
val claims = JWTClaimsSet.Builder()
.issuer("https://appleid.apple.com")
.audience("com.example.ios")
.subject("apple-subject")
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plusSeconds(300)))
.claim("nonce", nonce)
.build()
return SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(),
claims,
).apply {
sign(RSASSASigner(key.toPrivateKey()))
}.serialize()
}
@@ -0,0 +1,576 @@
package com.osglab.account.features.credits
import com.osglab.account.features.credits.domain.CreditCostCalculator
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.ReservationStateRules
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.domain.externalIdempotencyKey
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.random.Random
class CreditServiceTest : FunSpec({
val now = Instant.parse("2026-08-15T00:00:00Z")
test("ASR and LLM rates round each billed dimension upward") {
CreditCostCalculator.calculate(
asrRate(now),
UsageMeasurement.Asr(durationMillis = 101),
) shouldBeExactly 2
CreditCostCalculator.calculate(
llmRate(now),
UsageMeasurement.Llm(inputTokens = 1001, outputTokens = 1),
) shouldBeExactly 4
}
test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") {
CreditCostCalculator.calculate(
asrRate(now).copy(
asrCreditsNumerator = 2,
asrMillisDenominator = 2,
),
UsageMeasurement.Asr(Long.MAX_VALUE),
) shouldBeExactly Long.MAX_VALUE
shouldThrow<InvalidCreditRequest> {
CreditCostCalculator.calculate(
asrRate(now).copy(
asrCreditsNumerator = 2,
asrMillisDenominator = 1,
),
UsageMeasurement.Asr(Long.MAX_VALUE),
)
}
}
test("signup grant is idempotent") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-001")
service.grantSignupTrial(userId, 100, "signup-key-001")
store.balance(userId) shouldBeExactly 100
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
}
test("balance overflow rolls back without appending a ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, Long.MAX_VALUE, "signup-max-key")
shouldThrow<InvalidCreditRequest> {
service.grantSignupTrial(userId, 1, "signup-overflow-key")
}
store.balance(userId) shouldBeExactly Long.MAX_VALUE
store.ledger.filter { it.userId == userId } shouldHaveSize 1
}
test("concurrent reservations cannot make the account negative") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-002")
val results = coroutineScope {
listOf("reserve-key-001", "reserve-key-002").map { key ->
async(Dispatchers.Default) {
runCatching {
service.reserve(
userId = userId,
provider = "asr-provider",
model = "asr-model",
estimatedUsage = UsageMeasurement.Asr(6_000),
managedCall = true,
idempotencyKey = key,
)
}
}
}.awaitAll()
}
results.count(Result<*>::isSuccess) shouldBe 1
results.single(Result<*>::isFailure).exceptionOrNull()
.shouldBeInstanceOf<InsufficientCredits>()
store.balance(userId) shouldBeExactly 40
}
test("first positive managed settlement rewards both users exactly once") {
val store = storeWithRates(now)
val service = service(store, now)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
val binding = ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
)
store.inTransaction { it.referrals.insertBindingIfAbsent(binding) }
service.grantSignupTrial(invitee, 100, "signup-key-003")
val reservation = service.reserve(
userId = invitee,
provider = "asr-provider",
model = "asr-model",
estimatedUsage = UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "reserve-key-003",
)
val first = service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"settle-key-003",
)
val retried = service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"settle-key-003",
)
first.status shouldBe ReservationStatus.SETTLED
retried shouldBe first
store.balance(invitee) shouldBeExactly 125
store.balance(inviter) shouldBeExactly 30
store.ledger.filter {
it.type == LedgerEntryType.REFERRAL_INVITEE ||
it.type == LedgerEntryType.REFERRAL_INVITER
} shouldHaveSize 2
store.usageRecords shouldHaveSize 1
store.usageRecords.single().rateVersionId shouldBe reservation.rateVersionId
store.bindings.getValue(invitee).rewardSettlementId shouldBe reservation.id
}
test("zero-cost managed settlement does not consume first valid referral reward") {
val store = storeWithRates(now)
val service = service(store, now)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
),
)
}
service.grantSignupTrial(invitee, 100, "zero-signup-key")
val zeroCost = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "zero-reserve-key",
)
service.settle(
invitee,
zeroCost.id,
UsageMeasurement.Asr(0),
"zero-settle-key",
)
store.bindings.getValue(invitee).rewardStatus shouldBe ReferralRewardStatus.PENDING
val qualifying = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "valid-reserve-key",
)
service.settle(
invitee,
qualifying.id,
UsageMeasurement.Asr(1),
"valid-settle-key",
)
store.bindings.getValue(invitee).rewardSettlementId shouldBe qualifying.id
store.ledger.count {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
} shouldBe 2
}
test("concurrent settlement replay grants both referral sides only once") {
val store = storeWithRates(now)
val service = service(store, now)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
),
)
}
service.grantSignupTrial(invitee, 100, "concurrent-signup-key")
val reservation = service.reserve(
userId = invitee,
provider = "asr-provider",
model = "asr-model",
estimatedUsage = UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "concurrent-reserve-key",
)
val results = coroutineScope {
List(8) {
async(Dispatchers.Default) {
service.settle(
userId = invitee,
reservationId = reservation.id,
actualUsage = UsageMeasurement.Asr(500),
idempotencyKey = "concurrent-settle-key",
)
}
}.awaitAll()
}
results.distinct() shouldHaveSize 1
store.usageRecords shouldHaveSize 1
store.ledger.count {
it.type == LedgerEntryType.REFERRAL_INVITEE ||
it.type == LedgerEntryType.REFERRAL_INVITER
} shouldBe 2
store.balance(inviter) shouldBeExactly 30
store.balance(invitee) shouldBeExactly 125
}
test("ledger projection remains non-negative across randomized terminal operations") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 20_000, "property-signup-key")
val random = Random(42)
repeat(100) { index ->
val estimate = random.nextLong(1, 5_000)
val reservation = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(estimate),
managedCall = false,
idempotencyKey = "property-reserve-$index",
)
if (index % 3 == 0) {
service.release(userId, reservation.id, "property-release-$index")
} else {
val actual = random.nextLong(0, estimate + 1)
service.settle(
userId,
reservation.id,
UsageMeasurement.Asr(actual),
"property-settle-$index",
)
if (index % 5 == 0) {
service.refund(userId, reservation.id, "property-refund-$index")
}
}
}
var projection = 0L
store.ledger.filter { it.userId == userId }.forEach { entry ->
projection = Math.addExact(projection, entry.amountDelta)
entry.balanceAfter shouldBeExactly projection
(projection >= 0) shouldBe true
}
store.balance(userId) shouldBeExactly projection
}
test("campaign cap is consumed once and later qualification is not rewarded") {
val store = storeWithRates(now)
val service = service(store, now)
val campaignId = UUID.randomUUID()
store.campaigns[campaignId] = ReferralCampaign(
id = campaignId,
name = "One reward",
startsAt = now.minusSeconds(60),
endsAt = null,
bindingWindowSeconds = 604_800,
inviterRewardCredits = 7,
inviteeRewardCredits = 5,
maxRewardedBindings = 1,
budgetCredits = 12,
enabled = true,
)
store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now)
val inviter = UUID.randomUUID()
val invitees = List(2) { UUID.randomUUID() }
invitees.forEach { invitee ->
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = campaignId,
),
)
}
service.grantSignupTrial(invitee, 100, "campaign-signup-$invitee")
val reservation = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "campaign-reserve-$invitee",
)
service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"campaign-settle-$invitee",
)
}
store.ledger.count {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
} shouldBe 2
store.campaignBudgets.getValue(campaignId).rewardedBindings shouldBeExactly 1
store.bindings.getValue(invitees[1]).rewardStatus shouldBe
ReferralRewardStatus.INELIGIBLE_BUDGET
}
test("exhausted campaign counters fail closed without partial rewards") {
val store = storeWithRates(now)
val service = service(store, now)
val campaignId = UUID.randomUUID()
store.campaigns[campaignId] = ReferralCampaign(
id = campaignId,
name = "Exhausted",
startsAt = now.minusSeconds(60),
endsAt = null,
bindingWindowSeconds = 604_800,
inviterRewardCredits = 7,
inviteeRewardCredits = 5,
maxRewardedBindings = null,
budgetCredits = null,
enabled = true,
)
store.campaignBudgets[campaignId] = ReferralCampaignBudget(
campaignId,
Long.MAX_VALUE,
Long.MAX_VALUE,
now,
)
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
store.inTransaction {
it.referrals.insertBindingIfAbsent(
ReferralBinding(
id = UUID.randomUUID(),
inviterUserId = inviter,
inviteeUserId = invitee,
codeId = UUID.randomUUID(),
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = campaignId,
),
)
}
service.grantSignupTrial(invitee, 100, "exhausted-signup-key")
val reservation = service.reserve(
invitee,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "exhausted-reserve-key",
)
service.settle(
invitee,
reservation.id,
UsageMeasurement.Asr(500),
"exhausted-settle-key",
)
store.bindings.getValue(invitee).rewardStatus shouldBe
ReferralRewardStatus.INELIGIBLE_BUDGET
store.ledger.none {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
} shouldBe true
}
test("release and refund restore only the corresponding debit") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-004")
val released = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = false,
idempotencyKey = "reserve-key-004",
)
service.release(userId, released.id, "release-key-004")
service.release(userId, released.id, "release-key-004")
val settled = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = false,
idempotencyKey = "reserve-key-005",
)
service.settle(userId, settled.id, UsageMeasurement.Asr(500), "settle-key-005")
service.refund(userId, settled.id, "refund-key-005")
service.refund(userId, settled.id, "refund-key-005")
store.balance(userId) shouldBeExactly 100
store.ledger.filter { it.type == LedgerEntryType.USAGE_RELEASE } shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.USAGE_REFUND } shouldHaveSize 1
}
test("settlement replay rejects different actual usage") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "signup-key-006")
val reservation = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = false,
idempotencyKey = "reserve-key-006",
)
service.settle(userId, reservation.id, UsageMeasurement.Asr(500), "settle-key-006")
shouldThrow<CreditConflict> {
service.settle(userId, reservation.id, UsageMeasurement.Asr(600), "settle-key-006")
}
}
test("public idempotency values cannot occupy the internal reward namespace") {
externalIdempotencyKey("internal:referral:known-binding:invitee") shouldNotBe
"internal:referral:known-binding:invitee"
}
test("reservation state rules allow only settle release and refund transitions") {
ReservationStateRules.canTransition(
ReservationStatus.RESERVED,
ReservationStatus.SETTLED,
) shouldBe true
ReservationStateRules.canTransition(
ReservationStatus.RESERVED,
ReservationStatus.RELEASED,
) shouldBe true
ReservationStateRules.canTransition(
ReservationStatus.SETTLED,
ReservationStatus.REFUNDED,
) shouldBe true
ReservationStatus.entries.forEach { target ->
ReservationStateRules.canTransition(
ReservationStatus.REFUNDED,
target,
) shouldBe false
}
ReservationStateRules.canTransition(
ReservationStatus.RELEASED,
ReservationStatus.SETTLED,
) shouldBe false
}
})
private fun service(store: TestBillingStore, now: Instant) = CreditService(
transactions = store,
referralRewards = ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
clock = Clock.fixed(now, ZoneOffset.UTC),
)
private fun storeWithRates(now: Instant) = TestBillingStore().also {
val asr = asrRate(now)
val llm = llmRate(now)
it.rates[asr.id] = asr
it.rates[llm.id] = llm
}
private fun asrRate(now: Instant) = CreditRateVersion(
id = UUID.nameUUIDFromBytes("asr-rate".toByteArray()),
kind = UsageKind.ASR,
provider = "asr-provider",
model = "asr-model",
effectiveFrom = now.minusSeconds(60),
effectiveUntil = null,
asrCreditsNumerator = 1,
asrMillisDenominator = 100,
inputCreditsNumerator = null,
inputTokensDenominator = null,
outputCreditsNumerator = null,
outputTokensDenominator = null,
)
private fun llmRate(now: Instant) = CreditRateVersion(
id = UUID.nameUUIDFromBytes("llm-rate".toByteArray()),
kind = UsageKind.LLM,
provider = "llm-provider",
model = "llm-model",
effectiveFrom = now.minusSeconds(60),
effectiveUntil = null,
asrCreditsNumerator = null,
asrMillisDenominator = null,
inputCreditsNumerator = 2,
inputTokensDenominator = 1_000,
outputCreditsNumerator = 3,
outputTokensDenominator = 1_000,
)
@@ -0,0 +1,244 @@
package com.osglab.account.features.credits
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.BillingUnitOfWork
import com.osglab.account.features.credits.repositories.CreditsRepository
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import java.time.Instant
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
private val lock = ReentrantLock()
private val accounts = mutableMapOf<UUID, CreditAccount>()
val ledger = mutableListOf<LedgerEntry>()
val usageRecords = mutableListOf<CreditUsageRecord>()
val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>()
val bindings = mutableMapOf<UUID, ReferralBinding>()
val campaigns = mutableMapOf(
DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaign(
id = DEFAULT_REFERRAL_CAMPAIGN_ID,
name = "Default",
startsAt = Instant.EPOCH,
endsAt = null,
bindingWindowSeconds = 7 * 24 * 60 * 60,
inviterRewardCredits = 30,
inviteeRewardCredits = 30,
maxRewardedBindings = null,
budgetCredits = null,
enabled = true,
),
)
val campaignBudgets = mutableMapOf(
DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaignBudget(
campaignId = DEFAULT_REFERRAL_CAMPAIGN_ID,
rewardedBindings = 0,
spentCredits = 0,
updatedAt = Instant.EPOCH,
),
)
override val credits: CreditsRepository = Credits()
override val referrals: ReferralsRepository = Referrals()
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
lock.withLock {
val accountSnapshot = accounts.toMap()
val ledgerSnapshot = ledger.toList()
val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap()
val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap()
try {
block(this)
} catch (failure: Throwable) {
accounts.replaceWith(accountSnapshot)
ledger.replaceWith(ledgerSnapshot)
usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot)
bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot)
throw failure
}
}
fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 }
private inner class Credits : CreditsRepository {
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
accounts.putIfAbsent(userId, CreditAccount(userId, 0, now))
}
override fun lockAccount(userId: UUID): CreditAccount =
accounts[userId] ?: throw CreditNotFound("Credit account does not exist")
override fun updateAccountBalance(
userId: UUID,
newBalance: Long,
now: Instant,
): CreditAccount = CreditAccount(userId, newBalance, now).also { accounts[userId] = it }
override fun findLedgerEntry(userId: UUID, idempotencyKey: String): LedgerEntry? =
ledger.singleOrNull {
it.userId == userId && it.idempotencyKey == idempotencyKey
}
override fun insertLedgerEntry(entry: LedgerEntry) {
check(findLedgerEntry(entry.userId, entry.idempotencyKey) == null)
ledger += entry
}
override fun listLedgerEntries(userId: UUID, limit: Int): List<LedgerEntry> =
ledger.filter { it.userId == userId }
.sortedWith(compareByDescending<LedgerEntry> { it.createdAt }.thenByDescending { it.id })
.take(limit)
override fun insertUsageRecord(record: CreditUsageRecord) {
check(usageRecords.none { it.reservationId == record.reservationId })
usageRecords += record
}
override fun findReservationByReserveKey(
userId: UUID,
idempotencyKey: String,
): CreditReservation? = reservations.values.singleOrNull {
it.userId == userId && it.reserveIdempotencyKey == idempotencyKey
}
override fun lockReservation(id: UUID): CreditReservation? = reservations[id]
override fun insertReservation(reservation: CreditReservation) {
check(reservations.putIfAbsent(reservation.id, reservation) == null)
}
override fun updateReservation(reservation: CreditReservation) {
check(reservations.containsKey(reservation.id))
reservations[reservation.id] = reservation
}
override fun findRateVersion(id: UUID): CreditRateVersion? = rates[id]
override fun findEffectiveRate(
kind: UsageKind,
provider: String,
model: String,
at: Instant,
): CreditRateVersion? = rates.values
.filter {
it.kind == kind &&
it.provider == provider &&
it.model == model &&
it.effectiveFrom <= at &&
(it.effectiveUntil == null || it.effectiveUntil > at)
}
.maxByOrNull(CreditRateVersion::effectiveFrom)
override fun listEffectiveRates(at: Instant): List<CreditRateVersion> =
rates.values.filter {
it.effectiveFrom <= at && (it.effectiveUntil == null || it.effectiveUntil > at)
}
}
private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values
.filter { it.ownerUserId == ownerUserId }
.filter { campaignId == null || it.campaignId == campaignId }
.maxByOrNull(ReferralCode::createdAt)
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
findCodeByOwner(ownerUserId, campaignId)
override fun findCode(code: String): ReferralCode? =
codes.values.singleOrNull { it.code == code }
override fun insertCodeIfAbsent(code: ReferralCode): Boolean {
if (findCodeByOwner(code.ownerUserId, code.campaignId) != null ||
findCode(code.code) != null
) {
return false
}
codes[code.id] = code
return true
}
override fun findCampaign(id: UUID): ReferralCampaign? = campaigns[id]
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
campaigns.values.filter { it.isActive(at) }.sortedByDescending { it.startsAt }
override fun lockCampaignBudget(campaignId: UUID): ReferralCampaignBudget =
campaignBudgets.getValue(campaignId)
override fun updateCampaignBudget(budget: ReferralCampaignBudget) {
campaignBudgets[budget.campaignId] = budget
}
override fun findBinding(inviteeUserId: UUID): ReferralBinding? = bindings[inviteeUserId]
override fun listBindingsByInviter(
inviterUserId: UUID,
limit: Int,
): List<ReferralBinding> =
bindings.values
.filter { it.inviterUserId == inviterUserId }
.sortedByDescending { it.boundAt }
.take(limit)
override fun lockBinding(inviteeUserId: UUID): ReferralBinding? = bindings[inviteeUserId]
override fun insertBindingIfAbsent(binding: ReferralBinding): Boolean {
if (bindings.containsKey(binding.inviteeUserId)) return false
bindings[binding.inviteeUserId] = binding
return true
}
override fun markRewarded(
bindingId: UUID,
settlementId: UUID,
rewardedAt: Instant,
) {
val entry = bindings.entries.single { it.value.id == bindingId }
entry.setValue(
entry.value.copy(
rewardedAt = rewardedAt,
rewardSettlementId = settlementId,
rewardStatus = ReferralRewardStatus.REWARDED,
),
)
}
override fun markRewardIneligible(bindingId: UUID) {
val entry = bindings.entries.single { it.value.id == bindingId }
entry.setValue(entry.value.copy(rewardStatus = ReferralRewardStatus.INELIGIBLE_BUDGET))
}
}
}
private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
clear()
putAll(snapshot)
}
private fun <T> MutableList<T>.replaceWith(snapshot: List<T>) {
clear()
addAll(snapshot)
}
@@ -0,0 +1,359 @@
package com.osglab.account.features.gateway.asr
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewayPrincipal
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.UsageMeter
import com.osglab.account.features.gateway.ports.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
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.volcengine.AsrTransportResult
import com.osglab.account.features.gateway.providers.volcengine.VolcengineStreamingClient
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
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.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
class AsrStreamingServiceTest : StringSpec({
"releases credits when the mock upstream fails" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, _, _ -> throw MockUpstreamFailure() },
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-1", request())
shouldThrow<MockUpstreamFailure> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2)),
DISCARD_OUTPUT,
)
}
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
fixture.credits.settled shouldBe emptyList()
} finally {
fixture.scope.cancel()
}
}
"releases credits when ASR completes without a result" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(700, "provider-1", hasResult = false)
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-2", request())
shouldThrow<RuntimeException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1)),
DISCARD_OUTPUT,
)
}
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
fixture.usage.manualReview shouldBe false
} finally {
fixture.scope.cancel()
}
}
"settles successful ASR by provider milliseconds" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(725, "provider-2", hasResult = true)
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-3", request())
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2, 3)),
DISCARD_OUTPUT,
)
fixture.credits.settled.shouldContainExactly(RESERVATION_ID to 725L)
fixture.credits.released shouldBe emptyList()
} finally {
fixture.scope.cancel()
}
}
"binds a streaming session to the grant that reserved it" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(500, "provider-grant")
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-grant", request())
shouldThrow<AsrSessionNotFoundException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL.copy(grantId = "other-grant"),
flowOf(byteArrayOf(1)),
DISCARD_OUTPUT,
)
}
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1)),
DISCARD_OUTPUT,
)
fixture.credits.settled.shouldContainExactly(RESERVATION_ID to 500L)
} finally {
fixture.scope.cancel()
}
}
"rejects oversized audio frames before forwarding them" {
var upstreamFrames = 0
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { upstreamFrames += 1 }
AsrTransportResult(500, "provider-3")
},
limits = AsrStreamingLimits(maxFrameBytes = 2),
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-4", request())
shouldThrow<IllegalArgumentException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2, 3)),
DISCARD_OUTPUT,
)
}
upstreamFrames shouldBe 0
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
} finally {
fixture.scope.cancel()
}
}
"does not allow a large frame to exceed declared PCM duration" {
var upstreamFrames = 0
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { upstreamFrames += 1 }
AsrTransportResult(1, "provider-duration")
},
)
try {
val session = fixture.service.createSession(
PRINCIPAL,
"request-asr-duration",
request().copy(estimatedDurationMillis = 1),
)
shouldThrow<IllegalArgumentException> {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(ByteArray(33)),
DISCARD_OUTPUT,
)
}
upstreamFrames shouldBe 0
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
} finally {
fixture.scope.cancel()
}
}
"reserves the full policy duration for compressed streaming audio" {
val fixture = fixture(
upstream = VolcengineStreamingClient { _, frames, _ ->
frames.collect { }
AsrTransportResult(500, "provider-compressed")
},
)
try {
fixture.service.createSession(
PRINCIPAL,
"request-asr-compressed",
request().copy(format = "mp3", codec = "raw"),
)
fixture.credits.lastEstimatedUnits shouldBe GatewayLimits.MAX_AUDIO_MILLIS
} finally {
fixture.scope.cancel()
}
}
"releases credits when a streaming ASR call is cancelled" {
val started = CompletableDeferred<Unit>()
val fixture = fixture(
upstream = VolcengineStreamingClient { _, _, _ ->
started.complete(Unit)
awaitCancellation()
},
)
try {
val session = fixture.service.createSession(PRINCIPAL, "request-asr-5", request())
coroutineScope {
val call = launch {
fixture.service.stream(
session.sessionId,
PRINCIPAL,
flowOf(byteArrayOf(1, 2, 3)),
DISCARD_OUTPUT,
)
}
started.await()
call.cancelAndJoin()
}
fixture.credits.released.shouldContainExactly(RESERVATION_ID)
fixture.credits.settled shouldBe emptyList()
} finally {
fixture.scope.cancel()
}
}
})
private data class Fixture(
val service: AsrStreamingService,
val credits: FakeAsrCredits,
val usage: FakeGatewayUsage,
val scope: CoroutineScope,
)
private fun fixture(
upstream: VolcengineStreamingClient,
limits: AsrStreamingLimits = AsrStreamingLimits(),
): Fixture {
val credits = FakeAsrCredits()
val usage = FakeGatewayUsage()
val gateway = GatewayService(
catalog = ProviderCatalog(listOf(DummyAsrProvider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = usage,
)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
return Fixture(
AsrStreamingService(gateway, upstream, scope, limits),
credits,
usage,
scope,
)
}
private fun request() = CreateAsrSessionRequest(estimatedDurationMillis = 1_000)
private class FakeAsrCredits : CreditMeterPort {
val settled = mutableListOf<Pair<String, Long>>()
val released = mutableListOf<String>()
var lastEstimatedUnits: Long? = null
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation {
lastEstimatedUnits = estimatedUnits
return CreditReservation(RESERVATION_ID, estimatedUnits)
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
settled += reservationId to actualUnits
}
override suspend fun settle(reservationId: String, usage: ProviderUsage) {
settle(reservationId, usage.units)
}
override suspend fun release(reservationId: String) {
released += reservationId
}
}
private class FakeGatewayUsage : GatewayUsagePort {
var manualReview = false
override suspend fun claim(metadata: ProviderRequestMetadata) = Unit
override suspend fun markStarted(accountId: String, requestId: String) = Unit
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) {
manualReview = true
}
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
}
private object DummyAsrProvider : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "test-asr",
capabilities = setOf(GatewayCapability.ASR),
streaming = true,
usageMeter = UsageMeter.AUDIO_MILLISECOND,
)
override fun accepts(request: ProviderRequest): Boolean =
request.capability == GatewayCapability.ASR
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage =
error("Streaming tests call the upstream transport directly")
}
private val PRINCIPAL = GatewayPrincipal(
userId = "user-1",
grantId = "grant-1",
scopes = setOf(GatewayCapability.ASR),
)
private val DISCARD_OUTPUT = ProviderOutput { }
private const val RESERVATION_ID = "reservation-asr"
private class MockUpstreamFailure : RuntimeException()
@@ -0,0 +1,45 @@
package com.osglab.account.features.gateway.models
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
class TextRequestPolicyTest : StringSpec({
"rejects blank and oversized input" {
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(TextGatewayRequest(input = " "))
}
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(input = "a".repeat(GatewayLimits.MAX_TEXT_INPUT_CHARS + 1)),
)
}
}
"rejects oversized context and output settings" {
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(
input = "hello",
context = "a".repeat(GatewayLimits.MAX_TEXT_CONTEXT_CHARS + 1),
),
)
}
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(
input = "hello",
maxOutputTokens = GatewayLimits.MAX_OUTPUT_TOKENS + 1,
),
)
}
}
"requires agent responses to be buffered for schema validation" {
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(
TextGatewayRequest(input = "plan this", stream = true),
GatewayCapability.AGENT,
)
}
}
})
@@ -0,0 +1,183 @@
package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.TextProviderRequest
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class DeepSeekClientTest : StringSpec({
"prefers provider token usage" {
val client = client(
"""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}""",
)
try {
val usage = KtorDeepSeekClient(client, CONFIG).complete(request(), DISCARD_OUTPUT)
usage.units shouldBe 13L
usage.inputUnits shouldBe 10L
usage.outputUnits shouldBe 3L
} finally {
client.close()
}
}
"fails closed when usage is absent" {
val client = client("""{"choices":[{"message":{"content":"ok"}}]}""")
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request(),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
"meters provider input and output tokens from a streamed response" {
val response = """
data: {"choices":[{"delta":{"content":"ok"}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":4,"total_tokens":15}}
data: [DONE]
""".trimIndent()
val client = client(response, ContentType.Text.EventStream)
val emitted = mutableListOf<ByteArray>()
try {
val usage = KtorDeepSeekClient(client, CONFIG).complete(
request().copy(stream = true),
ProviderOutput { bytes -> emitted += bytes },
)
usage.units shouldBe 15L
usage.inputUnits shouldBe 11L
usage.outputUnits shouldBe 4L
emitted.isNotEmpty() shouldBe true
} finally {
client.close()
}
}
"rejects a stream that closes without the DONE marker" {
val response = """
data: {"choices":[{"delta":{"content":"partial"}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}
""".trimIndent()
val client = client(response, ContentType.Text.EventStream)
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request().copy(stream = true),
DISCARD_OUTPUT,
)
}
} finally {
client.close()
}
}
"does not forward malformed provider SSE data" {
val client = client("data: not-json\n\n", ContentType.Text.EventStream)
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request().copy(stream = true),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
"rejects a successful response with the wrong content type before forwarding" {
val client = client("<html>upstream error</html>", ContentType.Text.Html)
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request(),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
"rejects an unstructured agent response before forwarding it" {
val client = client("""{"choices":[{"message":{"content":"not-json"}}]}""")
var emitted = false
try {
shouldThrow<DeepSeekProviderException> {
KtorDeepSeekClient(client, CONFIG).complete(
request(capability = GatewayCapability.AGENT),
ProviderOutput { emitted = true },
)
}
emitted shouldBe false
} finally {
client.close()
}
}
})
private fun client(
responseBody: String,
contentType: ContentType = ContentType.Application.Json,
) = HttpClient(
MockEngine {
respond(
content = responseBody,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, contentType.toString()),
)
},
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
}
private fun request(capability: GatewayCapability = GatewayCapability.AI) = TextProviderRequest(
requestId = "deepseek-request",
capability = capability,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private val CONFIG = DeepSeekConfig(
endpoint = "https://api.deepseek.com/v1",
apiKey = "test-key",
model = "configured-model",
)
private val DISCARD_OUTPUT = ProviderOutput { }
@@ -0,0 +1,144 @@
package com.osglab.account.features.gateway.providers.volcengine
import com.osglab.account.features.gateway.models.AsrGatewayOptions
import com.osglab.account.features.gateway.models.AudioDurationPolicy
import com.osglab.account.features.gateway.models.GatewayLimits
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.GZIPInputStream
class SaucV3ProtocolTest : StringSpec({
"encodes v3 full and final audio requests with the documented v1 binary header" {
val codec = SaucV3Codec()
val fullPayload = """{"audio":{"format":"pcm"}}""".encodeToByteArray()
val full = codec.fullClientRequest(fullPayload)
val finalAudio = codec.audioRequest(byteArrayOf(1, 2, 3), isLast = true)
(full[0].toInt() and 0xff) shouldBe 0x11
(full[1].toInt() and 0xff) shouldBe 0x10
(full[2].toInt() and 0xff) shouldBe 0x11
inflatePayload(full) shouldBe fullPayload
(finalAudio[0].toInt() and 0xff) shouldBe 0x11
(finalAudio[1].toInt() and 0xff) shouldBe 0x22
(finalAudio[2].toInt() and 0xff) shouldBe 0x01
inflatePayload(finalAudio) shouldBe byteArrayOf(1, 2, 3)
}
"accepts strictly increasing positive sequences and a negative final sequence" {
val codec = SaucV3Codec()
val validator = SaucSequenceValidator()
validator.accept(codec.decodeServerFrame(serverFrame(1, false, """{"result":{}}""")))
val final = codec.decodeServerFrame(
serverFrame(-2, true, """{"audio_info":{"duration":1234}}"""),
)
validator.accept(final)
extractFinalDuration(final) shouldBe 1_234L
}
"rejects a positive final sequence" {
val frame = SaucV3Codec().decodeServerFrame(
serverFrame(1, true, """{"audio_info":{"duration":100}}"""),
)
shouldThrow<SaucProtocolException> {
SaucSequenceValidator().accept(frame)
}
}
"rejects out of order server sequences" {
val codec = SaucV3Codec()
val validator = SaucSequenceValidator()
validator.accept(codec.decodeServerFrame(serverFrame(1, false, "{}")))
shouldThrow<SaucProtocolException> {
validator.accept(codec.decodeServerFrame(serverFrame(3, false, "{}")))
}
}
"only the final frame can provide billable duration" {
val nonFinal = SaucV3Codec().decodeServerFrame(
serverFrame(1, false, """{"audio_info":{"duration":1}}"""),
)
shouldThrow<VolcengineUsageException> {
extractFinalDuration(nonFinal)
}
}
"final duration is required and bounded" {
val codec = SaucV3Codec()
shouldThrow<VolcengineUsageException> {
extractFinalDuration(codec.decodeServerFrame(serverFrame(-1, true, "{}")))
}
shouldThrow<VolcengineUsageException> {
extractFinalDuration(
codec.decodeServerFrame(
serverFrame(
-1,
true,
"""{"audio_info":{"duration":${GatewayLimits.MAX_AUDIO_MILLIS + 1}}}""",
),
),
)
}
}
"requires actual recognized text before billing ASR output" {
hasRecognitionResult("""{"result":{"text":"hello"}}""".encodeToByteArray()) shouldBe true
hasRecognitionResult(
"""{"result":{"utterances":[{"text":"hello"}]}}""".encodeToByteArray(),
) shouldBe true
hasRecognitionResult("""{"result":{"text":"","utterances":[]}}""".encodeToByteArray()) shouldBe false
hasRecognitionResult("""{"result":{"definite":true}}""".encodeToByteArray()) shouldBe false
}
"PCM reservation is derived from bytes and rejects a forged short duration" {
val oneSecondPcmBytes = 16_000 * 2
val forged = AsrGatewayOptions(estimatedDurationMillis = 1)
shouldThrow<IllegalArgumentException> {
AudioDurationPolicy.reservationMillis(oneSecondPcmBytes, forged)
}
AudioDurationPolicy.reservationMillis(
oneSecondPcmBytes,
forged.copy(estimatedDurationMillis = 1_000),
) shouldBe 1_000L
}
"compressed audio reserves the full policy boundary" {
val options = AsrGatewayOptions(
format = "ogg",
codec = "opus",
estimatedDurationMillis = 20_000,
)
AudioDurationPolicy.reservationMillis(64_000, options) shouldBe
GatewayLimits.MAX_AUDIO_MILLIS
}
})
private fun serverFrame(sequence: Int, final: Boolean, payload: String): ByteArray {
val bytes = payload.encodeToByteArray()
return ByteBuffer.allocate(12 + bytes.size)
.order(ByteOrder.BIG_ENDIAN)
.put(0x11)
.put(if (final) 0x93.toByte() else 0x91.toByte())
.put(0x10)
.put(0)
.putInt(sequence)
.putInt(bytes.size)
.put(bytes)
.array()
}
private fun inflatePayload(frame: ByteArray): ByteArray {
val size = ByteBuffer.wrap(frame, 4, 4).order(ByteOrder.BIG_ENDIAN).int
return GZIPInputStream(ByteArrayInputStream(frame, 8, size)).use { it.readAllBytes() }
}
@@ -0,0 +1,158 @@
package com.osglab.account.features.gateway.routes
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
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.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
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.GatewayService
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
class GatewayRequestIdTest : StringSpec({
"requires X-Request-ID before invoking a billable provider" {
val provider = RequestIdProvider()
testApplication {
application { gatewayTestApplication(provider) }
val missing = client.post("/v1/gateway/llm/ai") {
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":8}""")
}
val invalid = client.post("/v1/gateway/llm/ai") {
header("X-Request-ID", "bad")
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":8}""")
}
missing.status shouldBe HttpStatusCode.BadRequest
invalid.status shouldBe HttpStatusCode.BadRequest
provider.calls shouldBe 0
}
}
"uses X-Request-ID as the account-scoped provider idempotency key" {
val provider = RequestIdProvider()
testApplication {
application { gatewayTestApplication(provider) }
val response = client.post("/v1/gateway/llm/ai") {
header("X-Request-ID", "request-route-123")
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":8}""")
}
response.status shouldBe HttpStatusCode.OK
provider.lastRequestId shouldBe "request-route-123"
}
}
})
private fun io.ktor.server.application.Application.gatewayTestApplication(provider: RequestIdProvider) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
val service = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = RequestIdCredits,
grants = { _, _ -> true },
usageRecords = RequestIdUsage,
)
routing {
configureGatewayRoutes(
service = service,
appIdentity = { null },
gatewayIdentity = { REQUEST_ID_PRINCIPAL },
)
}
}
private class RequestIdProvider : GatewayProvider {
var calls = 0
var lastRequestId: String? = null
override val descriptor = ProviderDescriptor(
id = "request-id-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
calls += 1
lastRequestId = request.requestId
output.emit("""{"result":"ok"}""".encodeToByteArray())
return ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 3,
inputUnits = 2,
outputUnits = 1,
)
}
}
private object RequestIdCredits : CreditReservationPort {
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
) = CreditReservation("00000000-0000-0000-0000-000000000002", estimatedUnits)
override suspend fun settle(reservationId: String, actualUnits: Long) = Unit
override suspend fun release(reservationId: String) = Unit
}
private object RequestIdUsage : GatewayUsagePort {
override suspend fun claim(metadata: ProviderRequestMetadata) = Unit
override suspend fun markStarted(accountId: String, requestId: String) = Unit
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
}
private val REQUEST_ID_PRINCIPAL = GatewayPrincipal(
userId = "00000000-0000-0000-0000-000000000001",
grantId = "00000000-0000-0000-0000-000000000003",
scopes = setOf(GatewayCapability.AI),
)
@@ -0,0 +1,228 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.models.CreateGatewayGrantRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult
import com.osglab.account.features.gateway.ports.NewGatewayGrant
import com.osglab.account.features.gateway.ports.StoredGatewayRefresh
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
class GatewayGrantServiceTest : StringSpec({
"issues a scope-limited token and stores only the refresh hash" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val tokens = service.create(
PRINCIPAL,
CreateGatewayGrantRequest(setOf(GatewayCapability.POLISH), 3_600),
"create-request-1",
)
repository.refreshes.values.single().tokenHash shouldBe repository.hash(tokens.refreshToken)
repository.refreshes.values.single().tokenHash.contains(tokens.refreshToken) shouldBe false
service.authenticate(tokens.accessToken)?.scopes shouldBe setOf(GatewayCapability.POLISH)
}
"rejects scopes not held by the issuing identity" {
val repository = FakeGrantRepository()
val service = grantService(repository)
shouldThrow<IllegalArgumentException> {
service.create(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
CreateGatewayGrantRequest(setOf(GatewayCapability.AI)),
"create-request-scope",
)
}
repository.grants.size shouldBe 0
}
"refresh rotation is idempotent for the same operation key and detects reuse" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val created = service.create(
PRINCIPAL,
CreateGatewayGrantRequest(setOf(GatewayCapability.AI)),
"create-request-2",
)
val rotated = service.refresh(created.refreshToken, "refresh-request-1")
val replay = service.refresh(created.refreshToken, "refresh-request-1")
replay.refreshToken shouldBe rotated.refreshToken
shouldThrow<GatewayRefreshTokenReuseException> {
service.refresh(created.refreshToken, "refresh-request-2")
}
service.authenticate(rotated.accessToken) shouldBe null
}
"grant creation replay returns the same refresh credential without a duplicate grant" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val request = CreateGatewayGrantRequest(setOf(GatewayCapability.ASR))
val first = service.create(PRINCIPAL, request, "create-request-4")
val replay = service.create(PRINCIPAL, request, "create-request-4")
replay.grantId shouldBe first.grantId
replay.refreshToken shouldBe first.refreshToken
repository.grants.size shouldBe 1
}
"revocation immediately invalidates an otherwise unexpired access token" {
val repository = FakeGrantRepository()
val service = grantService(repository)
val tokens = service.create(
PRINCIPAL,
CreateGatewayGrantRequest(setOf(GatewayCapability.AGENT)),
"create-request-3",
)
service.revoke(PRINCIPAL, tokens.grantId) shouldBe true
service.authenticate(tokens.accessToken) shouldBe null
}
})
private fun grantService(repository: GatewayGrantRepository) = GatewayGrantService(
repository = repository,
settings = GatewaySettings(
issuer = "osg-test",
audience = "osg-gateway-test",
accessTokenHmacSecret = ByteArray(32) { 1 },
refreshTokenHmacSecret = ByteArray(32) { 2 },
accessTokenLifetime = Duration.ofMinutes(5),
refreshTokenLifetime = Duration.ofDays(30),
maximumGrantLifetime = Duration.ofDays(90),
),
clock = Clock.fixed(NOW, ZoneOffset.UTC),
)
private class FakeGrantRepository : GatewayGrantRepository {
data class Refresh(
val tokenId: String,
val grantId: String,
val familyId: String,
val tokenHash: String,
val expiresAt: Instant,
var replacedById: String? = null,
var rotationKey: String? = null,
var revoked: Boolean = false,
)
val grants = mutableMapOf<String, GatewayGrant>()
val refreshes = mutableMapOf<String, Refresh>()
private val createKeys = mutableMapOf<Pair<String, String>, String>()
override suspend fun create(grant: NewGatewayGrant, now: Instant): StoredGatewayRefresh {
val existingId = createKeys[grant.accountId to grant.idempotencyKey]
if (existingId != null) {
val existing = grants.getValue(existingId)
val refresh = refreshes.values.single {
it.grantId == existingId && it.replacedById == null && !it.revoked
}
return refresh.stored(existing)
}
val storedGrant = GatewayGrant(grant.id, grant.accountId, grant.scopes, grant.expiresAt)
grants[grant.id] = storedGrant
createKeys[grant.accountId to grant.idempotencyKey] = grant.id
refreshes[grant.refreshTokenHash] = Refresh(
tokenId = grant.refreshTokenId,
grantId = grant.id,
familyId = grant.refreshFamilyId,
tokenHash = grant.refreshTokenHash,
expiresAt = grant.refreshExpiresAt,
)
return refreshes.getValue(grant.refreshTokenHash).stored(storedGrant)
}
override suspend fun rotateRefresh(
currentTokenHash: String,
rotationIdempotencyKey: String,
newTokenId: String,
newTokenHash: String,
newExpiresAt: Instant,
now: Instant,
): GatewayRefreshRotationResult {
val current = refreshes[currentTokenHash] ?: return GatewayRefreshRotationResult.Invalid
val grant = grants.getValue(current.grantId)
current.replacedById?.let { replacementId ->
if (current.rotationKey == rotationIdempotencyKey) {
val replacement = refreshes.values.single { it.tokenId == replacementId }
return GatewayRefreshRotationResult.Rotated(replacement.stored(grant))
}
refreshes.values.filter { it.familyId == current.familyId }.forEach { it.revoked = true }
grants[current.grantId] = grant.copy(revokedAt = now)
return GatewayRefreshRotationResult.ReuseDetected
}
if (current.revoked || !current.expiresAt.isAfter(now) || grant.revokedAt != null) {
return GatewayRefreshRotationResult.Invalid
}
val replacement = Refresh(
tokenId = newTokenId,
grantId = current.grantId,
familyId = current.familyId,
tokenHash = newTokenHash,
expiresAt = minOf(newExpiresAt, grant.expiresAt),
)
refreshes[newTokenHash] = replacement
current.replacedById = newTokenId
current.rotationKey = rotationIdempotencyKey
current.revoked = true
return GatewayRefreshRotationResult.Rotated(replacement.stored(grant))
}
override suspend fun revoke(accountId: String, grantId: String, now: Instant): Boolean {
val grant = grants[grantId]?.takeIf { it.accountId == accountId && it.revokedAt == null }
?: return false
grants[grantId] = grant.copy(revokedAt = now)
refreshes.values.filter { it.grantId == grantId }.forEach { it.revoked = true }
return true
}
override suspend fun findActive(
grantId: String,
accountId: String,
scopes: Set<GatewayCapability>,
now: Instant,
): GatewayGrant? = grants[grantId]?.takeIf {
it.accountId == accountId &&
it.scopes == scopes &&
it.revokedAt == null &&
it.expiresAt.isAfter(now)
}
override suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean =
grants.values.any {
it.accountId == accountId && capability in it.scopes && it.revokedAt == null
}
fun hash(token: String): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(token.encodeToByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
private fun Refresh.stored(grant: GatewayGrant) = StoredGatewayRefresh(
grant = grant,
tokenId = tokenId,
familyId = familyId,
expiresAt = expiresAt,
)
}
private val PRINCIPAL = GatewayPrincipal(
userId = "00000000-0000-0000-0000-000000000001",
scopes = GatewayCapability.entries.toSet(),
)
private val NOW = Instant.parse("2026-08-16T00:00:00Z")
@@ -0,0 +1,231 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
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.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
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.ports.ProviderRequestState
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.delay
class GatewayReplayStateTest : StringSpec({
"an account-scoped replay never calls upstream twice" {
val credits = ReplayCredits()
val records = ReplayRecords()
val provider = ReplayProvider()
val service = replayService(credits, records, provider)
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
shouldThrow<GatewayRequestAlreadyClaimedException> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
provider.calls shouldBe 1
}
"the same request ID is independent across accounts" {
val records = ReplayRecords()
val provider = ReplayProvider()
val service = replayService(ReplayCredits(), records, provider)
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
service.execute(PRINCIPAL_B, replayRequest(), DISCARD)
provider.calls shouldBe 2
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.SETTLED
records.state(PRINCIPAL_B.userId) shouldBe ProviderRequestState.SETTLED
}
"whole-call timeout releases only the incomplete provider call" {
val credits = ReplayCredits()
val records = ReplayRecords()
val service = replayService(
credits,
records,
ReplayProvider(delayMillis = 100),
timeoutMillis = 10,
)
shouldThrow<kotlinx.coroutines.TimeoutCancellationException> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
credits.releases shouldBe 1
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.RELEASED
}
"completed inconsistent provider usage releases the reservation" {
val credits = ReplayCredits()
val records = ReplayRecords()
val service = replayService(
credits,
records,
ReplayProvider(
usage = VALID_USAGE.copy(units = VALID_USAGE.units + 1),
),
)
shouldThrow<GatewayUsagePolicyException> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
credits.releases shouldBe 1
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.RELEASED
}
"marks a claimed request for review when release also fails" {
val credits = ReplayCredits(failRelease = true)
val records = ReplayRecords(failStart = true)
val service = replayService(credits, records, ReplayProvider())
shouldThrow<StartRecordingFailure> {
service.execute(PRINCIPAL_A, replayRequest(), DISCARD)
}
records.state(PRINCIPAL_A.userId) shouldBe ProviderRequestState.MANUAL_REVIEW
}
})
private fun replayService(
credits: CreditMeterPort,
records: GatewayUsagePort,
provider: GatewayProvider,
timeoutMillis: Long = 1_000,
) = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = records,
llmProviderTimeoutMillis = timeoutMillis,
asrProviderTimeoutMillis = timeoutMillis,
)
private fun replayRequest() = TextProviderRequest(
requestId = REPLAY_ID,
capability = GatewayCapability.AI,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private class ReplayCredits(
private val failRelease: Boolean = false,
) : CreditMeterPort {
private val reservations = mutableMapOf<Pair<String, String>, CreditReservation>()
var releases = 0
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation = reservations.getOrPut(accountId to requestId) {
CreditReservation("reservation-$accountId-$requestId", estimatedUnits)
}
override suspend fun settle(reservationId: String, actualUnits: Long) = Unit
override suspend fun release(reservationId: String) {
releases += 1
if (failRelease) throw ReleaseFailure()
}
}
private class ReplayProvider(
private val delayMillis: Long = 0,
private val usage: ProviderUsage = VALID_USAGE,
) : GatewayProvider {
var calls = 0
override val descriptor = ProviderDescriptor(
id = "replay-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
calls += 1
if (delayMillis > 0) delay(delayMillis)
return usage
}
}
private class ReplayRecords(
private val failStart: Boolean = false,
) : GatewayUsagePort {
private data class Record(
val metadata: ProviderRequestMetadata,
var state: ProviderRequestState,
var usage: ProviderUsage? = null,
)
private val values = mutableMapOf<Pair<String, String>, Record>()
override suspend fun claim(metadata: ProviderRequestMetadata) {
val key = metadata.accountId to metadata.requestId
values[key]?.let { throw GatewayRequestAlreadyClaimedException(it.state) }
values[key] = Record(metadata, ProviderRequestState.CLAIMED)
}
override suspend fun markStarted(accountId: String, requestId: String) {
if (failStart) throw StartRecordingFailure()
values.getValue(accountId to requestId).state = ProviderRequestState.STARTED
}
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
values.getValue(accountId to requestId).apply {
state = ProviderRequestState.SETTLEMENT_PENDING
this.usage = usage
}
}
override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) {
values.getValue(accountId to requestId).state = ProviderRequestState.SETTLED
}
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) {
values.getValue(accountId to requestId).state = ProviderRequestState.RELEASED
}
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) {
values.getValue(accountId to requestId).state = ProviderRequestState.MANUAL_REVIEW
}
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> = emptyList()
fun state(accountId: String): ProviderRequestState =
values.getValue(accountId to REPLAY_ID).state
}
private val PRINCIPAL_A = GatewayPrincipal("account-a", scopes = setOf(GatewayCapability.AI))
private val PRINCIPAL_B = GatewayPrincipal("account-b", scopes = setOf(GatewayCapability.AI))
private val DISCARD = ProviderOutput { }
private val VALID_USAGE = ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 7,
inputUnits = 5,
outputUnits = 2,
)
private const val REPLAY_ID = "request-replay"
private class StartRecordingFailure : RuntimeException()
private class ReleaseFailure : RuntimeException()
@@ -0,0 +1,289 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
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.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
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.ports.ProviderUsageEstimate
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
class GatewayServiceBillingTest : StringSpec({
"releases a reservation when the mock upstream fails" {
val credits = FakeCredits()
val service = service(credits, FakeProvider(fail = true))
shouldThrow<ProviderFailure> {
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
}
credits.settled shouldBe emptyList()
credits.released.shouldContainExactly(RESERVATION_ID)
}
"releases a reservation when the provider reports an empty result" {
val credits = FakeCredits()
val service = service(credits, EmptyResultProvider())
shouldThrow<EmptyResultFailure> {
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
}
credits.released.shouldContainExactly(RESERVATION_ID)
}
"settles successful token usage and does not release" {
val credits = FakeCredits()
val service = service(credits, FakeProvider())
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
credits.released shouldBe emptyList()
credits.lastEstimate?.meter shouldBe UsageMeter.LLM_TOKEN
credits.lastEstimate?.inputUnits shouldBe 261L
credits.lastEstimate?.outputUnits shouldBe 32L
}
"releases a reservation when a provider call is cancelled" {
val credits = FakeCredits()
val started = CompletableDeferred<Unit>()
val provider = object : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "cancellable-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(
request: ProviderRequest,
output: ProviderOutput,
): ProviderUsage {
started.complete(Unit)
awaitCancellation()
}
}
val service = service(credits, provider)
coroutineScope {
val call = launch { service.execute(PRINCIPAL, request(), DISCARD_OUTPUT) }
started.await()
call.cancelAndJoin()
}
credits.released.shouldContainExactly(RESERVATION_ID)
credits.settled shouldBe emptyList()
}
"keeps the reservation frozen when settlement fails after upstream success" {
val credits = FakeCredits(failSettle = true)
val service = service(credits, FakeProvider())
service.execute(PRINCIPAL, request(), DISCARD_OUTPUT)
credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
credits.released shouldBe emptyList()
}
"repeated settlement delegates idempotency to the credit port" {
val credits = FakeCredits(idempotent = true)
val usageRecords = FakeUsageRecords(
pending = mutableListOf(
PendingSettlement(
requestId = "request-123",
accountId = PRINCIPAL.userId,
reservationId = RESERVATION_ID,
usage = TOKEN_USAGE,
),
),
)
val reconciliation = GatewayReconciliationService(credits, usageRecords)
reconciliation.reconcile()
reconciliation.reconcile()
credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
}
"delegates an explicit settled-call reversal to billing refund" {
val credits = FakeCredits()
GatewayRefundService(credits).refund(RESERVATION_ID)
credits.refunded.shouldContainExactly(RESERVATION_ID)
}
"rejects a capability outside the gateway token scope before billing" {
val credits = FakeCredits()
val service = service(credits, FakeProvider())
shouldThrow<GatewayAccessDeniedException> {
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
request(),
DISCARD_OUTPUT,
)
}
credits.reserveCalls shouldBe 0
}
})
private fun service(
credits: CreditMeterPort,
provider: GatewayProvider,
usageRecords: GatewayUsagePort = FakeUsageRecords(),
): GatewayService = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = usageRecords,
)
private fun request() = TextProviderRequest(
requestId = "request-123",
capability = GatewayCapability.AI,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private class FakeCredits(
private val failSettle: Boolean = false,
private val idempotent: Boolean = false,
) : CreditMeterPort {
val settled = mutableListOf<Pair<String, Long>>()
val released = mutableListOf<String>()
val refunded = mutableListOf<String>()
var reserveCalls = 0
var lastEstimate: ProviderUsageEstimate? = null
override suspend fun reserve(
accountId: String,
meter: UsageMeter,
estimatedUnits: Long,
requestId: String,
): CreditReservation {
reserveCalls += 1
return CreditReservation(RESERVATION_ID, estimatedUnits)
}
override suspend fun reserve(
accountId: String,
estimate: ProviderUsageEstimate,
requestId: String,
): CreditReservation {
lastEstimate = estimate
return reserve(accountId, estimate.meter, estimate.units, requestId)
}
override suspend fun settle(reservationId: String, actualUnits: Long) {
val settlement = reservationId to actualUnits
if (!idempotent || settlement !in settled) settled += settlement
if (failSettle) throw BillingFailure()
}
override suspend fun settle(reservationId: String, usage: ProviderUsage) {
settle(reservationId, usage.units)
}
override suspend fun release(reservationId: String) {
if (!idempotent || reservationId !in released) released += reservationId
}
override suspend fun refund(reservationId: String) {
if (!idempotent || reservationId !in refunded) refunded += reservationId
}
}
private class FakeProvider(
private val fail: Boolean = false,
) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "mock-deepseek",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
if (fail) throw ProviderFailure()
return TOKEN_USAGE
}
}
private class EmptyResultProvider : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "empty-provider",
capabilities = setOf(GatewayCapability.AI),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
throw EmptyResultFailure()
}
}
private class FakeUsageRecords(
private val pending: MutableList<PendingSettlement> = mutableListOf(),
) : GatewayUsagePort {
override suspend fun claim(metadata: ProviderRequestMetadata) = Unit
override suspend fun markStarted(accountId: String, requestId: String) = Unit
override suspend fun markSettlementPending(
accountId: String,
requestId: String,
usage: ProviderUsage,
) = Unit
override suspend fun markSucceeded(
accountId: String,
requestId: String,
usage: ProviderUsage,
) {
pending.removeAll { it.accountId == accountId && it.requestId == requestId }
}
override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = Unit
override suspend fun findSettlementPending(limit: Int): List<PendingSettlement> =
pending.take(limit)
}
private val TOKEN_USAGE = ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
units = 21,
inputUnits = 13,
outputUnits = 8,
)
private val PRINCIPAL = GatewayPrincipal(
userId = "account-1",
scopes = setOf(GatewayCapability.AI),
)
private val DISCARD_OUTPUT = ProviderOutput { }
private const val RESERVATION_ID = "reservation-1"
private class BillingFailure : RuntimeException()
private class ProviderFailure : RuntimeException()
private class EmptyResultFailure : RuntimeException()
@@ -0,0 +1,196 @@
package com.osglab.account.features.integrity
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import com.upokecenter.cbor.CBORObject
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.math.BigInteger
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.Signature
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.util.Base64
class AppAttestCryptoTest : FunSpec({
test("attestation validates nonce RP ID AAGUID credential and counter") {
val fixture = AppAttestFixture()
val crypto = fixture.crypto()
val material = crypto.validateAttestation(
fixture.attestationObject(),
fixture.keyId,
fixture.challenge,
)
material.publicKey shouldBe fixture.keyPair.public.encoded
material.initialCounter shouldBe 0L
}
test("attestation rejects a nonce mismatch") {
val fixture = AppAttestFixture()
val crypto = fixture.crypto(nonce = ByteArray(32) { 9 })
shouldThrow<AppAttestRejectedException> {
crypto.validateAttestation(
fixture.attestationObject(),
fixture.keyId,
fixture.challenge,
)
}
}
test("attestation rejects an RP ID mismatch") {
val fixture = AppAttestFixture()
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAttestation(
fixture.attestationObject(rpIdHash = ByteArray(32)),
fixture.keyId,
fixture.challenge,
)
}
}
test("attestation rejects an AAGUID environment mismatch") {
val fixture = AppAttestFixture()
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAttestation(
fixture.attestationObject(
aaguid = "appattestdevelop".toByteArray(Charsets.US_ASCII),
),
fixture.keyId,
fixture.challenge,
)
}
}
test("assertion verifies ECDSA and requires a strictly increasing counter") {
val fixture = AppAttestFixture()
val hash = sha256ForTest("cost-request".toByteArray())
val assertion = fixture.assertionObject(counter = 4, clientDataHash = hash)
fixture.crypto().validateAssertion(
assertionObject = assertion,
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 3,
) shouldBe 4L
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAssertion(
assertionObject = assertion,
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 4,
)
}
}
})
private class AppAttestFixture {
val keyPair: KeyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val challenge: ByteArray = ByteArray(32) { it.toByte() }
val rpIdHash: ByteArray = sha256ForTest("X329MZU23S.com.osgkeyboard.ios".toByteArray())
val keyId: String = Base64.getEncoder().encodeToString(
sha256ForTest(uncompressedPointForTest(keyPair.public as ECPublicKey)),
)
fun crypto(nonce: ByteArray = expectedNonce()): LibraryAppAttestCrypto =
LibraryAppAttestCrypto(
IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.ENFORCE,
appAttestPolicy = IntegrityPolicy.ENFORCE,
appleEnvironment = AppleServiceEnvironment.PRODUCTION,
),
AppAttestCertificateValidator {
ValidatedAppAttestCertificate(keyPair.public as ECPublicKey, nonce)
},
)
fun attestationObject(
rpIdHash: ByteArray = this.rpIdHash,
aaguid: ByteArray = "appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7),
): ByteArray {
val authData = attestationAuthData(rpIdHash, aaguid)
return CBORObject.NewMap()
.Add("fmt", "apple-appattest")
.Add(
"attStmt",
CBORObject.NewMap()
.Add("x5c", CBORObject.NewArray().Add(byteArrayOf(1)))
.Add("receipt", byteArrayOf(2)),
)
.Add("authData", authData)
.EncodeToBytes()
}
fun assertionObject(counter: Int, clientDataHash: ByteArray): ByteArray {
val authData = ByteBuffer.allocate(37).order(ByteOrder.BIG_ENDIAN)
.put(rpIdHash)
.put(0)
.putInt(counter)
.array()
val signature = Signature.getInstance("SHA256withECDSA").run {
initSign(keyPair.private)
update(authData + clientDataHash)
sign()
}
return CBORObject.NewMap()
.Add("authenticatorData", authData)
.Add("signature", signature)
.EncodeToBytes()
}
private fun expectedNonce(): ByteArray =
sha256ForTest(attestationAuthData(rpIdHash, productionAaguid()) + sha256ForTest(challenge))
private fun productionAaguid(): ByteArray =
"appattest".toByteArray(Charsets.US_ASCII) + ByteArray(7)
private fun attestationAuthData(rpHash: ByteArray, aaguid: ByteArray): ByteArray {
val publicKey = keyPair.public as ECPublicKey
val credentialId = Base64.getDecoder().decode(keyId)
val cose = CBORObject.NewMap()
.Add(1, 2)
.Add(3, -7)
.Add(-1, 1)
.Add(-2, publicKey.w.affineX.toFixedForTest(32))
.Add(-3, publicKey.w.affineY.toFixedForTest(32))
.EncodeToBytes()
return ByteBuffer.allocate(32 + 1 + 4 + 16 + 2 + credentialId.size + cose.size)
.order(ByteOrder.BIG_ENDIAN)
.put(rpHash)
.put(0x40)
.putInt(0)
.put(aaguid)
.putShort(credentialId.size.toShort())
.put(credentialId)
.put(cose)
.array()
}
}
private fun uncompressedPointForTest(key: ECPublicKey): ByteArray =
byteArrayOf(0x04) +
key.w.affineX.toFixedForTest(32) +
key.w.affineY.toFixedForTest(32)
private fun BigInteger.toFixedForTest(size: Int): ByteArray {
val bytes = toByteArray().let {
if (it.size == size + 1 && it.first() == 0.toByte()) it.copyOfRange(1, it.size) else it
}
return ByteArray(size - bytes.size) + bytes
}
private fun sha256ForTest(value: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(value)
@@ -0,0 +1,229 @@
package com.osglab.account.features.integrity
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeTypeOf
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Base64
import java.util.UUID
class AppAttestServiceTest : FunSpec({
val keyId = Base64.getEncoder().encodeToString(ByteArray(32) { 7 })
val payload = AppleSignInIntegrityPayload("identity", "authorization", "nonce")
val assertion = Base64.getEncoder().encodeToString(byteArrayOf(1))
test("expired challenge is rejected before assertion validation") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 0)
val crypto = FakeAppAttestCrypto(nextCounter = 1)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
clock.now = challenge.expiresAt.plusSeconds(1)
service.verify(
AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
),
payload,
).shouldBeTypeOf<IntegrityVerification.Rejected>()
crypto.assertionCalls shouldBe 0
}
test("challenge cannot be replayed") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 0)
val crypto = FakeAppAttestCrypto(nextCounter = 1)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
val evidence = AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
)
service.verify(evidence, payload) shouldBe IntegrityVerification.Verified
service.verify(evidence, payload).shouldBeTypeOf<IntegrityVerification.Rejected>()
crypto.assertionCalls shouldBe 1
}
test("counter rollback is rejected even if a crypto adapter returns it") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 8)
val crypto = FakeAppAttestCrypto(nextCounter = 8)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
service.verify(
AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
),
payload,
).shouldBeTypeOf<IntegrityVerification.Rejected>()
repository.keys.getValue(keyId).counter shouldBe 8
}
test("server rebuilds canonical client data from the challenge and login fields") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val repository = InMemoryAppAttestRepository(keyId, counter = 2)
val crypto = FakeAppAttestCrypto(nextCounter = 3)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
service.verify(
AppAttestEvidence(
keyId,
challenge.id.toString(),
assertion,
Base64.getUrlEncoder().withoutPadding().encodeToString(challenge.value),
),
payload,
) shouldBe IntegrityVerification.Verified
crypto.clientDataHash shouldBe java.security.MessageDigest.getInstance("SHA-256")
.digest(AppAttestCanonicalPayload.appleSignIn(challenge.value, payload))
}
test("bound assertion rejects a key owned by another account") {
val clock = MutableClock(Instant.parse("2026-08-16T00:00:00Z"))
val ownerId = UUID.randomUUID()
val requesterId = UUID.randomUUID()
val repository = InMemoryAppAttestRepository(keyId, counter = 0, accountId = ownerId)
val crypto = FakeAppAttestCrypto(nextCounter = 1)
val service = appAttestService(repository, crypto, clock)
val challenge = service.issueChallenge(AppAttestChallengePurpose.ASSERTION, keyId)
shouldThrow<AppAttestRejectedException> {
service.verifyBoundAssertion(
challengeId = challenge.id.toString(),
challenge = challenge.value,
keyId = keyId,
assertionObject = assertion,
expectedClientDataHash = ByteArray(32),
expectedAccountId = requesterId,
)
}
crypto.assertionCalls shouldBe 0
}
})
private fun appAttestService(
repository: AppAttestRepository,
crypto: AppAttestCrypto,
clock: Clock,
) = AppAttestService(
repository = repository,
crypto = crypto,
config = IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.MONITOR,
appAttestPolicy = IntegrityPolicy.ENFORCE,
challengeLifetimeSeconds = 300,
),
clock = clock,
)
private class FakeAppAttestCrypto(
private val nextCounter: Long,
) : AppAttestCrypto {
var assertionCalls = 0
var clientDataHash: ByteArray? = null
override suspend fun validateAttestation(
attestationObject: ByteArray,
keyId: String,
challenge: ByteArray,
) = AttestedKeyMaterial(byteArrayOf(1), byteArrayOf(2), 0)
override suspend fun validateAssertion(
assertionObject: ByteArray,
clientDataHash: ByteArray,
publicKey: ByteArray,
lastCounter: Long,
): Long {
assertionCalls++
this.clientDataHash = clientDataHash
return nextCounter
}
}
private class InMemoryAppAttestRepository(
keyId: String,
counter: Long,
accountId: UUID? = null,
) : AppAttestRepository {
private val challenges = mutableMapOf<UUID, AppAttestChallenge>()
val keys = mutableMapOf(
keyId to StoredAppAttestKey(keyId, byteArrayOf(1), byteArrayOf(2), counter, accountId),
)
override suspend fun createChallenge(challenge: AppAttestChallenge) {
challenges[challenge.id] = challenge
}
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
): ConsumedChallenge {
val challenge = challenges[id] ?: return ConsumedChallenge.MissingOrMismatched
if (challenge.purpose != purpose || challenge.keyId != keyId) {
return ConsumedChallenge.MissingOrMismatched
}
if (challenge.challengeHash != challengeHash) {
return ConsumedChallenge.MissingOrMismatched
}
if (challenge.status == AppAttestChallengeStatus.CONSUMED) return ConsumedChallenge.Replayed
if (!challenge.expiresAt.isAfter(now)) return ConsumedChallenge.Expired
challenges[id] = challenge.copy(
status = AppAttestChallengeStatus.CONSUMED,
consumedAt = now,
)
return ConsumedChallenge.Valid
}
override suspend fun saveKey(key: StoredAppAttestKey): Boolean =
keys.putIfAbsent(key.keyId, key) == null
override suspend fun findKey(keyId: String): StoredAppAttestKey? = keys[keyId]
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
): Boolean {
val key = keys[keyId] ?: return false
if (key.counter != expectedCounter || newCounter <= expectedCounter) return false
keys[keyId] = key.copy(counter = newCounter)
return true
}
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant): Boolean {
val key = keys[keyId] ?: return false
if (key.accountId != null && key.accountId != accountId) return false
keys[keyId] = key.copy(accountId = accountId)
return true
}
}
private class MutableClock(
var now: Instant,
) : Clock() {
override fun instant(): Instant = now
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
}
@@ -0,0 +1,14 @@
package com.osglab.account.features.integrity
import io.kotest.matchers.shouldBe
import kotlin.test.Test
class BundledAppleAppAttestTrustTest {
@Test
fun `bundled root is the official Apple App Attestation CA`() {
val certificate = BundledAppleAppAttestTrust.loadRootCertificate()
certificate.subjectX500Principal.name.contains("Apple App Attestation Root CA") shouldBe true
(certificate.basicConstraints >= 0) shouldBe true
}
}
@@ -0,0 +1,350 @@
package com.osglab.account.features.integrity
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.ECDSAVerifier
import com.nimbusds.jwt.SignedJWT
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.config.AppleServiceEnvironment
import com.osglab.account.config.IntegrityPolicy
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.HttpTimeout
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.Base64
import java.util.UUID
class DeviceCheckTest : FunSpec({
test("ES256 JWT contains Apple team and key identifiers") {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val pem = """
-----BEGIN PRIVATE KEY-----
${Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded)}
-----END PRIVATE KEY-----
""".trimIndent()
val now = Instant.parse("2026-08-16T00:00:00Z")
val encoded = DeviceCheckJwtGenerator(
teamId = "X329MZU23S",
keyId = "APPLEKEY1",
privateKeyPem = pem,
clock = Clock.fixed(now, ZoneOffset.UTC),
).create()
val jwt = SignedJWT.parse(encoded)
jwt.header.algorithm shouldBe JWSAlgorithm.ES256
jwt.header.keyID shouldBe "APPLEKEY1"
jwt.jwtClaimsSet.issuer shouldBe "X329MZU23S"
jwt.jwtClaimsSet.issueTime.toInstant() shouldBe now
jwt.jwtClaimsSet.expirationTime.toInstant() shouldBe now.plusSeconds(55 * 60)
jwt.verify(ECDSAVerifier(keyPair.public as ECPublicKey)).shouldBeTrue()
}
test("an Apple bit0 claim never grants signup credits") {
val repository = InMemoryTrialRepository()
var updates = 0
var grants = 0
val service = DeviceCheckTrialService(
repository = repository,
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String) =
DeviceCheckQuery.Found(DeviceCheckState(true, false, "2026-08"))
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
updates++
}
},
creditGranter = TrialCreditGranter { grants++ },
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32))).shouldBeFalse()
updates shouldBe 0
grants shouldBe 0
repository.claims.values.single().status shouldBe TrialClaimStatus.REJECTED
}
test("Apple is marked before the idempotent credit boundary") {
val events = mutableListOf<String>()
val repository = InMemoryTrialRepository(events)
var queryCount = 0
val service = DeviceCheckTrialService(
repository = repository,
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
if (queryCount++ == 0) {
DeviceCheckQuery.NotFound
} else {
DeviceCheckQuery.Found(DeviceCheckState(true, false, "2026-08"))
}
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
bit0.shouldBeTrue()
events += "apple"
}
},
creditGranter = TrialCreditGranter { events += "credits" },
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32) { 1 }))
.shouldBeTrue()
(events.indexOf("apple") < events.indexOf("credits")) shouldBe true
events shouldBe listOf("apple", "APPLE_MARKED", "credits", "COMPLETED")
}
test("monitor skips a trial while enforce fails closed on Apple outage") {
val unavailable = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
throw DeviceCheckUnavailableException("network")
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit =
error("not reached")
}
val token = Base64.getEncoder().encodeToString(ByteArray(32) { 2 })
val accountId = UUID.randomUUID()
DeviceCheckTrialService(
InMemoryTrialRepository(),
unavailable,
TrialCreditGranter { error("must not grant") },
IntegrityPolicy.MONITOR,
).claimAndGrant(accountId, token).shouldBeFalse()
shouldThrow<ExternalServiceUnavailableException> {
DeviceCheckTrialService(
InMemoryTrialRepository(),
unavailable,
TrialCreditGranter { error("must not grant") },
IntegrityPolicy.ENFORCE,
).claimAndGrant(accountId, token)
}
}
test("different ephemeral tokens for one device are serialized across the claim window") {
val repository = InMemoryTrialRepository()
val lock = Mutex()
var appleBit = false
var grants = 0
val service = DeviceCheckTrialService(
repository = repository,
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
DeviceCheckQuery.Found(DeviceCheckState(appleBit, false, null))
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
appleBit = bit0
}
},
creditGranter = TrialCreditGranter { grants++ },
policy = IntegrityPolicy.ENFORCE,
mutex = object : DeviceCheckTrialMutex {
override suspend fun <T> withLock(block: suspend () -> T): T =
lock.withLock { block() }
},
)
val results = coroutineScope {
listOf(3, 4).map { marker ->
async {
service.claimAndGrant(
UUID.randomUUID(),
Base64.getEncoder().encodeToString(ByteArray(32) { marker.toByte() }),
)
}
}.awaitAll()
}
results.count { it } shouldBe 1
grants shouldBe 1
}
test("an unconfirmed Apple mark never grants credits") {
var grants = 0
val service = DeviceCheckTrialService(
repository = InMemoryTrialRepository(),
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String) = DeviceCheckQuery.NotFound
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) = Unit
},
creditGranter = TrialCreditGranter { grants++ },
policy = IntegrityPolicy.ENFORCE,
)
shouldThrow<ExternalServiceUnavailableException> {
service.claimAndGrant(
UUID.randomUUID(),
Base64.getEncoder().encodeToString(ByteArray(32) { 5 }),
)
}
grants shouldBe 0
}
test("DeviceCheck maps rejected and temporary Apple errors differently") {
suspend fun queryFor(status: HttpStatusCode): Throwable {
val client = HttpClient(
MockEngine {
respond(
content = """{"error":"redacted"}""",
status = status,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
) {
install(HttpTimeout)
}
return try {
runCatching {
KtorAppleDeviceCheckClient(
client,
testJwtGenerator(),
AppleServiceEnvironment.PRODUCTION,
).query(Base64.getEncoder().encodeToString(ByteArray(32)))
}.exceptionOrNull()!!
} finally {
client.close()
}
}
queryFor(HttpStatusCode.BadRequest)::class shouldBe DeviceCheckRejectedException::class
queryFor(HttpStatusCode.Unauthorized)::class shouldBe DeviceCheckUnavailableException::class
queryFor(HttpStatusCode.TooManyRequests)::class shouldBe DeviceCheckUnavailableException::class
queryFor(HttpStatusCode.ServiceUnavailable)::class shouldBe DeviceCheckUnavailableException::class
}
test("DeviceCheck uses environment-specific Apple hosts") {
suspend fun hostFor(environment: AppleServiceEnvironment): String {
var host = ""
val client = HttpClient(
MockEngine { request ->
host = request.url.host
respond(
content = """{"bit0":false,"bit1":false}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
) {
install(HttpTimeout)
}
try {
KtorAppleDeviceCheckClient(client, testJwtGenerator(), environment)
.query(Base64.getEncoder().encodeToString(ByteArray(32)))
} finally {
client.close()
}
return host
}
hostFor(AppleServiceEnvironment.DEVELOPMENT) shouldBe
"api.development.devicecheck.apple.com"
hostFor(AppleServiceEnvironment.PRODUCTION) shouldBe
"api.devicecheck.apple.com"
}
test("DeviceCheck maps Apple's successful missing-state response to NotFound") {
val client = HttpClient(
MockEngine {
respond(
content = "Failed to find bit state\n",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
)
},
) {
install(HttpTimeout)
}
try {
KtorAppleDeviceCheckClient(
client,
testJwtGenerator(),
AppleServiceEnvironment.PRODUCTION,
).query(Base64.getEncoder().encodeToString(ByteArray(32))) shouldBe
DeviceCheckQuery.NotFound
} finally {
client.close()
}
}
test("DeviceCheck maps request timeout to temporary unavailability") {
val client = HttpClient(
MockEngine {
delay(250)
respond(
content = """{"bit0":false,"bit1":false}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
) {
install(HttpTimeout)
}
try {
shouldThrow<DeviceCheckUnavailableException> {
KtorAppleDeviceCheckClient(
client,
testJwtGenerator(),
AppleServiceEnvironment.PRODUCTION,
timeoutMillis = 20,
).query(Base64.getEncoder().encodeToString(ByteArray(32)))
}
} finally {
client.close()
}
}
})
private fun testJwtGenerator(): DeviceCheckJwtGenerator {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val pem = """
-----BEGIN PRIVATE KEY-----
${Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded)}
-----END PRIVATE KEY-----
""".trimIndent()
return DeviceCheckJwtGenerator("TEAM", "KEY", pem)
}
private class InMemoryTrialRepository(
private val events: MutableList<String>? = null,
) : DeviceCheckTrialClaimRepository {
val claims = mutableMapOf<String, TrialClaim>()
override suspend fun begin(tokenHash: String, accountId: UUID, now: Instant): BeginTrialClaim {
val existing = claims[tokenHash]
if (existing != null && existing.accountId != accountId) {
return BeginTrialClaim.ClaimedByAnotherAccount
}
val claim = existing ?: TrialClaim(tokenHash, accountId, TrialClaimStatus.RESERVED)
claims[tokenHash] = claim
return BeginTrialClaim.Owned(claim)
}
override suspend fun transition(tokenHash: String, status: TrialClaimStatus, now: Instant) {
claims[tokenHash] = requireNotNull(claims[tokenHash]).copy(status = status)
events?.add(status.name)
}
}
@@ -0,0 +1,101 @@
package com.osglab.account.features.integrity
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class IntegrityPortsTest : FunSpec({
test("unsupported promotional evidence is explicitly ineligible") {
val port = DefaultIntegrityRiskPort(
deviceCheckClient = unavailableDeviceCheck(),
appAttestRepository = EmptyAppAttestRepository,
)
port.assess(
IntegrityRiskRequest(UUID.randomUUID(), IntegrityRiskUseCase.SIGNUP_TRIAL),
) shouldBe IntegrityRiskDecision(
IntegrityEligibility.INELIGIBLE,
IntegrityEvidenceState.UNSUPPORTED,
)
}
test("temporary DeviceCheck failure asks promotional caller to retry") {
val port = DefaultIntegrityRiskPort(
deviceCheckClient = unavailableDeviceCheck(),
appAttestRepository = EmptyAppAttestRepository,
)
port.assess(
IntegrityRiskRequest(
UUID.randomUUID(),
IntegrityRiskUseCase.SIGNUP_TRIAL,
deviceCheckToken = "token",
),
) shouldBe IntegrityRiskDecision(
IntegrityEligibility.RETRY_LATER,
IntegrityEvidenceState.TEMPORARILY_UNAVAILABLE,
)
}
test("trial and risk bits both deny another signup trial") {
suspend fun decision(bit0: Boolean, bit1: Boolean): IntegrityRiskDecision {
val port = DefaultIntegrityRiskPort(
deviceCheckClient = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String) =
DeviceCheckQuery.Found(DeviceCheckState(bit0, bit1, null))
override suspend fun update(
deviceToken: String,
bit0: Boolean,
bit1: Boolean,
) = Unit
},
appAttestRepository = EmptyAppAttestRepository,
)
return port.assess(
IntegrityRiskRequest(
UUID.randomUUID(),
IntegrityRiskUseCase.SIGNUP_TRIAL,
deviceCheckToken = "token",
),
)
}
decision(bit0 = true, bit1 = false).eligibility shouldBe IntegrityEligibility.INELIGIBLE
decision(bit0 = false, bit1 = true).eligibility shouldBe IntegrityEligibility.INELIGIBLE
}
})
private fun unavailableDeviceCheck() = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
throw DeviceCheckUnavailableException("timeout")
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean): Unit =
throw DeviceCheckUnavailableException("timeout")
}
private object EmptyAppAttestRepository : AppAttestRepository {
override suspend fun createChallenge(challenge: AppAttestChallenge) = Unit
override suspend fun consumeChallenge(
id: UUID,
purpose: AppAttestChallengePurpose,
keyId: String,
challengeHash: String,
accountId: UUID?,
now: Instant,
) = ConsumedChallenge.MissingOrMismatched
override suspend fun saveKey(key: StoredAppAttestKey) = false
override suspend fun findKey(keyId: String): StoredAppAttestKey? = null
override suspend fun updateCounter(
keyId: String,
expectedCounter: Long,
newCounter: Long,
now: Instant,
) = false
override suspend fun bindKeyToAccount(keyId: String, accountId: UUID, now: Instant) = false
}
@@ -0,0 +1,60 @@
package com.osglab.account.features.integrity
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.config.IntegrityConfig
import com.osglab.account.config.IntegrityPolicy
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
class IntegrityServiceTest : FunSpec({
test("monitor policy fails open when Apple verification is unavailable") {
val service = service(
IntegrityPolicy.MONITOR,
IntegrityVerification.Unavailable("not configured"),
)
service.verifyAppleSignIn(IntegrityEvidence(), payload())
}
test("enforce policy fails closed when verification is unavailable") {
val service = service(
IntegrityPolicy.ENFORCE,
IntegrityVerification.Unavailable("network error"),
)
shouldThrow<ExternalServiceUnavailableException> {
service.verifyAppleSignIn(
IntegrityEvidence(deviceCheckToken = "token"),
payload(),
)
}
}
test("cryptographically rejected evidence is never fail open") {
val service = service(
IntegrityPolicy.MONITOR,
IntegrityVerification.Rejected("invalid"),
)
shouldThrow<InvalidRequestException> {
service.verifyAppleSignIn(
IntegrityEvidence(deviceCheckToken = "token"),
payload(),
)
}
}
})
private fun service(
devicePolicy: IntegrityPolicy,
deviceResult: IntegrityVerification,
): IntegrityService = IntegrityService(
config = IntegrityConfig(devicePolicy, IntegrityPolicy.MONITOR),
deviceCheckVerifier = object : DeviceCheckVerifier {
override suspend fun verify(deviceToken: String): IntegrityVerification = deviceResult
},
appAttestVerifier = UnavailableAppAttestVerifier(),
)
private fun payload() = AppleSignInIntegrityPayload("identity", "code", "nonce")
@@ -0,0 +1,211 @@
package com.osglab.account.features.inviteweb
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.delay
import kotlin.test.Test
class InviteWebRoutesTest {
private val config = InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id1234567890",
universalLinkBaseUrl = "https://osglab.com/i",
appleAppId = "ABCDE12345.com.example.osg",
)
@Test
fun `valid referral renders a bilingual first-party page with hardened headers`() = testApplication {
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { true }, config)
}
}
val response = client.get("/i/$VALID_CODE")
val body = response.bodyAsText()
response.status shouldBe HttpStatusCode.OK
response.headers[HttpHeaders.ContentType].orEmpty() shouldContain "text/html"
response.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0"
response.headers["Referrer-Policy"] shouldBe "no-referrer"
response.headers["X-Frame-Options"] shouldBe "DENY"
response.headers["X-Robots-Tag"] shouldBe "noindex, nofollow, noarchive"
response.headers["Content-Security-Policy"].orEmpty() shouldContain "script-src 'nonce-"
body shouldContain VALID_CODE
body shouldContain "复制邀请码"
body shouldContain "Copy invitation code"
body shouldContain "href=\"https://osglab.com/i/$VALID_CODE\""
body shouldContain "https://apps.apple.com/app/id1234567890"
body shouldNotContain "analytics"
body shouldNotContain "googletag"
body shouldNotContain "firebase"
body shouldNotContain "branch.io"
body shouldNotContain "appsflyer"
body shouldNotContain "adjust.com"
}
@Test
fun `malformed referral is rejected without invoking lookup`() = testApplication {
val lookedUpCodes = mutableListOf<String>()
application {
routing {
configureInviteWebRoutes(
referralLookup = ReferralLookupPort {
lookedUpCodes += it
true
},
config = config,
)
}
}
val response = client.get("/i/not-valid")
response.status shouldBe HttpStatusCode.NotFound
response.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0"
lookedUpCodes shouldBe emptyList()
}
@Test
fun `unknown referral returns the same generic not found response`() = testApplication {
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { false }, config)
}
}
val response = client.get("/i/$VALID_CODE")
response.status shouldBe HttpStatusCode.NotFound
response.bodyAsText() shouldBe
"邀请链接无效或已失效 / This invitation link is invalid or expired"
}
@Test
fun `lookup failure fails closed without exposing the exception`() = testApplication {
application {
routing {
configureInviteWebRoutes(
ReferralLookupPort { error("database password must not escape") },
config,
)
}
}
val response = client.get("/i/$VALID_CODE")
val body = response.bodyAsText()
response.status shouldBe HttpStatusCode.ServiceUnavailable
response.headers[HttpHeaders.RetryAfter] shouldBe "30"
body shouldNotContain "password"
body shouldContain "temporarily unavailable"
}
@Test
fun `lookup timeout fails closed with retry guidance`() = testApplication {
application {
routing {
configureInviteWebRoutes(
referralLookup = ReferralLookupPort {
delay(250)
true
},
config = config.copy(lookupTimeoutMillis = 100),
)
}
}
val response = client.get("/i/$VALID_CODE")
response.status shouldBe HttpStatusCode.ServiceUnavailable
response.headers[HttpHeaders.RetryAfter] shouldBe "30"
}
@Test
fun `both AASA paths return the same no-store JSON document`() = testApplication {
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { true }, config)
}
}
val wellKnown = client.get("/.well-known/apple-app-site-association")
val root = client.get("/apple-app-site-association")
wellKnown.status shouldBe HttpStatusCode.OK
root.status shouldBe HttpStatusCode.OK
wellKnown.headers[HttpHeaders.ContentType].orEmpty() shouldContain "application/json"
wellKnown.headers[HttpHeaders.CacheControl] shouldBe "no-store, max-age=0"
wellKnown.bodyAsText() shouldBe root.bodyAsText()
wellKnown.bodyAsText() shouldContain "\"ABCDE12345.com.example.osg\""
wellKnown.bodyAsText() shouldContain "\"/i/*\""
}
@Test
fun `configuration rejects unsafe URLs and malformed app IDs`() {
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "http://apps.apple.com/app/id123",
appleAppId = "ABCDE12345.com.example.osg",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id123",
appleAppId = "ABCDE12345.com.example.osg",
universalLinkBaseUrl = "https://user@osglab.com/i",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://example.com/fake-store",
appleAppId = "ABCDE12345.com.example.osg",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id123",
appleAppId = "ABCDE12345.com.example.osg",
universalLinkBaseUrl = "https://example.com/i",
)
}
shouldThrow<IllegalArgumentException> {
InviteWebConfig(
appStoreUrl = "https://apps.apple.com/app/id123",
appleAppId = "invalid",
)
}
}
@Test
fun `rendering safely encodes configured links and rejects path injection`() = testApplication {
val configWithQuery = config.copy(
appStoreUrl = "https://apps.apple.com/app/id1234567890?pt=1&ct=invite",
)
application {
routing {
configureInviteWebRoutes(ReferralLookupPort { true }, configWithQuery)
}
}
val response = client.get("/i/$VALID_CODE")
val body = response.bodyAsText()
response.status shouldBe HttpStatusCode.OK
body shouldContain
"href=\"https://apps.apple.com/app/id1234567890?pt=1&amp;ct=invite\""
body shouldNotContain "?pt=1&ct=invite"
client.get("/i/${VALID_CODE}%2Ftracking").status shouldBe HttpStatusCode.NotFound
}
private companion object {
const val VALID_CODE = "AbCdEf0123456789_-AbCd"
}
}
@@ -0,0 +1,206 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralConflict
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralWindowExpired
import com.osglab.account.features.referrals.services.ReferralService
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class ReferralServiceTest : FunSpec({
val now = Instant.parse("2026-08-15T00:00:00Z")
test("code creation is stable and code has non-enumerable length") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val first = service.getOrCreateCode(owner)
val second = service.getOrCreateCode(owner)
second shouldBe first
first.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("an account binds once and repeated same binding is idempotent") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
var identityLookups = 0
var registrationLookups = 0
val service = referralService(
store = store,
now = now,
riskIdentity = { userId ->
identityLookups += 1
ReferralRiskIdentity(fingerprint(userId), restricted = false)
},
registeredAt = {
registrationLookups += 1
now.minus(Duration.ofDays(1))
},
)
val code = service.getOrCreateCode(inviter)
val first = service.bind(invitee, code.code)
val identityLookupsAfterFirstBind = identityLookups
val registrationLookupsAfterFirstBind = registrationLookups
val second = service.bind(invitee, code.code)
second shouldBe first
store.bindings.size shouldBe 1
identityLookups shouldBe identityLookupsAfterFirstBind
registrationLookups shouldBe registrationLookupsAfterFirstBind
}
test("self-referral and binding after the configured window are rejected") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val inWindow = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = inWindow.getOrCreateCode(owner)
shouldThrow<ReferralConflict> {
inWindow.bind(owner, code.code)
}
val expired = referralService(store, now) { now.minus(Duration.ofDays(8)) }
shouldThrow<ReferralWindowExpired> {
expired.bind(UUID.randomUUID(), code.code)
}
}
test("identity tombstones prevent self-referral after account recreation") {
val store = TestBillingStore()
val deletedAccount = UUID.randomUUID()
val recreatedAccount = UUID.randomUUID()
val sharedFingerprint = "f".repeat(64)
val service = referralService(
store = store,
now = now,
registeredAt = { now.minus(Duration.ofDays(1)) },
riskIdentity = {
ReferralRiskIdentity(sharedFingerprint, restricted = false)
},
)
val code = service.getOrCreateCode(deletedAccount)
shouldThrow<ReferralConflict> {
service.bind(recreatedAccount, code.code)
}
}
test("campaign binding window overrides the legacy default window") {
val store = TestBillingStore()
val campaignId = UUID.randomUUID()
store.campaigns[campaignId] = ReferralCampaign(
id = campaignId,
name = "Short campaign",
startsAt = now.minusSeconds(60),
endsAt = now.plusSeconds(3_600),
bindingWindowSeconds = 3_600,
inviterRewardCredits = 10,
inviteeRewardCredits = 10,
maxRewardedBindings = 10,
budgetCredits = 200,
enabled = true,
)
store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now)
val service = referralService(store, now) { now.minus(Duration.ofHours(2)) }
val code = service.getOrCreateCode(UUID.randomUUID(), campaignId)
shouldThrow<ReferralWindowExpired> {
service.bind(UUID.randomUUID(), code.code)
}
}
test("campaign budget must cover one bilateral reward") {
shouldThrow<IllegalArgumentException> {
ReferralCampaign(
id = UUID.randomUUID(),
name = "Underfunded",
startsAt = now.minusSeconds(1),
endsAt = null,
bindingWindowSeconds = 3_600,
inviterRewardCredits = 10,
inviteeRewardCredits = 10,
maxRewardedBindings = null,
budgetCredits = 19,
enabled = true,
)
}
}
test("binding rules include the exact deadline and detect recreated identities") {
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
val registeredAt = now.minus(Duration.ofDays(7))
val fingerprint = "a".repeat(64)
val code = ReferralCode(
id = UUID.randomUUID(),
ownerUserId = inviter,
ownerIdentityFingerprint = fingerprint,
code = "abcdefghijklmnopqrstuv",
createdAt = registeredAt,
)
ReferralBindingRules.isWithinWindow(
registeredAt,
now,
Duration.ofDays(7),
) shouldBe true
ReferralBindingRules.isWithinWindow(
registeredAt,
now.plusNanos(1),
Duration.ofDays(7),
) shouldBe false
ReferralBindingRules.isSelfReferral(invitee, fingerprint, code) shouldBe true
ReferralBindingRules.isSelfReferral(invitee, "b".repeat(64), code) shouldBe false
ReferralBindingRules.isWithinWindow(
registeredAt = now,
attemptedAt = now.minusNanos(1),
bindingWindow = Duration.ofDays(7),
) shouldBe false
}
})
private fun referralService(
store: TestBillingStore,
now: Instant,
riskIdentity: (UUID) -> ReferralRiskIdentity = { userId ->
ReferralRiskIdentity(fingerprint(userId), restricted = false)
},
registeredAt: (UUID) -> Instant,
): ReferralService {
val sequence = AtomicInteger()
val generator = InviteCodeGenerator {
val suffix = sequence.incrementAndGet().toString().padStart(2, '0')
"abcdefghijklmnopqrst$suffix"
}
return ReferralService(
transactions = store,
registrationTimeProvider = UserRegistrationTimeProvider(registeredAt),
riskProvider = ReferralRiskProvider(riskIdentity),
bindingWindow = Duration.ofDays(7),
codeGenerator = generator,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
}
private fun fingerprint(userId: UUID): String =
userId.toString().replace("-", "").repeat(2)
@@ -0,0 +1,340 @@
package com.osglab.account.integration
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.config.SessionConfig
import com.osglab.account.features.account.ExposedAccountRepository
import com.osglab.account.features.auth.ExposedAuthRepository
import com.osglab.account.features.auth.SessionAccessAuthenticator
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.UsageMeasurement
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.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.ReferralService
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.sql.Connection
import java.sql.DriverManager
import java.time.Duration
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class MySqlSecurityIntegrationTest : FunSpec({
test("migrations enforce deletion, session, ledger concurrency and referral idempotency") {
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) {
KotlinMySqlContainer("mysql:8.4")
.withDatabaseName("osg_security_test")
.withUsername("test")
.withPassword("test")
.also(KotlinMySqlContainer::start)
} else {
null
}
val jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl
val username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root"
val password = System.getenv("TEST_MYSQL_PASSWORD")
?: mysql?.password
?: ""
fun connection(): Connection = DriverManager.getConnection(jdbcUrl, username, password)
val databaseConfig = DatabaseConfig(
jdbcUrl = jdbcUrl,
username = username,
password = password,
maximumPoolSize = 8,
)
val factory = DatabaseFactory(databaseConfig)
try {
factory.database
val secondFactory = DatabaseFactory(databaseConfig)
try {
val inside = AtomicInteger()
val maximumInside = AtomicInteger()
coroutineScope {
listOf(factory, secondFactory).map { lockOwner ->
async(Dispatchers.Default) {
lockOwner.withMysqlNamedLock("integration-global-trial", 10) {
val current = inside.incrementAndGet()
maximumInside.updateAndGet { previous -> maxOf(previous, current) }
delay(100)
inside.decrementAndGet()
}
}
}.awaitAll()
}
maximumInside.get() shouldBeExactly 1
} finally {
secondFactory.close()
}
val identity = IdentityFingerprint(ByteArray(32) { 7 })
val authRepository = ExposedAuthRepository(factory)
val accountRepository = ExposedAccountRepository(factory, identity)
val sessionConfig = SessionConfig(
issuer = "https://issuer.example",
audience = "ios",
hmacSecret = ByteArray(32) { 4 },
accessMinutes = 15,
refreshDays = 30,
)
val sessionJwt = SessionJwt(sessionConfig)
val authenticator = SessionAccessAuthenticator(sessionJwt, authRepository)
val deletedUser = UUID.randomUUID()
val deletedFamily = UUID.randomUUID()
val deletedSession = UUID.randomUUID()
val deletedFingerprint = identity.ofAppleSubject("deleted-apple-sub")
connection().use { connection ->
insertAccount(connection, deletedUser, "deleted-apple-sub", deletedFingerprint)
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO sessions (
id, account_id, family_id, refresh_token_hash, created_at, expires_at
) VALUES (
'$deletedSession', '$deletedUser', '$deletedFamily',
'${"1".repeat(64)}', CURRENT_TIMESTAMP(6),
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 1 DAY)
)
""".trimIndent(),
)
statement.executeUpdate(
"INSERT INTO credit_accounts (user_id, balance) VALUES ('$deletedUser', 10)",
)
statement.executeUpdate(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after, idempotency_key
) VALUES (
'${UUID.randomUUID()}', '$deletedUser', 'MANUAL_GRANT', 10, 10,
'integration-ledger-retained'
)
""".trimIndent(),
)
statement.executeUpdate(
"""
INSERT INTO referral_codes (
id, owner_user_id, owner_identity_fingerprint, code
) VALUES (
'${UUID.randomUUID()}', '$deletedUser', '$deletedFingerprint',
'abcdefghijklmnopqrstuv'
)
""".trimIndent(),
)
statement.executeUpdate(
"""
INSERT INTO gateway_grants (
id, account_id, idempotency_key, expires_at, created_at, updated_at
) VALUES (
'${UUID.randomUUID()}', '$deletedUser', 'integration-delete-grant',
DATE_ADD(CURRENT_TIMESTAMP(6), INTERVAL 1 DAY),
CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6)
)
""".trimIndent(),
)
}
}
val accessToken = sessionJwt.issue(deletedUser, deletedSession).value
authenticator.authenticate(accessToken).shouldNotBeNull()
val deletedAt = Instant.now()
accountRepository.deleteById(
deletedUser,
deletedAt,
deletedAt.plus(Duration.ofDays(365)),
createRevocation = { null },
)
authenticator.authenticate(accessToken).shouldBeNull()
connection().use { connection ->
count(connection, "accounts", "id = '$deletedUser'") shouldBeExactly 0
count(connection, "sessions", "account_id = '$deletedUser'") shouldBeExactly 0
count(connection, "credit_accounts", "user_id = '$deletedUser'") shouldBeExactly 0
count(connection, "referral_codes", "owner_user_id = '$deletedUser'") shouldBeExactly 0
count(connection, "gateway_grants", "account_id = '$deletedUser'") shouldBeExactly 0
count(connection, "credit_ledger", "user_id = '$deletedUser'") shouldBeExactly 1
count(
connection,
"account_identity_tombstones",
"identity_fingerprint = '$deletedFingerprint'",
) shouldBeExactly 1
}
val rateId = UUID.randomUUID()
connection().use { connection ->
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO credit_rate_versions (
id, kind, provider, model, effective_from,
asr_credits_numerator, asr_millis_denominator
) VALUES (
'$rateId', 'ASR', 'integration-provider', 'integration-model',
DATE_SUB(CURRENT_TIMESTAMP(6), INTERVAL 1 MINUTE), 1, 100
)
""".trimIndent(),
)
}
}
val transactions = ExposedBillingTransactionRunner(factory.database)
val credits = CreditService(
transactions,
ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
)
val concurrentUser = UUID.randomUUID()
connection().use {
insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub"))
}
credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent")
val reservationResults = coroutineScope {
listOf("integration-reserve-one", "integration-reserve-two").map { key ->
async(Dispatchers.Default) {
runCatching {
credits.reserve(
concurrentUser,
"integration-provider",
"integration-model",
UsageMeasurement.Asr(6_000),
managedCall = true,
idempotencyKey = key,
)
}
}
}.awaitAll()
}
withClue(
reservationResults.joinToString { result ->
result.exceptionOrNull()?.let { "${it::class.simpleName}: ${it.message}" } ?: "success"
},
) {
reservationResults.count { it.isSuccess } shouldBeExactly 1
}
credits.getAccount(concurrentUser).balance shouldBeExactly 40
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
connection().use {
insertAccount(it, inviter, "inviter-sub", identity.ofAppleSubject("inviter-sub"))
insertAccount(it, invitee, "invitee-sub", identity.ofAppleSubject("invitee-sub"))
}
val referrals = ReferralService(
transactions = transactions,
registrationTimeProvider = UserRegistrationTimeProvider { accountId ->
accountRepository.findById(accountId)?.createdAt
},
riskProvider = ReferralRiskProvider { accountId ->
accountRepository.findById(accountId)?.let {
ReferralRiskIdentity(it.identityFingerprint, it.antiAbuseRestricted)
}
},
bindingWindow = Duration.ofDays(7),
)
val code = referrals.getOrCreateCode(inviter)
referrals.bind(invitee, code.code)
credits.grantSignupTrial(invitee, 100, "integration-signup-invitee")
val referralReservation = credits.reserve(
invitee,
"integration-provider",
"integration-model",
UsageMeasurement.Asr(1_000),
managedCall = true,
idempotencyKey = "integration-referral-reserve",
)
coroutineScope {
List(2) {
async(Dispatchers.Default) {
credits.settle(
invitee,
referralReservation.id,
UsageMeasurement.Asr(500),
"integration-referral-settle",
)
}
}.awaitAll()
}
shouldThrow<CreditConflict> {
credits.settle(
invitee,
referralReservation.id,
UsageMeasurement.Asr(600),
"integration-referral-settle",
)
}
connection().use { connection ->
count(
connection,
"credit_ledger",
"entry_type IN ('REFERRAL_INVITER', 'REFERRAL_INVITEE')",
) shouldBeExactly 2
count(
connection,
"referral_bindings",
"invitee_user_id = '$invitee' AND rewarded_at IS NOT NULL",
) shouldBeExactly 1
}
} finally {
factory.close()
mysql?.stop()
}
}
})
private class KotlinMySqlContainer(image: String) :
MySQLContainer<KotlinMySqlContainer>(image)
private fun KotlinMySqlContainer.connection(): Connection =
DriverManager.getConnection(jdbcUrl, username, password)
private fun insertAccount(
connection: Connection,
id: UUID,
appleSubject: String,
identityFingerprint: String,
) {
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO accounts (
id, apple_sub, identity_fingerprint, anti_abuse_restricted, created_at, updated_at
) VALUES (
'$id', '$appleSubject', '$identityFingerprint', FALSE,
CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6)
)
""".trimIndent(),
)
}
}
private fun count(connection: Connection, table: String, predicate: String): Int =
connection.createStatement().use { statement ->
statement.executeQuery("SELECT COUNT(*) FROM $table WHERE $predicate").use { result ->
result.next()
result.getInt(1)
}
}