Add secure administrator operations console

Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
This commit is contained in:
Rocky
2026-08-17 15:20:34 +08:00
parent 676bfd2451
commit 1a9c518f96
76 changed files with 14602 additions and 3 deletions
@@ -34,6 +34,48 @@ class AppConfigTest : FunSpec({
config.database.migrationUsername shouldBe "test_migrator"
}
test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "true")
put("app.admin.bootstrapEnabled", "true")
put("app.admin.bootstrapOperatorId", "2c031def-4517-4fde-b592-5db3a3eefdf6")
put("app.admin.bootstrapUsername", "owner")
put(
"app.admin.bootstrapPasswordHash",
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdHNhbHRzYWx0c2FsdA\$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA",
)
put("app.admin.bootstrapTotpSecretBase32", "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")
}
val admin = AppConfig.from(config).admin
admin.enabled shouldBe true
admin.bootstrapEnabled shouldBe true
}
test("production accepts established admin without bootstrap credentials") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "true")
put("app.admin.bootstrapEnabled", "false")
}
val admin = AppConfig.from(config).admin
admin.enabled shouldBe true
admin.bootstrapEnabled shouldBe false
admin.bootstrapTotpSecretBase32 shouldBe null
}
test("admin bootstrap cannot be enabled while admin routes are disabled") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "false")
put("app.admin.bootstrapEnabled", "true")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
}
test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply {
put("app.apple.keyId", "")
@@ -0,0 +1,426 @@
package com.osglab.account.features.admin
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import com.osglab.account.features.admin.repositories.AdminRepository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant
import java.util.UUID
internal class InMemoryAdminRepository(
val operatorId: UUID = UUID.randomUUID(),
var username: String = "admin@example.com",
var passwordHash: String = "valid-password-hash",
var encryptedTotpSecret: String,
var role: AdminRole = AdminRole.SUPER_ADMIN,
) : AdminRepository {
private val mutex = Mutex()
var lockState = AdminLockState(0, null)
var disabledAt: Instant? = null
var lastTotpCounter: Long? = null
val sessions = mutableMapOf<String, AdminSessionRecord>()
val revokedTokenHashes = mutableSetOf<String>()
private val revokedAtByTokenHash = mutableMapOf<String, Instant>()
val audits = mutableListOf<NewAdminAuditEvent>()
private val additionalOperators = linkedMapOf<UUID, MutableOperator>()
override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean =
mutex.withLock {
if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
false
} else {
additionalOperators[operator.id] = MutableOperator(operator)
true
}
}
override suspend fun createOperator(
operator: NewAdminOperator,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
AdminOperatorMutationResult.USERNAME_CONFLICT
} else {
additionalOperators[operator.id] = MutableOperator(operator)
AdminOperatorMutationResult.SUCCESS
}
audits += auditEvent.forResult(result)
result
}
override suspend fun listOperators(): List<AdminOperatorRecord> = mutex.withLock {
listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord)
}
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
): List<AdminOperatorRecord> = mutex.withLock {
require(limit in 1..101)
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
.asSequence()
.filter {
before == null ||
it.createdAt.isAfter(before.createdAt) ||
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString())
}
.sortedWith(
compareBy<AdminOperatorRecord> { it.createdAt }
.thenBy { it.id.toString() },
)
.take(limit)
.toList()
}
override suspend fun countActiveSessions(now: Instant): Long = mutex.withLock {
sessions.count { (tokenHash, session) ->
tokenHash !in revokedTokenHashes &&
session.expiresAt.isAfter(now) &&
operatorDisabledAt(session.operatorId) == null
}.toLong()
}
override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? = mutex.withLock {
if (operatorId == this.operatorId) {
baseOperatorRecord()
} else {
additionalOperators[operatorId]?.toRecord()
}
}
override suspend fun setOperatorEnabled(
operatorId: UUID,
enabled: Boolean,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val targetRole = when (operatorId) {
this.operatorId -> role
else -> additionalOperators[operatorId]?.operator?.role
}
val result = when {
targetRole == null -> AdminOperatorMutationResult.NOT_FOUND
!enabled &&
operatorDisabledAt(operatorId) == null &&
targetRole == AdminRole.SUPER_ADMIN &&
enabledSuperAdministrators() <= 1 ->
AdminOperatorMutationResult.LAST_SUPER_ADMIN
else -> {
if (operatorId == this.operatorId) {
disabledAt = if (enabled) null else now
} else {
additionalOperators.getValue(operatorId).apply {
disabledAt = if (enabled) null else now
updatedAt = now
}
}
if (!enabled) revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
}
audits += auditEvent.forResult(result)
result
}
override suspend fun unlockOperator(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = when (operatorId) {
this.operatorId -> {
lockState = AdminLockState(0, null)
AdminOperatorMutationResult.SUCCESS
}
in additionalOperators -> {
additionalOperators.getValue(operatorId).apply {
lockState = AdminLockState(0, null)
updatedAt = now
}
AdminOperatorMutationResult.SUCCESS
}
else -> AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun resetOperatorCredentials(
operatorId: UUID,
passwordHash: String,
encryptedTotpSecret: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = when (operatorId) {
this.operatorId -> {
this.passwordHash = passwordHash
this.encryptedTotpSecret = encryptedTotpSecret
lockState = AdminLockState(0, null)
lastTotpCounter = null
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
in additionalOperators -> {
additionalOperators.getValue(operatorId).apply {
this.passwordHash = passwordHash
this.encryptedTotpSecret = encryptedTotpSecret
lockState = AdminLockState(0, null)
lastTotpCounter = null
updatedAt = now
}
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
else -> AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun revokeOperatorSessions(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = if (operatorId == this.operatorId || operatorId in additionalOperators) {
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
} else {
AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun findOperatorForAuthentication(
normalizedUsername: String,
): AdminOperatorAuthRecord? = mutex.withLock {
if (normalizedUsername == username) {
authRecord()
} else {
additionalOperators.values
.firstOrNull { it.operator.normalizedUsername == normalizedUsername }
?.toAuthRecord()
}
}
override suspend fun updateLockState(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
transform: (AdminLockState) -> AdminLockState,
): AdminLockState? = mutex.withLock {
if (operatorId != this.operatorId || disabledAt != null) return@withLock null
transform(lockState).also {
lockState = it
audits += auditEvent
}
}
override suspend fun createSessionIfTotpCounterFresh(
session: NewAdminSession,
totpCounter: Long,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = mutex.withLock {
if (session.operatorId != operatorId || disabledAt != null || lockState.isLockedAt(now)) {
return@withLock null
}
if (lastTotpCounter?.let { it >= totpCounter } == true) return@withLock null
lastTotpCounter = totpCounter
lockState = AdminLockState(0, null)
AdminSessionRecord(
id = session.id,
operatorId = operatorId,
normalizedUsername = username,
role = role,
csrfTokenHash = session.csrfTokenHash,
expiresAt = session.expiresAt,
).also {
sessions[session.tokenHash] = it
audits += auditEvent
}
}
override suspend fun findActiveSessionByTokenHash(
tokenHash: String,
now: Instant,
): AdminSessionRecord? = mutex.withLock {
if (disabledAt != null || tokenHash in revokedTokenHashes) return@withLock null
sessions[tokenHash]?.takeIf { it.expiresAt.isAfter(now) }
}
override suspend fun revokeSessionByTokenHash(
tokenHash: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = mutex.withLock {
if (tokenHash in revokedTokenHashes) return@withLock null
sessions[tokenHash]?.also {
revokedTokenHashes += tokenHash
revokedAtByTokenHash[tokenHash] = now
audits += auditEvent
}
}
override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int =
mutex.withLock {
require(limit in 1..1_000)
val candidates = sessions.entries.asSequence()
.filter { (tokenHash, session) ->
!session.expiresAt.isAfter(cutoff) ||
revokedAtByTokenHash[tokenHash]?.isAfter(cutoff) == false
}
.sortedWith(
compareBy<Map.Entry<String, AdminSessionRecord>> { it.value.expiresAt }
.thenBy { it.value.id.toString() },
)
.take(limit)
.map(Map.Entry<String, AdminSessionRecord>::key)
.toList()
candidates.forEach {
sessions.remove(it)
revokedTokenHashes.remove(it)
revokedAtByTokenHash.remove(it)
}
candidates.size
}
override suspend fun appendAudit(event: NewAdminAuditEvent) {
mutex.withLock {
audits += event
}
}
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
): List<AdminAuditRecord> =
mutex.withLock {
audits.asSequence()
.filter {
before == null ||
it.occurredAt.isBefore(before.occurredAt) ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString()
)
}
.sortedWith(
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() },
)
.take(limit)
.map {
AdminAuditRecord(
id = it.id,
actorOperatorId = it.actorOperatorId,
action = it.action,
outcome = it.outcome,
targetType = it.targetType,
targetId = it.targetId,
requestId = it.requestId,
occurredAt = it.occurredAt,
)
}
.toList()
}
suspend fun seedSession(tokenHash: String, session: AdminSessionRecord) {
mutex.withLock {
sessions[tokenHash] = session
}
}
private fun authRecord() = AdminOperatorAuthRecord(
id = operatorId,
normalizedUsername = username,
passwordHash = passwordHash,
encryptedTotpSecret = encryptedTotpSecret,
role = role,
lockState = lockState,
disabledAt = disabledAt,
)
private fun baseOperatorRecord() = AdminOperatorRecord(
id = operatorId,
normalizedUsername = username,
role = role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
private fun usernameExists(candidate: String): Boolean =
additionalOperators.values.any { it.operator.normalizedUsername == candidate }
private fun enabledSuperAdministrators(): Int =
(if (role == AdminRole.SUPER_ADMIN && disabledAt == null) 1 else 0) +
additionalOperators.values.count {
it.operator.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
}
private fun operatorDisabledAt(operatorId: UUID): Instant? =
if (operatorId == this.operatorId) disabledAt else additionalOperators[operatorId]?.disabledAt
private fun revokeSessionsFor(operatorId: UUID, now: Instant) {
sessions.filterValues { it.operatorId == operatorId }.keys.forEach {
revokedTokenHashes += it
revokedAtByTokenHash[it] = now
}
}
private class MutableOperator(
val operator: NewAdminOperator,
var passwordHash: String = operator.passwordHash,
var encryptedTotpSecret: String = operator.encryptedTotpSecret,
var lockState: AdminLockState = AdminLockState(0, null),
var lastTotpCounter: Long? = null,
var disabledAt: Instant? = null,
var updatedAt: Instant = operator.createdAt,
) {
fun toAuthRecord() = AdminOperatorAuthRecord(
id = operator.id,
normalizedUsername = operator.normalizedUsername,
passwordHash = passwordHash,
encryptedTotpSecret = encryptedTotpSecret,
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
)
fun toRecord() = AdminOperatorRecord(
id = operator.id,
normalizedUsername = operator.normalizedUsername,
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
createdAt = operator.createdAt,
updatedAt = updatedAt,
)
}
}
private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): NewAdminAuditEvent =
copy(
outcome = if (result == AdminOperatorMutationResult.SUCCESS) {
com.osglab.account.features.admin.models.AdminAuditOutcome.SUCCESS
} else {
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
},
)
@@ -0,0 +1,246 @@
package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Duration
import java.time.Instant
import java.util.UUID
class AdminOperatorRepositoryIntegrationTest : FunSpec({
test("row locks preserve one enabled super administrator under concurrent disables") {
withAdminRepositories { first, second ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val firstId = UUID.randomUUID()
val secondId = UUID.randomUUID()
first.createOperatorIfAbsent(
newOperator(firstId, "owner-${firstId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
)
first.createOperatorIfAbsent(
newOperator(secondId, "owner-${secondId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
)
val results = coroutineScope {
listOf(
async {
first.setOperatorEnabled(
firstId,
enabled = false,
now = now,
auditEvent = audit(secondId, AdminAuditAction.OPERATOR_DISABLED, firstId, now),
)
},
async {
second.setOperatorEnabled(
secondId,
enabled = false,
now = now,
auditEvent = audit(firstId, AdminAuditAction.OPERATOR_DISABLED, secondId, now),
)
},
).awaitAll()
}
results shouldContainExactlyInAnyOrder listOf(
AdminOperatorMutationResult.SUCCESS,
AdminOperatorMutationResult.LAST_SUPER_ADMIN,
)
first.listOperators().count {
it.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
} shouldBe 1
first.listAudit(10).count {
it.action == AdminAuditAction.OPERATOR_DISABLED &&
it.outcome == AdminAuditOutcome.DENIED
} shouldBe 1
}
}
test("credential reset atomically revokes active sessions") {
withAdminRepositories { repository, _ ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val ownerId = UUID.randomUUID()
val targetId = UUID.randomUUID()
val ownerUsername = "owner-${ownerId.toString().take(8)}"
val targetUsername = "target-${targetId.toString().take(8)}"
repository.createOperatorIfAbsent(
newOperator(ownerId, ownerUsername, AdminRole.SUPER_ADMIN, now),
)
repository.createOperatorIfAbsent(
newOperator(targetId, targetUsername, AdminRole.SUPPORT, now),
)
val session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = targetId,
tokenHash = "a".repeat(64),
csrfTokenHash = "b".repeat(64),
createdAt = now,
expiresAt = now.plus(Duration.ofHours(1)),
)
repository.createSessionIfTotpCounterFresh(
session = session,
totpCounter = 1,
now = now,
auditEvent = audit(targetId, AdminAuditAction.LOGIN_SUCCEEDED, targetId, now),
)
(repository.findActiveSessionByTokenHash(session.tokenHash, now) != null) shouldBe true
repository.resetOperatorCredentials(
operatorId = targetId,
passwordHash = "new-password-hash",
encryptedTotpSecret = "new-encrypted-secret",
now = now.plusSeconds(1),
auditEvent = audit(
ownerId,
AdminAuditAction.OPERATOR_CREDENTIALS_RESET,
targetId,
now.plusSeconds(1),
),
) shouldBe AdminOperatorMutationResult.SUCCESS
repository.findActiveSessionByTokenHash(session.tokenHash, now.plusSeconds(1)) shouldBe null
repository.findOperatorForAuthentication(targetUsername)?.run {
passwordHash shouldBe "new-password-hash"
encryptedTotpSecret shouldBe "new-encrypted-secret"
lockState.failedLoginCount shouldBe 0
lockState.lockedUntil shouldBe null
}
}
}
test("inactive session cleanup is bounded and preserves active sessions") {
withAdminRepositories { repository, _ ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val operatorId = UUID.randomUUID()
repository.createOperatorIfAbsent(
newOperator(operatorId, "cleanup-${operatorId.toString().take(8)}", AdminRole.SUPPORT, now),
)
val oldTokens = (1L..3L).map { counter ->
val tokenHash = counter.toString().repeat(64).take(64)
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = tokenHash,
csrfTokenHash = counter.plus(3).toString().repeat(64).take(64),
createdAt = now.minus(Duration.ofDays(10)),
expiresAt = now.minus(Duration.ofDays(9)),
),
totpCounter = counter,
now = now.minus(Duration.ofDays(10)),
auditEvent = audit(
operatorId,
AdminAuditAction.LOGIN_SUCCEEDED,
operatorId,
now.minus(Duration.ofDays(10)),
),
)
tokenHash
}
val activeToken = "f".repeat(64)
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = activeToken,
csrfTokenHash = "e".repeat(64),
createdAt = now,
expiresAt = now.plus(Duration.ofHours(1)),
),
totpCounter = 4,
now = now,
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, now),
)
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 2
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 1
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 0
oldTokens.forEach {
repository.findActiveSessionByTokenHash(it, now) shouldBe null
}
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
}
}
})
private suspend fun withAdminRepositories(
block: suspend (ExposedAdminRepository, ExposedAdminRepository) -> 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) {
AdminMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_repository_test")
.withUsername("test")
.withPassword("test")
.also(AdminMySqlContainer::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 firstFactory = DatabaseFactory(config)
val secondFactory = DatabaseFactory(config)
try {
firstFactory.database
secondFactory.database
block(ExposedAdminRepository(firstFactory), ExposedAdminRepository(secondFactory))
} finally {
secondFactory.close()
firstFactory.close()
mysql?.stop()
}
}
private fun newOperator(
id: UUID,
username: String,
role: AdminRole,
now: Instant,
) = NewAdminOperator(
id = id,
normalizedUsername = username,
passwordHash = "password-hash",
encryptedTotpSecret = "encrypted-secret",
role = role,
createdAt = now,
)
private fun audit(
actorId: UUID,
action: AdminAuditAction,
targetId: UUID,
now: Instant,
) = NewAdminAuditEvent(
actorOperatorId = actorId,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ADMIN_OPERATOR",
targetId = targetId.toString(),
occurredAt = now,
)
private class AdminMySqlContainer(image: String) :
MySQLContainer<AdminMySqlContainer>(image)
@@ -0,0 +1,331 @@
package com.osglab.account.features.admin.routes
import com.osglab.account.config.AdminConfig
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import io.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.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.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.ratelimit.RateLimit
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.test.Test
import kotlin.test.assertEquals
class AdminRoutesTest {
@Test
fun `admin api is hidden without verified edge header`() = testApplication {
application { installAdminTestRoutes() }
val response = client.get("/v1/admin/auth/session")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `verified edge can check anonymous session`() = testApplication {
application { installAdminTestRoutes() }
val response = client.get("/v1/admin/auth/session") {
header("X-OSG-mTLS-Verified", "SUCCESS")
}
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"authenticated":false"""
}
@Test
fun `authenticated session exposes role for client-side capability navigation`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val response = client.get("/v1/admin/auth/session") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"operatorName":"operator""""
response.bodyAsText() shouldContain """"role":"SUPER_ADMIN""""
}
@Test
fun `login rejects requests without exact same origin`() = testApplication {
val authService = mockk<AdminAuthService>(relaxed = true)
application { installAdminTestRoutes(authService) }
val response = client.post("/v1/admin/auth/login") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://attacker.example")
contentType(ContentType.Application.Json)
setBody("""{"username":"owner","password":"not-a-real-password","totpCode":"123456"}""")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
coVerify(exactly = 0) { authService.login(any(), any(), any(), any()) }
}
@Test
fun `admin web resources are embedded`() = testApplication {
application {
routing { adminWebRoutes() }
}
val response = client.get("/admin/")
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain "OSG 运营后台"
response.bodyAsText() shouldContain """/admin/assets/"""
}
@Test
fun `manual grant maps missing user to stable admin error`() = testApplication {
val fixture = grantRouteFixture(CreditNotFound("missing"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.NotFound, response.status)
response.bodyAsText() shouldContain """"code":"USER_NOT_FOUND""""
}
@Test
fun `manual grant maps idempotency conflict to conflict`() = testApplication {
val fixture = grantRouteFixture(CreditConflict("conflict"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.Conflict, response.status)
response.bodyAsText() shouldContain """"code":"IDEMPOTENCY_CONFLICT""""
}
@Test
fun `manual grant maps invalid domain request to validation error`() = testApplication {
val fixture = grantRouteFixture(InvalidCreditRequest("invalid"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.BadRequest, response.status)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
@Test
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPPORT)
val operatorService = mockk<AdminOperatorService>()
coEvery { operatorService.listPage(any(), any(), any()) } throws
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
application {
installAdminTestRoutes(
sessionService = sessionService,
operatorService = operatorService,
)
}
val response = client.get("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `analyst cannot access user records`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.ANALYST),
)
}
val response = client.get("/v1/admin/users") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
val operatorService = mockk<AdminOperatorService>()
coEvery {
operatorService.create(any(), any(), any(), any(), any())
} throws AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT)
application {
installAdminTestRoutes(
sessionService = sessionService,
operatorService = operatorService,
)
}
val response = client.post("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(
"""{"username":"owner","password":"long-enough-password","role":"SUPPORT"}""",
)
}
assertEquals(HttpStatusCode.Conflict, response.status)
response.bodyAsText() shouldContain """"code":"ADMIN_USERNAME_CONFLICT""""
}
@Test
fun `operator creation maps malformed body to stable validation error`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val response = client.post("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody("""{"username":"owner","password":"long-enough-password"}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
}
private fun io.ktor.server.application.Application.installAdminTestRoutes(
authService: AdminAuthService = mockk(relaxed = true),
sessionService: AdminSessionService = mockk(relaxed = true),
grantService: AdminGrantService = mockk(relaxed = true),
operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true),
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
install(RateLimit) {
register(RateLimitName("admin-auth")) {
rateLimiter(limit = 20, refillPeriod = 1.minutes)
}
}
val config = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { isProduction } returns false
every { admin } returns AdminConfig()
}
routing {
adminApiRoutes(
config = config,
authService = authService,
sessionService = sessionService,
statsService = mockk<AdminStatsService>(relaxed = true),
usersService = mockk<AdminUsersService>(relaxed = true),
grantService = grantService,
operatorService = operatorService,
auditService = auditService,
)
}
}
private fun grantRouteFixture(
failure: RuntimeException,
): Pair<AdminSessionService, AdminGrantService> {
val sessionService = mockk<AdminSessionService>()
val grantService = mockk<AdminGrantService>()
coEvery {
sessionService.authenticateMutation("session-token", "csrf-token")
} returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
coEvery { grantService.grant(any()) } throws failure
return sessionService to grantService
}
private fun sessionFixture(role: AdminRole): AdminSessionService =
mockk<AdminSessionService>().also {
coEvery { it.authenticate("session-token") } returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "operator",
role = role,
)
coEvery { it.authenticateMutation("session-token", "csrf-token") } returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "operator",
role = role,
)
}
private suspend fun io.ktor.client.HttpClient.postGrant() =
post("/v1/admin/credits/grants") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
header("Idempotency-Key", "grant-request-1")
contentType(ContentType.Application.Json)
setBody(
"""{"userId":"5a33af2f-a878-43c0-8315-31729402b7cd","amount":100,"reason":"support credit"}""",
)
}
@@ -0,0 +1,37 @@
package com.osglab.account.features.admin.security
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldStartWith
class AdminPasswordHasherTest : FunSpec({
val hasher = BouncyCastleArgon2idPasswordHasher(
Argon2idConfig(memoryKb = 1_024, iterations = 2, parallelism = 1),
)
test("Argon2id hashes verify the correct password and use unique salts") {
val password = "correct horse battery staple".toCharArray()
val first = hasher.hash(password)
val second = hasher.hash(password)
first.shouldStartWith("\$argon2id\$v=19\$")
(first != second) shouldBe true
hasher.verify(password, first) shouldBe true
hasher.verify("wrong password".toCharArray(), first) shouldBe false
}
test("verification rejects malformed and excessive work factors") {
hasher.verify("password".toCharArray(), "not-a-phc-hash") shouldBe false
hasher.verify(
"password".toCharArray(),
"\$argon2id\$v=19\$m=999999999,t=3,p=1\$MTIzNDU2Nzg5MDEyMzQ1Ng\$" +
"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY",
) shouldBe false
}
test("hashing rejects empty and oversized passwords") {
shouldThrow<IllegalArgumentException> { hasher.hash(charArrayOf()) }
shouldThrow<IllegalArgumentException> { hasher.hash(CharArray(1_025) { 'a' }) }
}
})
@@ -0,0 +1,54 @@
package com.osglab.account.features.admin.security
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.time.Instant
class AdminTotpTest : FunSpec({
val secret = "12345678901234567890".toByteArray()
test("TOTP generation matches RFC 6238 SHA-1 vectors") {
val verifier = HmacTotpVerifier(digits = 8, allowedWindow = 0)
verifier.generate(secret, Instant.ofEpochSecond(59)) shouldBe "94287082"
verifier.generate(secret, Instant.ofEpochSecond(1_111_111_109)) shouldBe "07081804"
}
test("verification accepts only the configured adjacent time window") {
val verifier = HmacTotpVerifier(digits = 6, allowedWindow = 1)
val previousStep = Instant.ofEpochSecond(1_700_000_010)
val currentStep = previousStep.plusSeconds(30)
val code = verifier.generate(secret, previousStep)
verifier.verify(secret, code, currentStep) shouldBe previousStep.epochSecond / 30
verifier.verify(secret, code, currentStep.plusSeconds(30)) shouldBe null
}
test("verification rejects malformed codes and secrets") {
val verifier = HmacTotpVerifier()
verifier.verify(secret, "12345x", Instant.EPOCH.plusSeconds(60)) shouldBe null
verifier.verify(ByteArray(15), "123456", Instant.EPOCH.plusSeconds(60)) shouldBe null
}
test("Base32 decoder accepts canonical secrets and rejects trailing bits") {
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
.contentEquals(secret) shouldBe true
shouldThrow<IllegalArgumentException> {
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQB")
}
}
test("provisioning generator returns a 160-bit secret without exposing it in logs") {
val provisioning = SecureAdminTotpSecretGenerator().generate("support.agent")
provisioning.secretBase32.length shouldBe 32
Base32TotpSecret.decode(provisioning.secretBase32).size shouldBe 20
provisioning.otpauthUri shouldContain "otpauth://totp/OSGKeyboard%3Asupport.agent"
provisioning.otpauthUri shouldContain "secret=${provisioning.secretBase32}"
provisioning.toString() shouldNotContain provisioning.secretBase32
}
})
@@ -0,0 +1,87 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import java.time.Instant
import java.util.UUID
class AdminAuditServiceTest : FunSpec({
test("page cursor continues after the final visible audit record") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
val records = listOf(
auditRecord("2026-08-17T00:00:03Z"),
auditRecord("2026-08-17T00:00:02Z"),
auditRecord("2026-08-17T00:00:01Z"),
)
coEvery { repository.listAudit(3, null) } returns records
coEvery { repository.listOperators() } returns emptyList()
val firstPage = service.list(superAdministrator(), null, limit = 2)
firstPage.items.size shouldBe 2
firstPage.nextCursor.isNullOrBlank() shouldBe false
coEvery { repository.listAudit(3, any()) } returns emptyList()
service.list(superAdministrator(), firstPage.nextCursor, limit = 2)
coVerify {
repository.listAudit(
3,
match {
it.occurredAt == records[1].occurredAt &&
it.id == records[1].id
},
)
}
}
test("malformed cursor is rejected before querying audit records") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
shouldThrow<AdminAuditCursorException> {
service.list(superAdministrator(), "not-a-valid-cursor")
}
coVerify(exactly = 0) { repository.listAudit(any(), any()) }
}
test("non-super administrator cannot list audit records") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
val support = superAdministrator().copy(role = AdminRole.SUPPORT)
shouldThrow<AdminOperatorException> {
service.list(support, null)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
}
})
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
id = UUID.randomUUID(),
actorOperatorId = UUID.randomUUID(),
action = AdminAuditAction.LOGIN_SUCCEEDED,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ADMIN_OPERATOR",
targetId = "target",
requestId = "request-1",
occurredAt = Instant.parse(occurredAt),
)
private fun superAdministrator() = AdminPrincipal(
operatorId = UUID.randomUUID(),
sessionId = UUID.randomUUID(),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
@@ -0,0 +1,226 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class AdminAuthServiceTest : FunSpec({
test("successful login persists only token hashes and records an audit event") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val result = fixture.service.login(
username = " ADMIN@EXAMPLE.COM ",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
requestId = "request-1",
) as AdminLoginResult.Authenticated
val sessionHash = TokenHash.sha256(result.credentials.sessionToken)
fixture.repository.sessions.containsKey(sessionHash) shouldBe true
fixture.repository.sessions.containsKey(result.credentials.sessionToken) shouldBe false
TokenHash.matches(
result.credentials.csrfToken,
fixture.repository.sessions.getValue(sessionHash).csrfTokenHash,
) shouldBe true
fixture.repository.audits.single().action shouldBe AdminAuditAction.LOGIN_SUCCEEDED
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.SUCCESS
result.credentials.toString().contains(result.credentials.sessionToken) shouldBe false
}
test("same TOTP counter is accepted once under concurrent login") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val results = coroutineScope {
List(2) {
async {
fixture.service.login(
username = "admin@example.com",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
)
}
}.awaitAll()
}
results.count { it is AdminLoginResult.Authenticated } shouldBe 1
results.count { it is AdminLoginResult.InvalidCredentials } shouldBe 1
fixture.repository.sessions.size shouldBe 1
}
test("failed credentials lock at the threshold") {
val fixture = authFixture(
lockPolicy = AdminLoginLockPolicy(
maxFailedAttempts = 3,
lockDuration = Duration.ofMinutes(10),
),
)
repeat(2) {
fixture.service.login(
"admin@example.com",
"wrong".toCharArray(),
"000000",
) shouldBe AdminLoginResult.InvalidCredentials
}
val third = fixture.service.login(
"admin@example.com",
"wrong".toCharArray(),
"000000",
)
third shouldBe AdminLoginResult.Locked(fixture.clock.instant().plusSeconds(600))
fixture.repository.lockState.failedLoginCount shouldBe 0
}
test("lock expiry boundary starts a fresh failure sequence") {
val policy = AdminLoginLockPolicy(
maxFailedAttempts = 3,
lockDuration = Duration.ofMinutes(10),
)
val lockEndsAt = Instant.parse("2026-08-16T01:00:00Z")
val next = policy.afterFailure(
AdminLockState(failedLoginCount = 0, lockedUntil = lockEndsAt),
lockEndsAt,
)
next shouldBe AdminLockState(failedLoginCount = 1, lockedUntil = null)
}
test("unknown users execute dummy password verification") {
val fixture = authFixture()
var verifiedHash: String? = null
val service = fixture.serviceWithHasher(
object : AdminPasswordHasher {
override fun hash(password: CharArray): String = error("Not used")
override fun verify(password: CharArray, encodedHash: String): Boolean {
verifiedHash = encodedHash
return false
}
},
)
service.login("unknown@example.com", "guess".toCharArray(), "000000") shouldBe
AdminLoginResult.InvalidCredentials
verifiedHash shouldBe DUMMY_HASH
}
test("invalid external request ID is omitted instead of failing login") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val result = fixture.service.login(
username = "admin@example.com",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
requestId = "invalid request id with spaces",
)
(result is AdminLoginResult.Authenticated) shouldBe true
fixture.repository.audits.single().requestId shouldBe null
}
})
private data class AuthFixture(
val repository: InMemoryAdminRepository,
val service: AdminAuthService,
val totp: HmacTotpVerifier,
val encryptor: FieldEncryptor,
val clock: MutableClock,
val lockPolicy: AdminLoginLockPolicy,
val tokenGenerator: SecureTokenGenerator,
) {
fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService(
repository = repository,
passwordHasher = hasher,
dummyPasswordHash = DUMMY_HASH,
totpVerifier = totp,
fieldEncryptor = encryptor,
sessionTtl = Duration.ofHours(1),
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
clock = clock,
)
}
private fun authFixture(
lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(),
): AuthFixture {
val operatorId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 7 })
val encryptedSecret = encryptor.encrypt(TOTP_SECRET_BASE32, adminTotpContext(operatorId))
val repository = InMemoryAdminRepository(
operatorId = operatorId,
encryptedTotpSecret = encryptedSecret,
)
val totp = HmacTotpVerifier()
val clock = MutableClock(Instant.parse("2026-08-16T00:00:10Z"))
val tokenGenerator = CountingTokenGenerator()
val fixture = AuthFixture(
repository = repository,
service = AdminAuthService(
repository = repository,
passwordHasher = TestPasswordHasher,
dummyPasswordHash = DUMMY_HASH,
totpVerifier = totp,
fieldEncryptor = encryptor,
sessionTtl = Duration.ofHours(1),
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
clock = clock,
),
totp = totp,
encryptor = encryptor,
clock = clock,
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
)
return fixture
}
private data object TestPasswordHasher : AdminPasswordHasher {
override fun hash(password: CharArray): String = error("Not used")
override fun verify(password: CharArray, encodedHash: String): Boolean =
encodedHash == "valid-password-hash" && password.concatToString() == CORRECT_PASSWORD
}
private class CountingTokenGenerator : SecureTokenGenerator {
private val counter = AtomicInteger()
override fun newRefreshToken(): String = "test-token-${counter.incrementAndGet()}"
}
private class MutableClock(
var current: Instant,
) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = current
}
private const val CORRECT_PASSWORD = "correct-password"
private const val DUMMY_HASH = "dummy-password-hash"
private const val TOTP_SECRET_BASE32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
private val TOTP_SECRET_BYTES = "12345678901234567890".toByteArray()
@@ -0,0 +1,54 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.runBlocking
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.Test
class AdminBootstrapServiceTest {
@Test
fun `bootstrap encrypts TOTP secret and creates owner idempotently`() = runBlocking {
val repository = mockk<AdminRepository>()
val operator = slot<NewAdminOperator>()
coEvery { repository.createOperatorIfAbsent(capture(operator)) } returns true
val encryptor = FieldEncryptor(ByteArray(32) { 9 })
val service = AdminBootstrapService(
repository,
encryptor,
Clock.fixed(Instant.parse("2026-08-16T00:00:00Z"), ZoneOffset.UTC),
)
val operatorId = UUID.fromString("2c031def-4517-4fde-b592-5db3a3eefdf6")
service.initialize(
AdminBootstrapConfig(
enabled = true,
operatorId = operatorId,
username = "Owner",
passwordHash = VALID_PASSWORD_HASH,
totpSecretBase32 = TOTP_SECRET,
),
) shouldBe true
operator.captured.normalizedUsername shouldBe "owner"
operator.captured.passwordHash shouldBe VALID_PASSWORD_HASH
encryptor.decrypt(
operator.captured.encryptedTotpSecret,
adminTotpContext(operatorId),
) shouldBe TOTP_SECRET
coVerify(exactly = 1) { repository.createOperatorIfAbsent(any()) }
}
}
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
private const val VALID_PASSWORD_HASH =
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdA\$aGFzaA"
@@ -0,0 +1,284 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldNotContain
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class AdminOperatorServiceTest : FunSpec({
test("super administrator creates normalized operator with one-time encrypted TOTP") {
val fixture = operatorFixture()
val password = "long-enough-password".toCharArray()
val created = fixture.service.create(
fixture.owner,
" Support.Agent ",
password,
"SUPPORT",
"operator-create-1",
)
created.operator?.normalizedUsername shouldBe "support.agent"
created.operator?.role shouldBe AdminRole.SUPPORT
created.totpSecret shouldBe TOTP_SECRET
created.toString() shouldNotContain TOTP_SECRET
password.all { it == '\u0000' } shouldBe true
val stored = fixture.repository.findOperatorForAuthentication("support.agent")
fixture.encryptor.decrypt(
requireNotNull(stored).encryptedTotpSecret,
adminTotpContext(stored.id),
) shouldBe TOTP_SECRET
fixture.repository.audits.last().run {
action shouldBe AdminAuditAction.OPERATOR_CREATED
outcome shouldBe AdminAuditOutcome.SUCCESS
}
}
test("duplicate normalized username returns stable conflict and denied audit") {
val fixture = operatorFixture()
fixture.service.create(
fixture.owner,
"duplicate",
"long-enough-password".toCharArray(),
"ANALYST",
)
shouldThrow<AdminOperatorException> {
fixture.service.create(
fixture.owner,
" DUPLICATE ",
"another-long-password".toCharArray(),
"SUPPORT",
)
}.code shouldBe AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
}
test("self disable is rejected and audited") {
val fixture = operatorFixture()
shouldThrow<AdminOperatorException> {
fixture.service.setEnabled(fixture.owner, fixture.owner.operatorId, enabled = false)
}.code shouldBe AdminOperatorErrorCode.CANNOT_DISABLE_SELF
fixture.repository.disabledAt shouldBe null
fixture.repository.audits.single().run {
action shouldBe AdminAuditAction.OPERATOR_DISABLED
outcome shouldBe AdminAuditOutcome.DENIED
}
}
test("repository rejects disabling the final enabled super administrator") {
val fixture = operatorFixture()
val secondSuper = fixture.seedOperator("second-owner", AdminRole.SUPER_ADMIN)
fixture.service.setEnabled(fixture.owner, secondSuper, enabled = false)
val disabledPrincipal = fixture.owner.copy(operatorId = secondSuper)
shouldThrow<AdminOperatorException> {
fixture.service.setEnabled(disabledPrincipal, fixture.owner.operatorId, enabled = false)
}.code shouldBe AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED
fixture.repository.disabledAt shouldBe null
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
}
test("credential reset clears lock state and revokes every target session") {
val fixture = operatorFixture()
val targetId = fixture.seedOperator("reset-target", AdminRole.SUPPORT)
val session = AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = targetId,
normalizedUsername = "reset-target",
role = AdminRole.SUPPORT,
csrfTokenHash = "csrf-hash",
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
)
fixture.repository.seedSession("target-session-hash", session)
val password = "replacement-password".toCharArray()
val credentials = fixture.service.resetCredentials(
fixture.owner,
targetId,
password,
"credentials-reset-1",
)
credentials.totpSecret shouldBe TOTP_SECRET
password.all { it == '\u0000' } shouldBe true
fixture.repository.revokedTokenHashes shouldBe setOf("target-session-hash")
fixture.repository.findOperatorForAuthentication("reset-target")?.lockState shouldBe
AdminLockState(0, null)
fixture.repository.audits.last().run {
action shouldBe AdminAuditAction.OPERATOR_CREDENTIALS_RESET
outcome shouldBe AdminAuditOutcome.SUCCESS
}
}
test("security summary counts enabled operators and active sessions") {
val fixture = operatorFixture()
val targetId = fixture.seedOperator("support-summary", AdminRole.SUPPORT)
fixture.repository.seedSession(
"summary-session-hash",
AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = targetId,
normalizedUsername = "support-summary",
role = AdminRole.SUPPORT,
csrfTokenHash = "csrf-hash",
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
),
)
val summary = fixture.service.summary(fixture.owner)
summary.enabledOperators shouldBe 2
summary.lockedOperators shouldBe 0
summary.activeSessions shouldBe 1
}
test("non-super administrator cannot mutate operators and denial is audited") {
val fixture = operatorFixture()
val support = fixture.owner.copy(role = AdminRole.SUPPORT)
shouldThrow<AdminOperatorException> {
fixture.service.unlock(support, fixture.owner.operatorId)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.DENIED
}
test("username role and minimum password boundaries are validated") {
val fixture = operatorFixture()
val invalidRequests = listOf(
Triple("ab", "long-enough-password", "SUPPORT"),
Triple("valid-name", "elevenchars", "SUPPORT"),
Triple("valid-name", "long-enough-password", "OWNER"),
)
invalidRequests.forEach { (username, password, role) ->
shouldThrow<AdminOperatorException> {
fixture.service.create(
fixture.owner,
username,
password.toCharArray(),
role,
)
}.code shouldBe AdminOperatorErrorCode.VALIDATION_ERROR
}
fixture.repository.listOperators() shouldHaveSize 1
fixture.repository.audits.all { it.outcome == AdminAuditOutcome.DENIED } shouldBe true
}
test("operator cursor pagination is stable across equal timestamps") {
val fixture = operatorFixture()
fixture.seedOperator("operator-a", AdminRole.SUPPORT)
fixture.seedOperator("operator-b", AdminRole.ANALYST)
fixture.seedOperator("operator-c", AdminRole.SUPPORT)
val expected = fixture.repository.listOperators()
.sortedWith(compareBy<AdminOperatorRecord> { it.createdAt }.thenBy { it.id.toString() })
val first = fixture.service.listPage(fixture.owner, cursor = null, limit = 2)
val second = fixture.service.listPage(
fixture.owner,
cursor = requireNotNull(first.nextCursor),
limit = 2,
)
(first.items + second.items).map { it.id } shouldContainExactly expected.map { it.id }
second.nextCursor shouldBe null
}
test("invalid operator cursor returns a stable cursor error") {
val fixture = operatorFixture()
shouldThrow<AdminOperatorCursorException> {
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
}
}
})
private data class OperatorFixture(
val repository: InMemoryAdminRepository,
val service: AdminOperatorService,
val owner: AdminPrincipal,
val encryptor: FieldEncryptor,
val clock: Clock,
) {
suspend fun seedOperator(username: String, role: AdminRole): UUID {
val id = UUID.randomUUID()
repository.createOperatorIfAbsent(
NewAdminOperator(
id = id,
normalizedUsername = username,
passwordHash = "seed-hash",
encryptedTotpSecret = "seed-encrypted-secret",
role = role,
createdAt = clock.instant(),
),
)
return id
}
}
private fun operatorFixture(): OperatorFixture {
val ownerId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 4 })
val repository = InMemoryAdminRepository(
operatorId = ownerId,
encryptedTotpSecret = "owner-encrypted-secret",
)
val clock = Clock.fixed(Instant.parse("2026-08-17T00:00:00Z"), ZoneOffset.UTC)
val generator = AdminTotpSecretGenerator {
AdminTotpProvisioning(
secretBase32 = TOTP_SECRET,
otpauthUri = "otpauth://totp/OSGKeyboard:test?secret=$TOTP_SECRET",
)
}
return OperatorFixture(
repository = repository,
service = AdminOperatorService(
repository = repository,
passwordHasher = RecordingPasswordHasher,
fieldEncryptor = encryptor,
totpSecretGenerator = generator,
clock = clock,
),
owner = AdminPrincipal(
operatorId = ownerId,
sessionId = UUID.randomUUID(),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
),
encryptor = encryptor,
clock = clock,
)
}
private data object RecordingPasswordHasher : AdminPasswordHasher {
override fun hash(password: CharArray): String = "hash:${password.concatToString()}"
override fun verify(password: CharArray, encodedHash: String): Boolean = false
}
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
@@ -0,0 +1,131 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
class AdminSessionServiceTest : FunSpec({
test("active session authenticates and mutation requires matching CSRF token") {
val fixture = sessionFixture()
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
fixture.service.authenticateMutation(SESSION_TOKEN, CSRF_TOKEN)?.sessionId shouldBe
fixture.sessionId
fixture.service.authenticateMutation(SESSION_TOKEN, "wrong-csrf") shouldBe null
}
test("session is expired at the exact expiry boundary") {
val fixture = sessionFixture()
fixture.clock.current = fixture.expiresAt
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
}
test("revocation is idempotently denied after the first success") {
val fixture = sessionFixture()
fixture.service.revoke(SESSION_TOKEN, "wrong-csrf", "request-2") shouldBe false
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe true
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe false
fixture.repository.audits.single().action shouldBe AdminAuditAction.SESSION_REVOKED
}
test("malformed token is rejected before repository lookup") {
val fixture = sessionFixture()
fixture.service.authenticate("") shouldBe null
fixture.service.authenticate("x".repeat(513)) shouldBe null
}
test("invalid external request ID is omitted when revoking a session") {
val fixture = sessionFixture()
fixture.service.revoke(
SESSION_TOKEN,
CSRF_TOKEN,
"invalid request id with spaces",
) shouldBe true
fixture.repository.audits.single().requestId shouldBe null
}
test("cleanup removes only sessions inactive beyond retention") {
val fixture = sessionFixture()
val oldToken = "expired-session-token"
fixture.repository.seedSession(
TokenHash.sha256(oldToken),
AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = fixture.repository.operatorId,
normalizedUsername = "admin@example.com",
role = AdminRole.SUPPORT,
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
expiresAt = fixture.clock.instant().minus(Duration.ofDays(8)),
),
)
fixture.service.cleanupInactive(Duration.ofDays(7), limit = 10) shouldBe 1
fixture.repository.sessions.size shouldBe 1
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
}
})
private data class SessionFixture(
val repository: InMemoryAdminRepository,
val service: AdminSessionService,
val clock: SessionTestClock,
val sessionId: UUID,
val expiresAt: Instant,
)
private suspend fun sessionFixture(): SessionFixture {
val operatorId = UUID.randomUUID()
val repository = InMemoryAdminRepository(
operatorId = operatorId,
encryptedTotpSecret = "not-used",
)
val now = Instant.parse("2026-08-16T00:00:00Z")
val clock = SessionTestClock(now)
val sessionId = UUID.randomUUID()
val expiresAt = now.plusSeconds(60)
repository.seedSession(
TokenHash.sha256(SESSION_TOKEN),
AdminSessionRecord(
id = sessionId,
operatorId = operatorId,
normalizedUsername = "admin@example.com",
role = AdminRole.SUPPORT,
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
expiresAt = expiresAt,
),
)
return SessionFixture(
repository = repository,
service = AdminSessionService(repository, clock = clock),
clock = clock,
sessionId = sessionId,
expiresAt = expiresAt,
)
}
private class SessionTestClock(
var current: Instant,
) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = current
}
private const val SESSION_TOKEN = "opaque-session-token"
private const val CSRF_TOKEN = "opaque-csrf-token"
@@ -0,0 +1,71 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Instant
class AdminStatsRepositoryIntegrationTest : FunSpec({
test("MySQL executes every aggregate query without loading entity rows") {
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) {
StatsMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_stats_test")
.withUsername("test")
.withPassword("test")
.also(StatsMySqlContainer::start)
} else {
null
}
val factory = DatabaseFactory(
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 = 2,
),
)
try {
factory.database
val snapshot = ExposedAdminStatsRepository(factory).load(
AdminStatsRange(
from = Instant.parse("2026-08-10T12:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
)
// A dedicated container starts empty, so every scalar and grouped aggregate is explicit.
if (mysql != null) {
snapshot.overview.totalUsers shouldBeExactly 0
snapshot.overview.totalCreditBalance shouldBeExactly 0
snapshot.overview.activeUsers shouldBeExactly 0
snapshot.referralFunnel.pendingBindings shouldBeExactly 0
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 0
snapshot.registrationsByDate shouldBe emptyMap()
snapshot.referralRanking.shouldBeEmpty()
snapshot.usage.shouldBeEmpty()
}
} finally {
factory.close()
mysql?.stop()
}
}
})
private class StatsMySqlContainer(image: String) :
MySQLContainer<StatsMySqlContainer>(image)
@@ -0,0 +1,162 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot
import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong
import com.osglab.account.features.admin.stats.services.AdminStatsService
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.math.BigDecimal
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
class AdminStatsRepositoryTest : FunSpec({
val from = Instant.parse("2026-08-15T00:00:00Z")
val until = Instant.parse("2026-08-17T00:00:00Z")
test("aggregated rows preserve referral ranking and usage output") {
val snapshot = assembleAdminStats(
AdminStatsAggregates(
overview = AdminOverviewDto(
totalUsers = 20,
registrations = 4,
activeUsers = 3,
totalCreditBalance = 500,
issuedCredits = 130,
consumedCredits = 25,
),
registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4),
issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 5,
bindings = 7,
rewardedBindings = 4,
pendingBindings = 2,
ineligibleBindings = 1,
),
referralBindingsByInviter = listOf(
ReferralBindingAggregateRow("user-b", invitedUsers = 3, rewardedUsers = 1),
ReferralBindingAggregateRow("user-c", invitedUsers = 0, rewardedUsers = 1),
ReferralBindingAggregateRow("user-a", invitedUsers = 3, rewardedUsers = 2),
),
referralCreditsByInviter = mapOf(
"user-a" to 60,
"user-b" to 30,
"ledger-only-user" to 90,
),
usage = listOf(
AdminUsageAggregateDto(
kind = "LLM",
requests = 2,
chargedCredits = 20,
asrMillis = 0,
inputTokens = 100,
outputTokens = 50,
),
AdminUsageAggregateDto(
kind = "ASR",
requests = 1,
chargedCredits = 5,
asrMillis = 500,
inputTokens = 0,
outputTokens = 0,
),
),
),
)
snapshot.referralFunnel.pendingBindings shouldBeExactly 2
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 1
snapshot.referralRanking.map { it.userId } shouldBe listOf("user-a", "user-b", "user-c")
snapshot.referralRanking.first().earnedCredits shouldBeExactly 60
snapshot.referralRanking.last().let {
it.invitedUsers shouldBeExactly 0
it.rewardedUsers shouldBeExactly 1
it.earnedCredits shouldBeExactly 0
}
snapshot.referralRanking.none { it.userId == "ledger-only-user" } shouldBe true
snapshot.usage.map { it.kind } shouldBe listOf("ASR", "LLM")
snapshot.usage.first().asrMillis shouldBeExactly 500
snapshot.usage.last().inputTokens shouldBeExactly 100
}
test("service keeps exact 7 30 and 90 day buckets and serializes new outputs") {
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 0,
bindings = 0,
rewardedBindings = 0,
pendingBindings = 3,
ineligibleBindings = 2,
),
referralRanking = emptyList(),
usage = listOf(
AdminUsageAggregateDto(
kind = "ASR",
requests = 4,
chargedCredits = 12,
asrMillis = 1_200,
inputTokens = 0,
outputTokens = 0,
),
),
)
val capturedRanges = mutableListOf<AdminStatsRange>()
val service = AdminStatsService(
AdminStatsRepository { range ->
capturedRanges += range
snapshot
},
)
listOf(7L, 30L, 90L).forEach { days ->
val result = service.get(until.minus(Duration.ofDays(days)), until)
result.registrationTrend shouldHaveSize days.toInt()
result.creditFlow shouldHaveSize days.toInt()
capturedRanges.last() shouldBe AdminStatsRange(
until.minus(Duration.ofDays(days)),
until,
)
}
val result = service.get(from, until)
val json = Json.encodeToJsonElement(result).jsonObject
json["referralFunnel"]!!.jsonObject["pendingBindings"]!!.jsonPrimitive.content shouldBe "3"
json["referralFunnel"]!!.jsonObject["ineligibleBindings"]!!.jsonPrimitive.content shouldBe "2"
json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4"
}
test("database decimal aggregates require an exact Long representation") {
BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE
BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE
shouldThrow<ArithmeticException> {
BigDecimal.valueOf(Long.MAX_VALUE).add(BigDecimal.ONE).toExactLong()
}
shouldThrow<ArithmeticException> {
BigDecimal("1.5").toExactLong()
}
}
})
@@ -0,0 +1,229 @@
package com.osglab.account.features.admin.users
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class AdminUsersServiceTest : FunSpec({
test("detail preserves referral code usage referral and recent ledger data") {
val userId = UUID.randomUUID()
val detail = AdminUserDetailDto(
summary = summary(userId, Instant.parse("2026-08-15T00:00:00Z")),
referralCode = "OSG-REFERRAL",
usage = listOf(
AdminUsageAggregateDto(
kind = "LLM",
requests = 2,
chargedCredits = 8,
asrMillis = 0,
inputTokens = 20,
outputTokens = 10,
),
),
referral = AdminUserReferralDto(
inviterUserId = UUID.randomUUID().toString(),
invitedUsers = 3,
rewardedInvites = 2,
),
recentLedger = listOf(
ledgerEntry(
id = UUID.randomUUID(),
createdAt = Instant.parse("2026-08-15T01:00:00Z"),
),
),
)
val service = AdminUsersService(
PagingUsersRepository(
users = emptyList(),
details = mapOf(userId to detail),
),
)
service.detail(userId) shouldBe detail
}
test("ledger returns a page and continuation cursor") {
val userId = UUID.randomUUID()
val entries = listOf(
ledgerEntry(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
Instant.parse("2026-08-15T03:00:00Z"),
),
ledgerEntry(
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
Instant.parse("2026-08-15T02:00:00Z"),
),
ledgerEntry(
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
Instant.parse("2026-08-15T01:00:00Z"),
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(userId, limit = 2)
page.items shouldBe entries.take(2)
page.nextCursor.shouldNotBeNull()
}
test("ledger cursor pagination is stable for equal timestamps") {
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val ids = listOf(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
)
val userId = UUID.randomUUID()
val entries = ids.map { ledgerEntry(it, createdAt) }
val repository = PagingUsersRepository(
users = emptyList(),
ledger = mapOf(userId to entries),
)
val service = AdminUsersService(repository)
val first = service.ledger(userId, limit = 2)
val second = service.ledger(
userId,
limit = 2,
cursor = first.nextCursor.shouldNotBeNull(),
)
first.items.map { it.id } shouldBe ids.take(2).map(UUID::toString)
second.items.map { it.id } shouldBe listOf(ids.last().toString())
second.nextCursor shouldBe null
}
test("invalid ledger cursor throws a stable illegal argument exception") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
val failure = shouldThrow<IllegalArgumentException> {
service.ledger(UUID.randomUUID(), cursor = "not-a-cursor")
}
failure.message shouldBe "User ledger cursor is invalid"
}
test("ledger last page has no continuation cursor") {
val userId = UUID.randomUUID()
val entries = listOf(
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(userId, limit = 2)
page.items shouldHaveSize 2
page.nextCursor shouldBe null
}
test("ledger rejects a missing user instead of returning an empty page") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
shouldThrow<AdminUserNotFoundException> {
service.ledger(UUID.randomUUID())
}
}
test("invalid user cursor and missing detail fail without returning user data") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
shouldThrow<IllegalArgumentException> {
service.list(cursor = "not-a-cursor")
}
shouldThrow<AdminUserNotFoundException> {
service.detail(UUID.randomUUID())
}
}
})
private class PagingUsersRepository(
private val users: List<AdminUserSummaryDto>,
private val details: Map<UUID, AdminUserDetailDto> = emptyMap(),
private val ledger: Map<UUID, List<AdminUserLedgerEntryDto>> = emptyMap(),
) : AdminUsersRepository {
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
): List<AdminUserSummaryDto> =
users.filter {
cursor == null ||
Instant.parse(it.createdAt) < cursor.createdAt ||
(
Instant.parse(it.createdAt) == cursor.createdAt &&
UUID.fromString(it.id).toString() < cursor.userId.toString()
)
}.take(limit)
override suspend fun findDetail(
userId: UUID,
ledgerLimit: Int,
): AdminUserDetailDto? = details[userId]
override suspend fun exists(userId: UUID): Boolean =
userId in details || userId in ledger || users.any { it.id == userId.toString() }
override suspend fun listLedger(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> =
ledger[userId].orEmpty()
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
)
.take(limit)
}
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
id = id.toString(),
createdAt = createdAt.toString(),
antiAbuseRestricted = false,
creditBalance = 0,
consumedCredits = 0,
manualGrantedCredits = 0,
usageRequests = 0,
lastActiveAt = null,
invitedUsers = 0,
rewardedInvites = 0,
)
private fun ledgerEntry(
id: UUID,
createdAt: Instant,
) = AdminUserLedgerEntryDto(
id = id.toString(),
type = "MANUAL_GRANT",
amountDelta = 10,
balanceAfter = 10,
referenceId = null,
createdAt = createdAt.toString(),
)
@@ -80,6 +80,161 @@ class CreditServiceTest : FunSpec({
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
}
test("manual grant appends one linked audit and ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val result = service.grantManual(
operatorId = TEST_OPERATOR_ID,
userId = userId,
credits = 250,
reason = "Customer support adjustment",
requestId = "support-ticket-1042",
idempotencyKey = "manual-grant-key-001",
)
result.replayed shouldBe false
result.balanceAfter shouldBeExactly 250
store.balance(userId) shouldBeExactly 250
store.manualGrants.single() shouldBe result.grant
store.adminAudits.single().id shouldBe result.grant.auditLogId
store.ledger.single { it.type == LedgerEntryType.MANUAL_GRANT }.let { ledger ->
ledger.referenceId shouldBe result.grant.id
ledger.id shouldBe result.grant.ledgerEntryId
}
}
test("manual grant replay is stable and rejects changed parameters") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val first = service.grantManual(
TEST_OPERATOR_ID,
userId,
100,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
val replay = service.grantManual(
TEST_OPERATOR_ID,
userId,
100,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
replay.grant shouldBe first.grant
replay.replayed shouldBe true
shouldThrow<CreditConflict> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
101,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
}
store.balance(userId) shouldBeExactly 100
store.manualGrants shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
}
test("manual grant validates positive amount and rolls back an audit failure") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
shouldThrow<InvalidCreditRequest> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
0,
"Invalid adjustment",
"support-ticket-1044",
"manual-grant-key-003",
)
}
store.failNextManualGrantInsert = true
shouldThrow<IllegalStateException> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
50,
"Rollback adjustment",
"support-ticket-1045",
"manual-grant-key-004",
)
}
store.balance(userId) shouldBeExactly 0
store.manualGrants shouldHaveSize 0
store.adminAudits shouldHaveSize 0
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 0
}
test("concurrent manual grant replay credits exactly once") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val results = coroutineScope {
List(8) {
async(Dispatchers.Default) {
service.grantManual(
TEST_OPERATOR_ID,
userId,
75,
"Concurrent adjustment",
"support-ticket-1046",
"manual-grant-key-005",
)
}
}.awaitAll()
}
results.map { it.grant.id }.distinct() shouldHaveSize 1
store.balance(userId) shouldBeExactly 75
store.manualGrants shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
}
test("manual grant supports the maximum balance and rejects overflow") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
service.grantManual(
TEST_OPERATOR_ID,
userId,
Long.MAX_VALUE,
"Maximum supported adjustment",
"support-ticket-1047",
"manual-grant-key-006",
)
shouldThrow<InvalidCreditRequest> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
1,
"Overflow adjustment",
"support-ticket-1048",
"manual-grant-key-007",
)
}
store.balance(userId) shouldBeExactly Long.MAX_VALUE
store.manualGrants shouldHaveSize 1
}
test("balance overflow rolls back without appending a ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
@@ -574,3 +729,5 @@ private fun llmRate(now: Instant) = CreditRateVersion(
outputCreditsNumerator = 3,
outputTokensDenominator = 1_000,
)
private val TEST_OPERATOR_ID = UUID.fromString("11111111-1111-1111-1111-111111111111")
@@ -1,11 +1,14 @@
package com.osglab.account.features.credits
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.ManualCreditGrant
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.BillingUnitOfWork
@@ -25,7 +28,10 @@ import kotlin.concurrent.withLock
class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
private val lock = ReentrantLock()
private val accounts = mutableMapOf<UUID, CreditAccount>()
val registeredUsers = mutableSetOf<UUID>()
val ledger = mutableListOf<LedgerEntry>()
val manualGrants = mutableListOf<ManualCreditGrant>()
val adminAudits = mutableListOf<NewAdminAuditEvent>()
val usageRecords = mutableListOf<CreditUsageRecord>()
val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>()
@@ -56,11 +62,17 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
override val credits: CreditsRepository = Credits()
override val referrals: ReferralsRepository = Referrals()
override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants()
var failNextManualGrantInsert = false
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
lock.withLock {
val accountSnapshot = accounts.toMap()
val registeredUserSnapshot = registeredUsers.toSet()
val ledgerSnapshot = ledger.toList()
val manualGrantSnapshot = manualGrants.toList()
val adminAuditSnapshot = adminAudits.toList()
val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap()
@@ -70,7 +82,10 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
block(this)
} catch (failure: Throwable) {
accounts.replaceWith(accountSnapshot)
registeredUsers.replaceWith(registeredUserSnapshot)
ledger.replaceWith(ledgerSnapshot)
manualGrants.replaceWith(manualGrantSnapshot)
adminAudits.replaceWith(adminAuditSnapshot)
usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot)
@@ -83,6 +98,9 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 }
private inner class Credits : CreditsRepository {
override fun accountExists(userId: UUID): Boolean =
userId in registeredUsers || userId in accounts
override fun createAccountIfAbsent(userId: UUID, now: Instant) {
accounts.putIfAbsent(userId, CreditAccount(userId, 0, now))
}
@@ -157,6 +175,28 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
}
}
private inner class AdminCreditGrants : AdminCreditGrantRepository {
override fun findByIdempotencyKey(
idempotencyKey: String,
): ManualCreditGrant? =
manualGrants.singleOrNull { it.idempotencyKey == idempotencyKey }
override fun insertAudit(event: NewAdminAuditEvent) {
check(adminAudits.none { it.id == event.id })
adminAudits += event
}
override fun insert(grant: ManualCreditGrant) {
if (failNextManualGrantInsert) {
failNextManualGrantInsert = false
error("Simulated manual grant audit failure")
}
check(findByIdempotencyKey(grant.idempotencyKey) == null)
check(manualGrants.none { it.id == grant.id || it.ledgerEntryId == grant.ledgerEntryId })
manualGrants += grant
}
}
private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values
@@ -238,6 +278,11 @@ private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
putAll(snapshot)
}
private fun <T> MutableSet<T>.replaceWith(snapshot: Set<T>) {
clear()
addAll(snapshot)
}
private fun <T> MutableList<T>.replaceWith(snapshot: List<T>) {
clear()
addAll(snapshot)
@@ -20,6 +20,7 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.nulls.shouldBeNull
@@ -208,8 +209,21 @@ class MySqlSecurityIntegrationTest : FunSpec({
ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
)
val concurrentUser = UUID.randomUUID()
val adminOperator = UUID.randomUUID()
connection().use {
insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub"))
it.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO admin_operators (
id, username, password_hash, encrypted_totp_secret, role
) VALUES (
'$adminOperator', 'integration-admin', 'unused-hash',
'unused-secret', 'SUPER_ADMIN'
)
""".trimIndent(),
)
}
}
credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent")
val reservationResults = coroutineScope {
@@ -236,6 +250,34 @@ class MySqlSecurityIntegrationTest : FunSpec({
reservationResults.count { it.isSuccess } shouldBeExactly 1
}
credits.getAccount(concurrentUser).balance shouldBeExactly 40
val manualGrantResults = coroutineScope {
List(4) {
async(Dispatchers.Default) {
credits.grantManual(
operatorId = adminOperator,
userId = concurrentUser,
credits = 25,
reason = "Integration support adjustment",
requestId = "integration-audit-reference",
idempotencyKey = "integration-manual-grant",
)
}
}.awaitAll()
}
manualGrantResults.map { it.grant.id }.distinct() shouldHaveSize 1
credits.getAccount(concurrentUser).balance shouldBeExactly 65
connection().use { connection ->
count(
connection,
"admin_credit_grants",
"account_id = '$concurrentUser'",
) shouldBeExactly 1
count(
connection,
"credit_ledger",
"user_id = '$concurrentUser' AND entry_type = 'MANUAL_GRANT'",
) shouldBeExactly 1
}
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
@@ -0,0 +1,29 @@
package com.osglab.account.tools
import io.kotest.matchers.string.shouldContain
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertFailsWith
class AdminCredentialGeneratorTest {
@Test
fun `generator creates separate runtime and operator files without overwriting`() {
val directory = Files.createTempDirectory("admin-credentials-test")
val runtime = directory.resolve("runtime.env")
val handoff = directory.resolve("handoff.txt")
AdminCredentialGenerator.main(
arrayOf("Owner", runtime.toString(), handoff.toString()),
)
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_ENABLED=true"
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_USERNAME=owner"
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_PASSWORD_HASH='\$argon2id\$"
Files.readString(handoff) shouldContain "认证器 URIotpauth://totp/"
assertFailsWith<IllegalArgumentException> {
AdminCredentialGenerator.main(
arrayOf("owner", runtime.toString(), handoff.toString()),
)
}
}
}