checkpoint before checking out feature/account-managed-gateway
This commit is contained in:
@@ -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()
|
||||
|
||||
+20
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user