Add StoreKit history and modernize admin console
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
Rocky
2026-08-19 22:13:13 +08:00
parent 11ec34dacb
commit 231c5040a5
51 changed files with 6484 additions and 4236 deletions
@@ -295,7 +295,7 @@ fun Application.module() {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
@@ -55,6 +55,24 @@ data class StoreKitPurchaseResult(
val replayed: Boolean,
)
data class StoreKitTransactionCursor(
val purchasedAt: Instant,
val transactionId: String,
)
data class CreditedStoreKitTransaction(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: Instant,
)
data class StoreKitTransactionPage(
val items: List<CreditedStoreKitTransaction>,
val nextCursor: String?,
)
sealed class StoreKitException(message: String) : RuntimeException(message)
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
@@ -1,8 +1,13 @@
package com.osglab.account.features.storekit.models
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
@Serializable
data class StoreKitProductDto(
@@ -40,3 +45,41 @@ data class StoreKitPurchaseResponse(
}
}
@Serializable
data class StoreKitTransactionHistoryItemResponse(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: String,
val status: String,
) {
companion object {
fun fromDomain(
transaction: CreditedStoreKitTransaction,
): StoreKitTransactionHistoryItemResponse =
StoreKitTransactionHistoryItemResponse(
transactionId = transaction.transactionId,
productId = transaction.productId,
creditsGranted = transaction.creditsGranted,
balanceAfter = transaction.balanceAfter,
purchasedAt = transaction.purchasedAt.toString(),
status = "credited",
)
}
}
@Serializable
data class StoreKitTransactionHistoryResponse(
val items: List<StoreKitTransactionHistoryItemResponse>,
val nextCursor: JsonElement,
) {
companion object {
fun fromDomain(page: StoreKitTransactionPage): StoreKitTransactionHistoryResponse =
StoreKitTransactionHistoryResponse(
items = page.items.map(StoreKitTransactionHistoryItemResponse::fromDomain),
nextCursor = page.nextCursor?.let(::JsonPrimitive) ?: JsonNull,
)
}
}
@@ -1,16 +1,26 @@
package com.osglab.account.features.storekit.repositories
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import java.util.UUID
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
interface StoreKitRepository {
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction>
fun insert(purchase: StoreKitCreditPurchase)
}
@@ -22,6 +32,60 @@ object ExposedStoreKitRepository : StoreKitRepository {
.singleOrNull()
?.toStoreKitCreditPurchase()
override fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction> {
val accountId = userId.toString()
val query = StoreKitCreditPurchases
.innerJoin(
otherTable = StoreKitPurchaseLedger,
onColumn = { ledgerEntryId },
otherColumn = { id },
)
.select(
StoreKitCreditPurchases.transactionId,
StoreKitCreditPurchases.productId,
StoreKitCreditPurchases.creditsGranted,
StoreKitCreditPurchases.purchasedAt,
StoreKitPurchaseLedger.balanceAfter,
)
query.where {
val accountAndCredited =
(StoreKitCreditPurchases.userId eq accountId) and
(StoreKitPurchaseLedger.userId eq accountId) and
(StoreKitPurchaseLedger.entryType eq LedgerEntryType.STOREKIT_PURCHASE) and
(
StoreKitPurchaseLedger.amountDelta eq
StoreKitCreditPurchases.creditsGranted
) and
(StoreKitPurchaseLedger.referenceId eq StoreKitCreditPurchases.id)
if (before == null) {
accountAndCredited
} else {
accountAndCredited and
(
(StoreKitCreditPurchases.purchasedAt less before.purchasedAt) or
(
(StoreKitCreditPurchases.purchasedAt eq before.purchasedAt) and
(
StoreKitCreditPurchases.transactionId less
before.transactionId
)
)
)
}
}
return query
.orderBy(
StoreKitCreditPurchases.purchasedAt to SortOrder.DESC,
StoreKitCreditPurchases.transactionId to SortOrder.DESC,
)
.limit(limit)
.map(ResultRow::toCreditedStoreKitTransaction)
}
override fun insert(purchase: StoreKitCreditPurchase) {
StoreKitCreditPurchases.insert {
it[id] = purchase.id.toString()
@@ -59,6 +123,17 @@ private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
override val primaryKey = PrimaryKey(id)
}
private object StoreKitPurchaseLedger : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val referenceId = varchar("reference_id", 36).nullable()
override val primaryKey = PrimaryKey(id)
}
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
StoreKitCreditPurchase(
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
@@ -75,3 +150,12 @@ private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
signedAt = this[StoreKitCreditPurchases.signedAt],
createdAt = this[StoreKitCreditPurchases.createdAt],
)
private fun ResultRow.toCreditedStoreKitTransaction(): CreditedStoreKitTransaction =
CreditedStoreKitTransaction(
transactionId = this[StoreKitCreditPurchases.transactionId],
productId = this[StoreKitCreditPurchases.productId],
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
balanceAfter = this[StoreKitPurchaseLedger.balanceAfter],
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
)
@@ -2,6 +2,7 @@ package com.osglab.account.features.storekit.routes
import com.osglab.account.common.api.ApiError
import com.osglab.account.common.api.ApiErrorResponse
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
@@ -12,8 +13,10 @@ import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.models.StoreKitProductDto
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
import com.osglab.account.features.storekit.models.StoreKitTransactionHistoryResponse
import com.osglab.account.features.storekit.services.StoreKitService
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
@@ -25,64 +28,102 @@ fun Route.storeKitRoutes(
service: StoreKitService,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
authenticate(SESSION_AUTH_NAME) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@post
get("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
try {
if (call.request.queryParameters.contains("accountId")) {
throw InvalidStoreKitRequest("accountId is not accepted")
}
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) {
50
} else {
throw InvalidStoreKitRequest("limit must be an integer")
}
call.respond(
StoreKitTransactionHistoryResponse.fromDomain(
service.listTransactions(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
)
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
}
}
val request = call.receive<StoreKitSubmitRequest>()
try {
call.respond(
HttpStatusCode.OK,
StoreKitPurchaseResponse.fromDomain(
service.submit(userId, request.signedTransaction)
),
)
} catch (_: StoreKitUnavailable) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ApiErrorResponse(
ApiError("external_service_unavailable", "Credit purchases are unavailable")
),
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
} catch (_: StoreKitVerificationFailed) {
call.respond(
HttpStatusCode.UnprocessableEntity,
ApiErrorResponse(
ApiError("transaction_invalid", "The App Store transaction is invalid")
),
)
} catch (_: StoreKitPurchaseConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
} catch (_: CreditConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@post
}
val request = call.receive<StoreKitSubmitRequest>()
try {
call.respond(
HttpStatusCode.OK,
StoreKitPurchaseResponse.fromDomain(
service.submit(userId, request.signedTransaction)
),
)
} catch (_: StoreKitUnavailable) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ApiErrorResponse(
ApiError("external_service_unavailable", "Credit purchases are unavailable")
),
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
} catch (_: StoreKitVerificationFailed) {
call.respond(
HttpStatusCode.UnprocessableEntity,
ApiErrorResponse(
ApiError("transaction_invalid", "The App Store transaction is invalid")
),
)
} catch (_: StoreKitPurchaseConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
} catch (_: CreditConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
}
}
}
}
@@ -9,13 +9,17 @@ import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.UUID
class StoreKitService(
@@ -33,6 +37,33 @@ class StoreKitService(
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
suspend fun listTransactions(
userId: UUID,
limit: Int = DEFAULT_PAGE_SIZE,
cursor: String? = null,
): StoreKitTransactionPage {
if (limit !in 1..MAX_PAGE_SIZE) {
throw InvalidStoreKitRequest("limit must be between 1 and 100")
}
val decodedCursor = cursor?.let(StoreKitTransactionCursorCodec::decode)
val results = transactions.inTransaction { unit ->
unit.storeKit.listCreditedTransactions(userId, limit + 1, decodedCursor)
}
val items = results.take(limit)
return StoreKitTransactionPage(
items = items,
nextCursor = if (results.size > limit) {
items.lastOrNull()?.let {
StoreKitTransactionCursorCodec.encode(
StoreKitTransactionCursor(it.purchasedAt, it.transactionId)
)
}
} else {
null
},
)
}
suspend fun submit(
userId: UUID,
signedTransaction: String,
@@ -155,6 +186,40 @@ class StoreKitService(
private companion object {
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
}
}
internal object StoreKitTransactionCursorCodec {
private const val MAX_CURSOR_LENGTH = 256
private const val INVALID_CURSOR_MESSAGE = "cursor is invalid"
private val TRANSACTION_ID = Regex("[0-9]{1,64}")
fun encode(cursor: StoreKitTransactionCursor): String {
val value = "${cursor.purchasedAt}|${cursor.transactionId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): StoreKitTransactionCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH || value != value.trim()) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2 && TRANSACTION_ID.matches(parts[1]))
StoreKitTransactionCursor(
purchasedAt = Instant.parse(parts[0]),
transactionId = parts[1],
)
} catch (_: IllegalArgumentException) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
}
}
@@ -0,0 +1,2 @@
CREATE INDEX idx_storekit_purchase_user_purchased_transaction
ON storekit_credit_purchases (user_id, purchased_at, transaction_id);
@@ -77,6 +77,26 @@ class DeploymentConsistencyTest : FunSpec({
rates shouldContain "1,\n 1000,\n 1,\n 400,"
}
test("StoreKit history remains ledger backed, indexed, and privacy minimized") {
val historyIndex = root.read(
"src/main/resources/db/migration/V15__storekit_purchase_history_index.sql",
)
historyIndex shouldContain
"ON storekit_credit_purchases (user_id, purchased_at, transaction_id)"
val openApi = root.read("docs/openapi.yaml")
val historySchema = openApi
.substringAfter(" StoreKitTransactionHistoryItem:")
.substringBefore(" AppleAppSiteAssociation:")
historySchema shouldContain "purchasedAt"
historySchema shouldContain "status"
historySchema shouldContain "nextCursor"
historySchema shouldNotContain "signedTransaction"
historySchema shouldNotContain "appAccountToken"
historySchema shouldNotContain "userId"
historySchema shouldNotContain "accountId"
}
test("account profiles cascade on deletion and grants stay aligned") {
val profileMigration = root.read(
"src/main/resources/db/migration/V11__account_profiles.sql",
@@ -21,7 +21,9 @@ 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.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import java.time.Instant
import java.util.UUID
@@ -292,6 +294,48 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
storeKitPurchases[transactionId]
override fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction> =
storeKitPurchases.values
.asSequence()
.filter { purchase ->
purchase.userId == userId &&
(
before == null ||
purchase.purchasedAt < before.purchasedAt ||
(
purchase.purchasedAt == before.purchasedAt &&
purchase.transactionId < before.transactionId
)
)
}
.mapNotNull { purchase ->
ledger.singleOrNull {
it.id == purchase.ledgerEntryId &&
it.userId == userId &&
it.type == LedgerEntryType.STOREKIT_PURCHASE &&
it.amountDelta == purchase.creditsGranted &&
it.referenceId == purchase.id
}?.let { ledgerEntry ->
CreditedStoreKitTransaction(
transactionId = purchase.transactionId,
productId = purchase.productId,
creditsGranted = purchase.creditsGranted,
balanceAfter = ledgerEntry.balanceAfter,
purchasedAt = purchase.purchasedAt,
)
}
}
.sortedWith(
compareByDescending<CreditedStoreKitTransaction> { it.purchasedAt }
.thenByDescending { it.transactionId }
)
.take(limit)
.toList()
override fun insert(purchase: StoreKitCreditPurchase) {
check(storeKitPurchases.putIfAbsent(purchase.transactionId, purchase) == null)
}
@@ -0,0 +1,175 @@
package com.osglab.account.features.storekit
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
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.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import java.sql.DriverManager
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class StoreKitRepositoryIntegrationTest : FunSpec({
test("existing credited rows are account isolated and use stable cursor pagination") {
withStoreKitDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-19T14:00:00Z")
val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
val otherUserId = UUID.fromString("10000000-0000-0000-0000-000000000011")
val product = StoreKitProduct("3000tks", 3_000)
insertAccounts(config, listOf(userId, otherUserId), now)
val transactions = mapOf(
"a".repeat(100) to verified("2000000000001", userId, now.minusSeconds(30)),
"b".repeat(100) to verified("2000000000002", userId, now.minusSeconds(20)),
"c".repeat(100) to verified("2000000000003", userId, now.minusSeconds(20)),
"d".repeat(100) to verified("2000000000009", otherUserId, now.minusSeconds(10)),
)
val runner = ExposedBillingTransactionRunner(databaseFactory.database)
val purchaseService = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier(transactions::getValue),
transactions = runner,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
transactions.keys.take(3).forEach { purchaseService.submit(userId, it) }
purchaseService.submit(otherUserId, "d".repeat(100))
purchaseService.submit(userId, "c".repeat(100))
// A fresh service instance proves the query reads persisted V9 audit/ledger rows.
val historyService = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier { error("history must not call Apple") },
transactions = runner,
clock = Clock.fixed(now.plusSeconds(60), ZoneOffset.UTC),
)
val firstPage = historyService.listTransactions(userId, limit = 2)
val secondPage = historyService.listTransactions(
userId = userId,
limit = 2,
cursor = firstPage.nextCursor,
)
firstPage.items.map { it.transactionId } shouldContainExactly listOf(
"2000000000003",
"2000000000002",
)
secondPage.items.map { it.transactionId } shouldContainExactly
listOf("2000000000001")
secondPage.nextCursor shouldBe null
storeKitHistoryIndexColumns(config) shouldContainExactly listOf(
"user_id",
"purchased_at",
"transaction_id",
)
}
}
})
private fun verified(
transactionId: String,
userId: UUID,
purchasedAt: Instant,
) = VerifiedStoreKitTransaction(
transactionId = transactionId,
originalTransactionId = transactionId,
appAccountToken = userId,
productId = "3000tks",
environment = StoreKitEnvironment.SANDBOX,
purchasedAt = purchasedAt,
signedAt = purchasedAt.plusSeconds(1),
revokedAt = null,
)
private suspend fun withStoreKitDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
StoreKitMySqlContainer("mysql:8.4")
.withDatabaseName("osg_storekit_history_test")
.withUsername("test")
.withPassword("test")
.also(StoreKitMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 4,
)
val databaseFactory = DatabaseFactory(config)
try {
databaseFactory.database
block(config, databaseFactory)
} finally {
databaseFactory.close()
mysql?.stop()
}
}
private fun storeKitHistoryIndexColumns(config: DatabaseConfig): List<String> =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
SELECT column_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'storekit_credit_purchases'
AND index_name = 'idx_storekit_purchase_user_purchased_transaction'
ORDER BY seq_in_index
""".trimIndent()
).use { statement ->
statement.executeQuery().use { result ->
buildList {
while (result.next()) {
add(result.getString("column_name"))
}
}
}
}
}
private fun insertAccounts(
config: DatabaseConfig,
accountIds: List<UUID>,
now: Instant,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES (?, ?, ?, ?)
""".trimIndent()
).use { statement ->
accountIds.forEach { accountId ->
statement.setString(1, accountId.toString())
statement.setString(2, "test-$accountId")
statement.setTimestamp(3, java.sql.Timestamp.from(now))
statement.setTimestamp(4, java.sql.Timestamp.from(now))
statement.addBatch()
}
statement.executeBatch()
}
}
}
private class StoreKitMySqlContainer(image: String) :
MySQLContainer<StoreKitMySqlContainer>(image)
@@ -1,7 +1,9 @@
package com.osglab.account.features.storekit
import com.osglab.account.common.api.installApiStatusPages
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.installSessionAuthentication
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
@@ -10,6 +12,8 @@ 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.kotest.matchers.string.shouldNotContain
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
@@ -32,6 +36,8 @@ 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 sessionId = UUID.fromString("50000000-0000-0000-0000-000000000010")
private val accessToken = "valid-access-token"
private val signedTransaction = "s".repeat(100)
@Test
@@ -39,20 +45,31 @@ class StoreKitRoutesTest {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { token ->
token.takeIf { it == accessToken }?.let { AccountPrincipal(userId, sessionId) }
}
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { userId })
storeKitRoutes(service)
}
}
val catalog = client.get("/v1/storekit/products")
val catalog = client.get("/v1/storekit/products") {
bearerAuth(accessToken)
}
val first = client.post("/v1/storekit/transactions") {
bearerAuth(accessToken)
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
val replay = client.post("/v1/storekit/transactions") {
bearerAuth(accessToken)
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
val history = client.get("/v1/storekit/transactions") {
bearerAuth(accessToken)
}
catalog.status shouldBe HttpStatusCode.OK
catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500"""
@@ -63,25 +80,70 @@ class StoreKitRoutesTest {
first.bodyAsText() shouldContain """"replayed":false"""
replay.status shouldBe HttpStatusCode.OK
replay.bodyAsText() shouldContain """"replayed":true"""
history.status shouldBe HttpStatusCode.OK
history.bodyAsText() shouldContain
""""transactionId":"2000000000001","productId":"3000tks","creditsGranted":3000"""
history.bodyAsText() shouldContain """"purchasedAt":"2026-08-18T07:59:50Z""""
history.bodyAsText() shouldContain """"status":"credited""""
history.bodyAsText() shouldContain """"nextCursor":null"""
history.bodyAsText() shouldNotContain "signedTransaction"
history.bodyAsText() shouldNotContain "appAccountToken"
history.bodyAsText() shouldNotContain userId.toString()
}
@Test
fun `product catalog and transaction submission require authentication`() = testApplication {
fun `StoreKit endpoints require bearer authentication`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { null }
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { null })
storeKitRoutes(service)
}
}
client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized
client.get("/v1/storekit/transactions").status shouldBe HttpStatusCode.Unauthorized
client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}.status shouldBe HttpStatusCode.Unauthorized
}
@Test
fun `empty history and invalid pagination use the public response contract`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
installSessionAuthentication { AccountPrincipal(userId, sessionId) }
routing {
storeKitRoutes(service)
}
}
val empty = client.get("/v1/storekit/transactions") {
bearerAuth(accessToken)
}
empty.status shouldBe HttpStatusCode.OK
empty.bodyAsText() shouldBe """{"items":[],"nextCursor":null}"""
listOf(
"limit=0",
"limit=101",
"limit=abc",
"cursor=not-base64!",
"accountId=$userId",
).forEach { query ->
val response = client.get("/v1/storekit/transactions?$query") {
bearerAuth(accessToken)
}
response.status shouldBe HttpStatusCode.BadRequest
response.bodyAsText() shouldContain """"code":"invalid_request""""
}
}
private fun service(): StoreKitService {
val verified = VerifiedStoreKitTransaction(
transactionId = "2000000000001",
@@ -1,8 +1,10 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
@@ -11,6 +13,7 @@ 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.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
@@ -78,6 +81,7 @@ class StoreKitServiceTest : FunSpec({
replay.replayed shouldBe true
replay.balanceAfter shouldBeExactly 3_000
store.ledger shouldHaveSize 1
service.listTransactions(userId).items shouldHaveSize 1
}
test("concurrent transaction replay grants credits exactly once") {
@@ -159,4 +163,116 @@ class StoreKitServiceTest : FunSpec({
}
revokedStore.ledger shouldHaveSize 0
}
test("history is account isolated, stably sorted, and cursor paginated") {
val store = TestBillingStore()
val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011")
val transactionsByJws = mapOf(
"a".repeat(100) to transaction(
transactionId = "2000000000001",
purchasedAt = now.minusSeconds(30),
),
"b".repeat(100) to transaction(
transactionId = "2000000000002",
purchasedAt = now.minusSeconds(20),
),
"c".repeat(100) to transaction(
transactionId = "2000000000003",
purchasedAt = now.minusSeconds(20),
),
"d".repeat(100) to transaction(
transactionId = "2000000000009",
accountToken = otherUser,
purchasedAt = now.minusSeconds(10),
),
)
val historyService = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier(transactionsByJws::getValue),
transactions = store,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
transactionsByJws.keys.take(3).forEach { historyService.submit(userId, it) }
historyService.submit(otherUser, "d".repeat(100))
historyService.submit(userId, "c".repeat(100))
val firstPage = historyService.listTransactions(userId, limit = 2)
val secondPage = historyService.listTransactions(
userId = userId,
limit = 2,
cursor = firstPage.nextCursor,
)
firstPage.items.map { it.transactionId } shouldContainExactly listOf(
"2000000000003",
"2000000000002",
)
firstPage.items.map { it.balanceAfter } shouldContainExactly listOf(9_000, 6_000)
(firstPage.nextCursor != null) shouldBe true
secondPage.items.map { it.transactionId } shouldContainExactly listOf("2000000000001")
secondPage.nextCursor shouldBe null
store.storeKitPurchases.values shouldHaveSize 4
}
test("history reads purchases credited before the history endpoint exists") {
val store = TestBillingStore()
val purchaseId = UUID.fromString("30000000-0000-0000-0000-000000000001")
val ledgerEntryId = UUID.fromString("40000000-0000-0000-0000-000000000001")
val purchasedAt = Instant.parse("2026-01-01T01:02:03Z")
store.inTransaction { unit ->
unit.credits.insertLedgerEntry(
LedgerEntry(
id = ledgerEntryId,
userId = userId,
type = LedgerEntryType.STOREKIT_PURCHASE,
amountDelta = 3_000,
balanceAfter = 4_500,
idempotencyKey = "storekit:2000000000001",
referenceId = purchaseId,
createdAt = purchasedAt.plusSeconds(5),
)
)
unit.storeKit.insert(
StoreKitCreditPurchase(
id = purchaseId,
transactionId = "2000000000001",
originalTransactionId = "2000000000001",
userId = userId,
appAccountToken = userId,
productId = product.productId,
environment = StoreKitEnvironment.PRODUCTION,
creditsGranted = 3_000,
ledgerEntryId = ledgerEntryId,
signedTransactionSha256 = "a".repeat(64),
purchasedAt = purchasedAt,
signedAt = purchasedAt.plusSeconds(1),
createdAt = purchasedAt.plusSeconds(5),
)
)
}
val history = service(store).listTransactions(userId)
history.items.single().run {
transactionId shouldBe "2000000000001"
balanceAfter shouldBeExactly 4_500
this.purchasedAt shouldBe purchasedAt
}
}
test("history validates limit boundaries and rejects malformed cursors") {
val historyService = service(TestBillingStore())
shouldThrow<InvalidStoreKitRequest> {
historyService.listTransactions(userId, limit = 0)
}
shouldThrow<InvalidStoreKitRequest> {
historyService.listTransactions(userId, limit = 101)
}
shouldThrow<InvalidStoreKitRequest> {
historyService.listTransactions(userId, cursor = "not-base64!")
}
historyService.listTransactions(userId, limit = 1).items shouldHaveSize 0
historyService.listTransactions(userId, limit = 100).items shouldHaveSize 0
}
})