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:
@@ -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
|
||||
},
|
||||
)
|
||||
+246
@@ -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()
|
||||
+54
@@ -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"
|
||||
+284
@@ -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"
|
||||
+131
@@ -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"
|
||||
+71
@@ -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 "认证器 URI:otpauth://totp/"
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
AdminCredentialGenerator.main(
|
||||
arrayOf("owner", runtime.toString(), handoff.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user