checkpoint before checking out feature/account-managed-gateway

This commit is contained in:
Rocky
2026-08-19 15:53:08 +08:00
parent 25cfbfa4e6
commit 1737106560
51 changed files with 1837 additions and 52 deletions
@@ -14,6 +14,9 @@ class AppConfigTest : FunSpec({
config.environment shouldBe Environment.TEST
config.apple.clientCredentialsAvailable shouldBe false
config.encryption.key.size shouldBe 32
config.credits.signupTrial shouldBe 1_000
config.credits.referralInviter shouldBe 1_000
config.credits.referralInvitee shouldBe 1_000
}
test("production rejects placeholder secrets") {
@@ -76,6 +79,43 @@ class AppConfigTest : FunSpec({
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
}
test("StoreKit requires an app identifier and dedicated credit product when enabled") {
val missingAppId = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(missingAppId)
}.message.orEmpty() shouldContain "appAppleId is required"
val enabled = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.appAppleId", "6781553267")
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
}
val storeKit = AppConfig.from(enabled).storeKit
storeKit.enabled shouldBe true
storeKit.appAppleId shouldBe 6_781_553_267
storeKit.products.map { it.productId to it.credits } shouldBe listOf(
"500tks" to 500,
"1500tks" to 1_500,
"3000tks" to 3_000,
)
}
test("StoreKit rejects duplicate product mappings") {
val config = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.appAppleId", "6781553267")
put("app.storeKit.products", "500tks:500,500tks:3000")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "duplicate product IDs"
}
test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply {
put("app.apple.keyId", "")
@@ -61,6 +61,44 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "unpadded Base64URL"
}
test("StoreKit catalog and smaller immutable rates stay aligned") {
listOf(root.read(".env.example"), root.read("compose.yaml")).forEach { configuration ->
configuration shouldContain "STOREKIT_PRODUCTS"
configuration shouldContain "500tks:500,1500tks:1500,3000tks:3000"
configuration shouldNotContain "STOREKIT_PRODUCT_CREDITS"
configuration shouldContain "SIGNUP_TRIAL_CREDITS"
configuration shouldContain "REFERRAL_INVITER_CREDITS"
}
val rates = root.read("src/main/resources/db/migration/V10__smaller_credit_units.sql")
rates shouldContain "'10000000-0000-0000-0000-000000000003'"
rates shouldContain "'10000000-0000-0000-0000-000000000004'"
rates shouldContain "1,\n 3000,"
rates shouldContain "1,\n 1000,\n 1,\n 400,"
}
test("account profiles cascade on deletion and grants stay aligned") {
val profileMigration = root.read(
"src/main/resources/db/migration/V11__account_profiles.sql",
)
val referralMigration = root.read(
"src/main/resources/db/migration/V12__align_referral_rewards.sql",
)
profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL"
profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
referralMigration shouldContain "inviter_reward_credits = 1000"
referralMigration shouldContain "invitee_reward_credits = 1000"
listOf(
root.read("src/main/resources/application.yaml"),
root.read(".env.example"),
root.read("compose.yaml"),
).forEach { configuration ->
configuration shouldContain "1000"
configuration shouldNotContain "SIGNUP_TRIAL_CREDITS=334"
}
}
test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml")
@@ -81,6 +119,7 @@ class DeploymentConsistencyTest : FunSpec({
test("admin bootstrap is one-time and runtime database grants stay explicit") {
val compose = root.read("compose.yaml")
val privileges = root.read("docs/mysql-minimum-privileges.sql")
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators"
@@ -91,10 +130,16 @@ class DeploymentConsistencyTest : FunSpec({
privileges shouldContain "GRANT INSERT ON osg_account.gateway_grant_scopes"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT SELECT ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT ON osg_account.admin_audit_log"
privileges shouldContain "GRANT INSERT ON osg_account.admin_credit_grants"
privileges shouldContain "GRANT SELECT ON osg_account.storekit_credit_purchases"
privileges shouldContain "GRANT INSERT ON osg_account.storekit_credit_purchases"
privileges shouldNotContain "UPDATE ON osg_account.admin_audit_log"
privileges shouldNotContain "DELETE ON osg_account.admin_credit_grants"
smokePrivileges shouldContain "GRANT SELECT ON osg_account_smoke.account_profiles"
smokePrivileges shouldContain "GRANT INSERT, UPDATE ON osg_account_smoke.account_profiles"
}
test("container image remains non-root and read-only compatible") {
@@ -162,6 +207,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
"/v1/storekit/products",
"/v1/storekit/transactions",
"/v1/referrals",
"/v1/referrals/me",
"/v1/referrals/code",
@@ -37,9 +37,13 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
runner shouldContain "Flyway history was not exactly successful V1-V8"
runner shouldContain "Flyway history was not exactly successful V1-V12"
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
runner shouldContain "active smaller credit rates did not match the V10 contract"
runner shouldContain "first ledger page omitted nextCursor"
runner shouldContain "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP()"
runner shouldContain "UPDATE storekit_credit_purchases SET credits_granted = credits_granted"
runner shouldContain "DELETE FROM storekit_credit_purchases WHERE 1 = 0"
runner shouldNotContain "appleid.apple.com"
runner shouldNotContain "api.deepseek.com"
runner shouldNotContain "openspeech.bytedance.com"
@@ -0,0 +1,94 @@
package com.osglab.account.features.account
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SESSION_AUTH_NAME
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.patch
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
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.auth.Authentication
import io.ktor.server.auth.bearer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import java.time.Instant
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlin.test.Test
class AccountRoutesTest {
private val accountId = UUID.fromString("10000000-0000-0000-0000-000000000010")
private val sessionId = UUID.fromString("20000000-0000-0000-0000-000000000010")
@Test
fun `profile read update and destructive deletion require the authenticated account`() =
testApplication {
val operations = RecordingAccountOperations(accountId)
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
install(Authentication) {
bearer(SESSION_AUTH_NAME) {
authenticate { AccountPrincipal(accountId, sessionId) }
}
}
routing { accountRoutes(operations) }
}
val profile = client.get("/v1/account") {
header(HttpHeaders.Authorization, "Bearer test")
}
val updated = client.patch("/v1/account") {
header(HttpHeaders.Authorization, "Bearer test")
contentType(ContentType.Application.Json)
setBody("""{"displayName":"OSG 用户"}""")
}
val deleted = client.delete("/v1/account") {
header(HttpHeaders.Authorization, "Bearer test")
contentType(ContentType.Application.Json)
setBody(
"""{"identityToken":"identity","authorizationCode":"code","nonce":"nonce"}""",
)
}
profile.status shouldBe HttpStatusCode.OK
profile.bodyAsText() shouldContain """"displayName":"Rocky""""
updated.status shouldBe HttpStatusCode.OK
updated.bodyAsText() shouldContain """"displayName":"OSG 用户""""
deleted.status shouldBe HttpStatusCode.NoContent
operations.deletedAccountId shouldBe accountId
operations.deletionProof shouldBe AppleReauthenticationProof("identity", "code", "nonce")
}
}
private class RecordingAccountOperations(
private val accountId: UUID,
) : AccountOperations {
private var displayName = "Rocky"
var deletedAccountId: UUID? = null
var deletionProof: AppleReauthenticationProof? = null
override suspend fun get(accountId: UUID): AccountView =
AccountView(accountId, Instant.EPOCH, displayName)
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) = Unit
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
displayName = candidate
return get(accountId)
}
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
deletedAccountId = accountId
deletionProof = proof
}
}
@@ -14,6 +14,40 @@ import java.time.Instant
import java.util.UUID
class AccountServiceTest : FunSpec({
test("Apple name seeds once and the user can update the nickname") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
val repository = RecordingAccountRepository(
AccountRecord(
id = accountId,
identityFingerprint = "d".repeat(64),
antiAbuseRestricted = false,
encryptedAppleRefreshToken = null,
createdAt = Instant.parse("2026-08-18T00:00:00Z"),
),
mutableListOf(),
)
val service = AccountService(
repository,
encryptor,
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
AppleRevocationOutboxProcessor(
repository,
RecordingAppleTokenClient(mutableListOf()),
encryptor,
),
AccountReauthenticator { _, _ -> "unused" },
)
service.seedDisplayName(accountId, " Rocky Chen ")
service.seedDisplayName(accountId, "Ignored")
service.get(accountId).displayName shouldBe "Rocky Chen"
service.updateDisplayName(accountId, "OSG 用户")
service.get(accountId).displayName shouldBe "OSG 用户"
}
test("account deletion commits locally before reliably revoking the Apple token") {
val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
@@ -117,15 +151,34 @@ private val REAUTH_PROOF = AppleReauthenticationProof(
)
private class RecordingAccountRepository(
private val account: AccountRecord,
account: AccountRecord,
private val events: MutableList<String>,
) : AccountRepository {
private var account = account
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 seedDisplayNameIfAbsent(
accountId: UUID,
encryptedDisplayName: String,
now: Instant,
) {
if (account.encryptedDisplayName == null) {
account = account.copy(encryptedDisplayName = encryptedDisplayName)
}
}
override suspend fun updateDisplayName(
accountId: UUID,
encryptedDisplayName: String,
now: Instant,
) {
account = account.copy(encryptedDisplayName = encryptedDisplayName)
}
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
@@ -48,6 +48,33 @@ class CreditServiceTest : FunSpec({
) shouldBeExactly 4
}
test("smaller production unit bills each started interval") {
val productionAsr = asrRate(now).copy(asrMillisDenominator = 3_000)
CreditCostCalculator.calculate(
productionAsr,
UsageMeasurement.Asr(durationMillis = 1),
) shouldBeExactly 1
CreditCostCalculator.calculate(
productionAsr,
UsageMeasurement.Asr(durationMillis = 3_000),
) shouldBeExactly 1
CreditCostCalculator.calculate(
productionAsr,
UsageMeasurement.Asr(durationMillis = 3_001),
) shouldBeExactly 2
val productionLlm = llmRate(now).copy(
inputCreditsNumerator = 1,
inputTokensDenominator = 1_000,
outputCreditsNumerator = 1,
outputTokensDenominator = 400,
)
CreditCostCalculator.calculate(
productionLlm,
UsageMeasurement.Llm(inputTokens = 1_001, outputTokens = 401),
) shouldBeExactly 4
}
test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") {
CreditCostCalculator.calculate(
asrRate(now).copy(
@@ -602,6 +629,43 @@ class CreditServiceTest : FunSpec({
} shouldBe true
}
test("account summary reports settled usage separately from remaining credits") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.grantSignupTrial(userId, 100, "summary-signup-key")
val reservation = service.reserve(
userId,
"asr-provider",
"asr-model",
UsageMeasurement.Asr(1_000),
managedCall = false,
idempotencyKey = "summary-reserve-key",
)
val reservedSummary = service.getAccountSummary(userId)
reservedSummary.account.balance shouldBeExactly 90
reservedSummary.lifetimeUsed shouldBeExactly 0
service.settle(
userId,
reservation.id,
UsageMeasurement.Asr(500),
"summary-settle-key",
)
val summary = service.getAccountSummary(userId)
summary.account.balance shouldBeExactly 95
summary.lifetimeUsed shouldBeExactly 5
service.refund(userId, reservation.id, "summary-refund-key")
val refundedSummary = service.getAccountSummary(userId)
refundedSummary.account.balance shouldBeExactly 100
refundedSummary.lifetimeUsed shouldBeExactly 0
}
test("release and refund restore only the corresponding debit") {
val store = storeWithRates(now)
val service = service(store, now)
@@ -8,6 +8,7 @@ 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.LedgerEntryType
import com.osglab.account.features.credits.domain.ManualCreditGrant
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
@@ -20,6 +21,8 @@ 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 com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import java.time.Instant
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
@@ -37,6 +40,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>()
val bindings = mutableMapOf<UUID, ReferralBinding>()
val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>()
val campaigns = mutableMapOf(
DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaign(
id = DEFAULT_REFERRAL_CAMPAIGN_ID,
@@ -63,6 +67,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
override val credits: CreditsRepository = Credits()
override val referrals: ReferralsRepository = Referrals()
override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants()
override val storeKit: StoreKitRepository = StoreKitPurchases()
var failNextManualGrantInsert = false
@@ -78,6 +83,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val codeSnapshot = codes.toMap()
val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap()
val storeKitSnapshot = storeKitPurchases.toMap()
try {
block(this)
} catch (failure: Throwable) {
@@ -91,6 +97,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
codes.replaceWith(codeSnapshot)
bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot)
storeKitPurchases.replaceWith(storeKitSnapshot)
throw failure
}
}
@@ -134,6 +141,15 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
usageRecords += record
}
override fun lifetimeUsedCredits(userId: UUID): Long {
val charged = usageRecords.filter { it.userId == userId }
.fold(0L) { total, record -> Math.addExact(total, record.chargedCredits) }
val refunded = ledger.filter {
it.userId == userId && it.type == LedgerEntryType.USAGE_REFUND
}.fold(0L) { total, entry -> Math.addExact(total, entry.amountDelta) }
return Math.subtractExact(charged, refunded)
}
override fun findReservationByReserveKey(
userId: UUID,
idempotencyKey: String,
@@ -271,6 +287,15 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
entry.setValue(entry.value.copy(rewardStatus = ReferralRewardStatus.INELIGIBLE_BUDGET))
}
}
private inner class StoreKitPurchases : StoreKitRepository {
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
storeKitPurchases[transactionId]
override fun insert(purchase: StoreKitCreditPurchase) {
check(storeKitPurchases.putIfAbsent(purchase.transactionId, purchase) == null)
}
}
}
private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
@@ -93,6 +93,40 @@ class AppAttestCryptoTest : FunSpec({
)
}
}
test("assertion accepts the production fixed profile with AT set") {
val fixture = AppAttestFixture()
val hash = sha256ForTest("production-request".toByteArray())
fixture.crypto().validateAssertion(
assertionObject = fixture.assertionObject(
counter = 1,
clientDataHash = hash,
flags = 0x40,
),
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 0,
) shouldBe 1L
}
test("assertion rejects the extension flag without extension data") {
val fixture = AppAttestFixture()
val hash = sha256ForTest("extension-request".toByteArray())
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAssertion(
assertionObject = fixture.assertionObject(
counter = 1,
clientDataHash = hash,
flags = 0x80,
),
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 0,
)
}
}
})
private class AppAttestFixture {
@@ -134,15 +168,19 @@ private class AppAttestFixture {
.EncodeToBytes()
}
fun assertionObject(counter: Int, clientDataHash: ByteArray): ByteArray {
fun assertionObject(
counter: Int,
clientDataHash: ByteArray,
flags: Int = 0,
): ByteArray {
val authData = ByteBuffer.allocate(37).order(ByteOrder.BIG_ENDIAN)
.put(rpIdHash)
.put(0)
.put(flags.toByte())
.putInt(counter)
.array()
val signature = Signature.getInstance("SHA256withECDSA").run {
initSign(keyPair.private)
update(authData + clientDataHash)
update(sha256ForTest(authData + clientDataHash))
sign()
}
return CBORObject.NewMap()
@@ -38,6 +38,19 @@ class ReferralServiceTest : FunSpec({
store.codes.size shouldBe 1
}
test("profile lookup automatically provisions a stable invitation code") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val first = service.getProfile(owner)
val second = service.getProfile(owner)
first.code shouldBe second.code
first.code?.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()
@@ -0,0 +1,20 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
class AppleStoreKitTransactionVerifierTest : FunSpec({
test("bundled Apple roots load and malformed JWS is rejected") {
val verifier = AppleStoreKitTransactionVerifier(
bundleId = "com.osgkeyboard.ios",
appAppleId = 6_781_553_267,
enableOnlineChecks = false,
)
shouldThrow<StoreKitVerificationFailed> {
verifier.verify("not-a-jws".repeat(20))
}
}
})
@@ -0,0 +1,107 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.routes.storeKitRoutes
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
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 java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlin.test.Test
class StoreKitRoutesTest {
private val now = Instant.parse("2026-08-18T08:00:00Z")
private val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
private val signedTransaction = "s".repeat(100)
@Test
fun `authenticated transaction submission grants once and replays safely`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { userId })
}
}
val catalog = client.get("/v1/storekit/products")
val first = client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
val replay = client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
catalog.status shouldBe HttpStatusCode.OK
catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500"""
catalog.bodyAsText() shouldContain """"productId":"1500tks","credits":1500"""
catalog.bodyAsText() shouldContain """"productId":"3000tks","credits":3000"""
first.status shouldBe HttpStatusCode.OK
first.bodyAsText() shouldContain """"creditsGranted":3000"""
first.bodyAsText() shouldContain """"replayed":false"""
replay.status shouldBe HttpStatusCode.OK
replay.bodyAsText() shouldContain """"replayed":true"""
}
@Test
fun `product catalog and transaction submission require authentication`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { null })
}
}
client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized
client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}.status shouldBe HttpStatusCode.Unauthorized
}
private fun service(): StoreKitService {
val verified = VerifiedStoreKitTransaction(
transactionId = "2000000000001",
originalTransactionId = "2000000000001",
appAccountToken = userId,
productId = "3000tks",
environment = StoreKitEnvironment.SANDBOX,
purchasedAt = now.minusSeconds(10),
signedAt = now.minusSeconds(5),
revokedAt = null,
)
return StoreKitService(
products = listOf(
StoreKitProduct("500tks", 500),
StoreKitProduct("1500tks", 1_500),
StoreKitProduct(verified.productId, 3_000),
),
verifier = StoreKitTransactionVerifier { verified },
transactions = TestBillingStore(),
clock = Clock.fixed(now, ZoneOffset.UTC),
)
}
}
@@ -0,0 +1,133 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class StoreKitServiceTest : FunSpec({
val now = Instant.parse("2026-08-18T08:00:00Z")
val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
val product = StoreKitProduct("3000tks", 3_000)
val signedTransaction = "s".repeat(100)
fun transaction(
transactionId: String = "2000000000001",
accountToken: UUID = userId,
productId: String = product.productId,
revokedAt: Instant? = null,
) = VerifiedStoreKitTransaction(
transactionId = transactionId,
originalTransactionId = transactionId,
appAccountToken = accountToken,
productId = productId,
environment = StoreKitEnvironment.SANDBOX,
purchasedAt = now.minusSeconds(10),
signedAt = now.minusSeconds(5),
revokedAt = revokedAt,
)
fun service(
store: TestBillingStore,
verified: VerifiedStoreKitTransaction = transaction(),
) = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier { verified },
transactions = store,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
test("verified consumable grants integer credits and appends immutable records") {
val store = TestBillingStore()
val result = service(store).submit(userId, signedTransaction)
result.balanceAfter shouldBeExactly 3_000
result.replayed shouldBe false
store.storeKitPurchases.values shouldHaveSize 1
store.ledger.single().type shouldBe LedgerEntryType.STOREKIT_PURCHASE
store.ledger.single().referenceId shouldBe result.purchase.id
}
test("same transaction replay returns the original grant without double crediting") {
val store = TestBillingStore()
val service = service(store)
service.submit(userId, signedTransaction)
val replay = service.submit(userId, signedTransaction)
replay.replayed shouldBe true
replay.balanceAfter shouldBeExactly 3_000
store.ledger shouldHaveSize 1
}
test("concurrent transaction replay grants credits exactly once") {
val store = TestBillingStore()
val service = service(store)
val results = coroutineScope {
(1..20).map {
async { service.submit(userId, signedTransaction) }
}.awaitAll()
}
results.count { !it.replayed } shouldBe 1
store.balance(userId) shouldBeExactly 3_000
store.ledger shouldHaveSize 1
}
test("transaction must be bound to the authenticated account") {
val store = TestBillingStore()
val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011")
shouldThrow<StoreKitPurchaseConflict> {
service(store, transaction(accountToken = otherUser))
.submit(userId, signedTransaction)
}
store.ledger shouldHaveSize 0
}
test("malformed signed transaction is rejected before verification or persistence") {
val store = TestBillingStore()
shouldThrow<InvalidStoreKitRequest> {
service(store).submit(userId, "too-short")
}
store.ledger shouldHaveSize 0
}
test("unknown or revoked products never grant credits") {
val unknownStore = TestBillingStore()
shouldThrow<StoreKitPurchaseConflict> {
service(unknownStore, transaction(productId = "com.osgkeyboard.credits.unknown"))
.submit(userId, signedTransaction)
}
unknownStore.ledger shouldHaveSize 0
val revokedStore = TestBillingStore()
shouldThrow<StoreKitPurchaseConflict> {
service(revokedStore, transaction(revokedAt = now))
.submit(userId, signedTransaction)
}
revokedStore.ledger shouldHaveSize 0
}
})