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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
+183
@@ -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 { }
|
||||
+144
@@ -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),
|
||||
)
|
||||
+228
@@ -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")
|
||||
+231
@@ -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()
|
||||
+289
@@ -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
|
||||
}
|
||||
+14
@@ -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&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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user