Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
This commit is contained in:
Rocky
2026-08-20 18:05:49 +08:00
parent 034a3e8745
commit 74c3fcd45f
39 changed files with 2990 additions and 431 deletions
@@ -1,14 +1,18 @@
package com.osglab.account.features.admin
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditQuery
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.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
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.AdminSortOrder
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
@@ -24,6 +28,7 @@ internal class InMemoryAdminRepository(
var passwordHash: String = "valid-password-hash",
var encryptedTotpSecret: String,
var role: AdminRole = AdminRole.SUPER_ADMIN,
var lastLoginAt: Instant? = null,
) : AdminRepository {
private val mutex = Mutex()
var lockState = AdminLockState(0, null)
@@ -66,19 +71,22 @@ internal class InMemoryAdminRepository(
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
query: AdminOperatorQuery,
): 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())
query.time.from?.let { from -> it.createdAt >= from } != false &&
query.time.until?.let { until -> it.createdAt < until } != false &&
query.role?.let { role -> it.role == role } != false &&
query.enabled?.let { enabled -> (it.disabledAt == null) == enabled } != false &&
query.locked?.let { locked ->
(it.lockState.lockedUntil?.isAfter(query.now) == true) == locked
} != false
}
.sortedWith(
compareBy<AdminOperatorRecord> { it.createdAt }
.thenBy { it.id.toString() },
)
.filter { before == null || operatorAfter(it, before, query) }
.sortedWith(operatorComparator(query))
.take(limit)
.toList()
}
@@ -307,20 +315,40 @@ internal class InMemoryAdminRepository(
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
query: AdminAuditQuery,
): List<AdminAuditRecord> =
mutex.withLock {
audits.asSequence()
.filter {
query.time.from?.let { from -> it.occurredAt >= from } != false &&
query.time.until?.let { until -> it.occurredAt < until } != false &&
query.action?.let { action -> it.action == action } != false &&
query.outcome?.let { outcome -> it.outcome == outcome } != false
}
.filter {
before == null ||
it.occurredAt.isBefore(before.occurredAt) ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString()
)
if (query.order == AdminSortOrder.ASC) {
it.occurredAt > before.occurredAt ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() > before.id.toString()
)
} else {
it.occurredAt < before.occurredAt ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString()
)
}
}
.sortedWith(
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() },
if (query.order == AdminSortOrder.ASC) {
compareBy<NewAdminAuditEvent> { it.occurredAt }
.thenBy { it.id.toString() }
} else {
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() }
},
)
.take(limit)
.map {
@@ -344,6 +372,27 @@ internal class InMemoryAdminRepository(
}
}
suspend fun configureOperatorListState(
targetId: UUID,
disabledAt: Instant? = null,
lockState: AdminLockState = AdminLockState(0, null),
lastLoginAt: Instant? = null,
) {
mutex.withLock {
if (targetId == operatorId) {
this.disabledAt = disabledAt
this.lockState = lockState
this.lastLoginAt = lastLoginAt
} else {
additionalOperators.getValue(targetId).apply {
this.disabledAt = disabledAt
this.lockState = lockState
this.lastLoginAt = lastLoginAt
}
}
}
}
private fun authRecord() = AdminOperatorAuthRecord(
id = operatorId,
normalizedUsername = username,
@@ -360,7 +409,7 @@ internal class InMemoryAdminRepository(
role = role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
lastLoginAt = lastLoginAt,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
@@ -391,6 +440,7 @@ internal class InMemoryAdminRepository(
var lockState: AdminLockState = AdminLockState(0, null),
var lastTotpCounter: Long? = null,
var disabledAt: Instant? = null,
var lastLoginAt: Instant? = null,
var updatedAt: Instant = operator.createdAt,
) {
fun toAuthRecord() = AdminOperatorAuthRecord(
@@ -409,7 +459,7 @@ internal class InMemoryAdminRepository(
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
lastLoginAt = lastLoginAt,
createdAt = operator.createdAt,
updatedAt = updatedAt,
)
@@ -424,3 +474,64 @@ private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): N
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
},
)
private fun operatorComparator(query: AdminOperatorQuery): Comparator<AdminOperatorRecord> =
Comparator { left, right ->
val primary = when (query.sort) {
AdminOperatorSort.CREATED_AT ->
orderedComparison(left.createdAt.compareTo(right.createdAt), query.order)
AdminOperatorSort.USERNAME ->
orderedComparison(left.normalizedUsername.compareTo(right.normalizedUsername), query.order)
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
left.lastLoginAt,
right.lastLoginAt,
query.order,
)
}
if (primary != 0) {
primary
} else {
orderedComparison(left.id.toString().compareTo(right.id.toString()), query.order)
}
}
private fun operatorAfter(
record: AdminOperatorRecord,
cursor: AdminOperatorCursor,
query: AdminOperatorQuery,
): Boolean {
val primary = when (query.sort) {
AdminOperatorSort.CREATED_AT -> orderedComparison(
record.createdAt.compareTo(Instant.parse(requireNotNull(cursor.value))),
query.order,
)
AdminOperatorSort.USERNAME -> orderedComparison(
record.normalizedUsername.compareTo(requireNotNull(cursor.value)),
query.order,
)
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
record.lastLoginAt,
cursor.value?.let(Instant::parse),
query.order,
)
}
return primary > 0 ||
(
primary == 0 &&
orderedComparison(record.id.toString().compareTo(cursor.id.toString()), query.order) > 0
)
}
private fun <T : Comparable<T>> compareNullableLast(
left: T?,
right: T?,
order: AdminSortOrder,
): Int = when {
left == null && right == null -> 0
left == null -> 1
right == null -> -1
else -> orderedComparison(left.compareTo(right), order)
}
private fun orderedComparison(value: Int, order: AdminSortOrder): Int =
if (order == AdminSortOrder.ASC) value else -value
@@ -4,12 +4,18 @@ 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.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
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.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
@@ -175,6 +181,85 @@ class AdminOperatorRepositoryIntegrationTest : FunSpec({
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
}
}
test("MySQL list queries preserve filters keyset order and null-last semantics") {
withAdminRepositories { repository, _ ->
val from = Instant.parse("2026-08-17T00:00:00Z")
val until = from.plusSeconds(120)
val neverLoggedIn = UUID.fromString("10000000-0000-0000-0000-000000000001")
val earlier = UUID.fromString("20000000-0000-0000-0000-000000000002")
val later = UUID.fromString("30000000-0000-0000-0000-000000000003")
listOf(neverLoggedIn, earlier, later).forEachIndexed { index, id ->
repository.createOperatorIfAbsent(
newOperator(id, "query-operator-$index", AdminRole.SUPPORT, from),
)
}
listOf(earlier to from.plusSeconds(10), later to from.plusSeconds(20)).forEach {
(operatorId, loginAt) ->
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = operatorId.toString().replace("-", "").padEnd(64, '0'),
csrfTokenHash = operatorId.toString().replace("-", "").padEnd(64, 'f'),
createdAt = loginAt,
expiresAt = loginAt.plusSeconds(60),
),
totpCounter = 1,
now = loginAt,
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, loginAt),
)
}
val operators = repository.listOperatorsPage(
limit = 10,
query = AdminOperatorQuery(
time = AdminTimeFilter(from, until),
role = AdminRole.SUPPORT,
sort = AdminOperatorSort.LAST_LOGIN_AT,
order = AdminSortOrder.DESC,
now = until,
),
)
operators.map { it.id } shouldContainExactly listOf(later, earlier, neverLoggedIn)
val lowerAuditId = UUID.fromString("40000000-0000-0000-0000-000000000004")
val higherAuditId = UUID.fromString("50000000-0000-0000-0000-000000000005")
listOf(
NewAdminAuditEvent(
id = higherAuditId,
actorOperatorId = null,
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
occurredAt = from,
),
NewAdminAuditEvent(
id = lowerAuditId,
actorOperatorId = null,
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
occurredAt = from,
),
NewAdminAuditEvent(
actorOperatorId = null,
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
occurredAt = until,
),
).forEach { repository.appendAudit(it) }
val audits = repository.listAudit(
limit = 10,
query = AdminAuditQuery(
time = AdminTimeFilter(from, until),
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
order = AdminSortOrder.ASC,
),
)
audits.map { it.id } shouldContainExactly listOf(lowerAuditId, higherAuditId)
}
}
})
private suspend fun withAdminRepositories(
@@ -188,7 +188,7 @@ class AdminRoutesTest {
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
coEvery { operatorService.listPage(any(), any(), any(), any()) } throws
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
application {
installAdminTestRoutes(
@@ -259,6 +259,34 @@ class AdminRoutesTest {
response.bodyAsText() shouldContain """"usageType":"hotword""""
}
@Test
fun `admin list routes reject non-whitelisted filters and invalid ranges`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val invalidPaths = listOf(
"/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z",
"/v1/admin/credits/ledger?type=unknown",
"/v1/admin/operators?enabled=1",
"/v1/admin/audit?action=NOT_AN_ACTION",
"/v1/admin/referrals?range=30d&limit=101",
"/v1/admin/users?unexpected=value",
"/v1/admin/users?from=2026-08-20T08:00:00%2B08:00",
)
invalidPaths.forEach { path ->
val response = client.get(path) {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.BadRequest, response.status, path)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
}
@Test
fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
@@ -1,10 +1,15 @@
package com.osglab.account.features.admin.services
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.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
@@ -66,6 +71,54 @@ class AdminAuditServiceTest : FunSpec({
service.list(support, null)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
}
test("audit filters use half-open boundaries and ascending ID tie-break") {
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
val actor = superAdministrator().copy(operatorId = repository.operatorId)
val from = Instant.parse("2026-08-17T00:00:00Z")
val until = from.plusSeconds(60)
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
listOf(
auditEvent(lowerId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(higherId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(UUID.randomUUID(), from.minusNanos(1), AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(UUID.randomUUID(), until, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(UUID.randomUUID(), from.plusSeconds(1), AdminAuditAction.LOGIN_SUCCEEDED, AdminAuditOutcome.SUCCESS),
).forEach { repository.appendAudit(it) }
val page = AdminAuditService(repository).list(
actor = actor,
cursor = null,
query = AdminAuditQuery(
time = AdminTimeFilter(from, until),
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
order = AdminSortOrder.ASC,
),
)
page.items.map { it.record.id } shouldBe listOf(lowerId, higherId)
}
test("audit cursor rejects the opposite order") {
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
val actor = superAdministrator().copy(operatorId = repository.operatorId)
listOf(
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:02Z")),
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:01Z")),
).forEach { repository.appendAudit(it) }
val service = AdminAuditService(repository)
val first = service.list(actor, null, limit = 1)
shouldThrow<AdminAuditCursorException> {
service.list(
actor,
first.nextCursor,
query = AdminAuditQuery(order = AdminSortOrder.ASC),
)
}
}
})
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
@@ -79,6 +132,19 @@ private fun auditRecord(occurredAt: String) = AdminAuditRecord(
occurredAt = Instant.parse(occurredAt),
)
private fun auditEvent(
id: UUID,
occurredAt: Instant,
action: AdminAuditAction = AdminAuditAction.LOGIN_SUCCEEDED,
outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS,
) = NewAdminAuditEvent(
id = id,
actorOperatorId = null,
action = action,
outcome = outcome,
occurredAt = occurredAt,
)
private fun superAdministrator() = AdminPrincipal(
operatorId = UUID.randomUUID(),
sessionId = UUID.randomUUID(),
@@ -6,9 +6,13 @@ 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.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
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.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
@@ -216,6 +220,104 @@ class AdminOperatorServiceTest : FunSpec({
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
}
}
test("operator filters apply half-open time role enabled and current lock state") {
val fixture = operatorFixture()
val matching = fixture.seedOperator("locked-support", AdminRole.SUPPORT)
val expired = fixture.seedOperator("expired-support", AdminRole.SUPPORT)
val disabled = fixture.seedOperator("disabled-support", AdminRole.SUPPORT)
fixture.repository.configureOperatorListState(
matching,
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
)
fixture.repository.configureOperatorListState(
expired,
lockState = AdminLockState(2, fixture.clock.instant()),
)
fixture.repository.configureOperatorListState(
disabled,
disabledAt = fixture.clock.instant(),
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
)
val page = fixture.service.listPage(
actor = fixture.owner,
cursor = null,
query = AdminOperatorQuery(
time = AdminTimeFilter(
fixture.clock.instant(),
fixture.clock.instant().plusNanos(1),
),
role = AdminRole.SUPPORT,
enabled = true,
locked = true,
now = fixture.clock.instant(),
),
)
page.items.map(AdminOperatorRecord::id) shouldBe listOf(matching)
}
test("last login sorting keeps null values last in both directions") {
val fixture = operatorFixture()
val earlier = fixture.seedOperator("earlier-login", AdminRole.SUPPORT)
val later = fixture.seedOperator("later-login", AdminRole.SUPPORT)
fixture.repository.configureOperatorListState(
earlier,
lastLoginAt = fixture.clock.instant().minusSeconds(60),
)
fixture.repository.configureOperatorListState(
later,
lastLoginAt = fixture.clock.instant(),
)
val ascending = fixture.service.listPage(
fixture.owner,
cursor = null,
query = AdminOperatorQuery(
sort = AdminOperatorSort.LAST_LOGIN_AT,
order = AdminSortOrder.ASC,
now = fixture.clock.instant(),
),
)
val descending = fixture.service.listPage(
fixture.owner,
cursor = null,
query = AdminOperatorQuery(
sort = AdminOperatorSort.LAST_LOGIN_AT,
order = AdminSortOrder.DESC,
now = fixture.clock.instant(),
),
)
ascending.items.map(AdminOperatorRecord::id) shouldBe
listOf(earlier, later, fixture.owner.operatorId)
descending.items.map(AdminOperatorRecord::id) shouldBe
listOf(later, earlier, fixture.owner.operatorId)
}
test("operator cursor rejects a different sort contract") {
val fixture = operatorFixture()
fixture.seedOperator("cursor-a", AdminRole.SUPPORT)
fixture.seedOperator("cursor-b", AdminRole.SUPPORT)
val first = fixture.service.listPage(
fixture.owner,
cursor = null,
limit = 1,
query = AdminOperatorQuery(now = fixture.clock.instant()),
)
shouldThrow<AdminOperatorCursorException> {
fixture.service.listPage(
fixture.owner,
cursor = first.nextCursor,
query = AdminOperatorQuery(
sort = AdminOperatorSort.USERNAME,
now = fixture.clock.instant(),
),
)
}
}
})
private data class OperatorFixture(
@@ -1,7 +1,9 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.features.admin.models.AdminSortOrder
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.AdminReferralRankDto
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
@@ -11,6 +13,7 @@ import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggre
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 com.osglab.account.features.admin.stats.services.AdminReferralSort
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
@@ -159,4 +162,40 @@ class AdminStatsRepositoryTest : FunSpec({
BigDecimal("1.5").toExactLong()
}
}
test("referral ranking supports explicit metric order stable ID tie-break and limit") {
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0),
referralRanking = listOf(
AdminReferralRankDto("user-c", 2, 1, 20),
AdminReferralRankDto("user-a", 3, 1, 20),
AdminReferralRankDto("user-b", 1, 2, 10),
),
usage = emptyList(),
)
val service = AdminStatsService(AdminStatsRepository { snapshot })
val descending = service.get(
from,
until,
referralRankLimit = 2,
referralSort = AdminReferralSort.CREDITS_EARNED,
referralOrder = AdminSortOrder.DESC,
)
val ascending = service.get(
from,
until,
referralSort = AdminReferralSort.QUALIFIED,
referralOrder = AdminSortOrder.ASC,
)
descending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
listOf("user-c", "user-a")
ascending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
listOf("user-a", "user-c", "user-b")
}
})
@@ -5,8 +5,14 @@ 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.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
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
@@ -223,6 +229,112 @@ class AdminUsersServiceTest : FunSpec({
service.detail(UUID.randomUUID())
}
}
test("user filters use a UTC half-open range and ascending ID tie-break") {
val boundary = Instant.parse("2026-08-15T00:00:00Z")
val until = Instant.parse("2026-08-16T00:00:00Z")
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val service = AdminUsersService(
PagingUsersRepository(
listOf(
summary(higherId, boundary, restricted = true),
summary(lowerId, boundary, restricted = true),
summary(UUID.randomUUID(), boundary.minusNanos(1), restricted = true),
summary(UUID.randomUUID(), until, restricted = true),
summary(UUID.randomUUID(), boundary.plusSeconds(1), restricted = false),
),
),
)
val page = service.list(
query = AdminUserListQuery(
time = AdminTimeFilter(boundary, until),
status = AdminUserStatus.SUSPENDED,
order = AdminSortOrder.ASC,
),
)
page.items.map(AdminUserSummaryDto::id) shouldBe
listOf(lowerId.toString(), higherId.toString())
}
test("ledger filters type and half-open range in ascending stable order") {
val userId = UUID.randomUUID()
val from = Instant.parse("2026-08-15T00:00:00Z")
val until = from.plusSeconds(60)
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val entries = listOf(
ledgerEntry(higherId, from, userId),
ledgerEntry(lowerId, from, userId),
ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId),
ledgerEntry(UUID.randomUUID(), until, userId),
ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(
userId = userId,
query = AdminLedgerQuery(
time = AdminTimeFilter(from, until),
type = AdminLedgerType.GRANT,
order = AdminSortOrder.ASC,
),
)
page.items.map(AdminUserLedgerEntryDto::id) shouldBe
listOf(lowerId.toString(), higherId.toString())
}
test("versioned user cursor rejects the opposite sort direction") {
val service = AdminUsersService(
PagingUsersRepository(
listOf(
summary(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
summary(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
),
),
)
val first = service.list(limit = 1, query = AdminUserListQuery(order = AdminSortOrder.DESC))
shouldThrow<IllegalArgumentException> {
service.list(
cursor = first.nextCursor.shouldNotBeNull(),
query = AdminUserListQuery(order = AdminSortOrder.ASC),
)
}
}
test("versioned ledger cursor rejects the opposite sort direction") {
val userId = UUID.randomUUID()
val service = AdminUsersService(
PagingUsersRepository(
emptyList(),
ledger = mapOf(
userId to listOf(
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z"), userId),
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z"), userId),
),
),
),
)
val first = service.ledger(
userId,
limit = 1,
query = AdminLedgerQuery(order = AdminSortOrder.DESC),
)
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = first.nextCursor.shouldNotBeNull(),
query = AdminLedgerQuery(order = AdminSortOrder.ASC),
)
}
}
})
private class PagingUsersRepository(
@@ -233,15 +345,24 @@ private class PagingUsersRepository(
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
query: AdminUserListQuery,
): 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)
users.asSequence()
.filter { it.matches(query) }
.filter {
cursor == null || userAfter(it, cursor, query.order)
}
.sortedWith(
if (query.order == AdminSortOrder.ASC) {
compareBy<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserSummaryDto::id)
} else {
compareByDescending<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserSummaryDto::id)
},
)
.take(limit)
.toList()
override suspend fun findDetail(
userId: UUID,
@@ -251,8 +372,11 @@ private class PagingUsersRepository(
override suspend fun findByIdSuffix(
suffix: String,
limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> =
users.filter { it.id.endsWith(suffix, ignoreCase = true) }.take(limit)
users.filter {
it.id.endsWith(suffix, ignoreCase = true) && it.matches(query)
}.take(limit)
override suspend fun exists(userId: UUID): Boolean =
userId in details || userId in ledger || users.any { it.id == userId.toString() }
@@ -261,48 +385,108 @@ private class PagingUsersRepository(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> =
ledger[userId].orEmpty()
.filter { it.matches(query) }
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
ledgerComparator(query.order),
)
.take(limit)
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> =
ledger.values.flatten()
.filter { it.matches(query) }
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
ledgerComparator(query.order),
)
.take(limit)
}
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
private fun AdminUserSummaryDto.matches(query: AdminUserListQuery): Boolean {
val instant = Instant.parse(createdAt)
return query.time.from?.let { instant >= it } != false &&
query.time.until?.let { instant < it } != false &&
when (query.status) {
AdminUserStatus.ACTIVE -> !antiAbuseRestricted
AdminUserStatus.SUSPENDED -> antiAbuseRestricted
null -> true
}
}
private fun userAfter(
item: AdminUserSummaryDto,
cursor: AdminUserCursor,
order: AdminSortOrder,
): Boolean {
val createdAt = Instant.parse(item.createdAt)
return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt ||
(createdAt == cursor.createdAt && item.id > cursor.userId.toString())
} else {
createdAt < cursor.createdAt ||
(createdAt == cursor.createdAt && item.id < cursor.userId.toString())
}
}
private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean {
val instant = Instant.parse(createdAt)
val category = when (type) {
"USAGE_RESERVE" -> AdminLedgerType.RESERVE
"USAGE_SETTLE" -> AdminLedgerType.SETTLE
"USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT",
-> AdminLedgerType.GRANT
else -> AdminLedgerType.ADJUSTMENT
}
return query.time.from?.let { instant >= it } != false &&
query.time.until?.let { instant < it } != false &&
(query.type == null || query.type == category)
}
private fun ledgerAfter(
item: AdminUserLedgerEntryDto,
cursor: AdminUserLedgerCursor,
order: AdminSortOrder,
): Boolean {
val createdAt = Instant.parse(item.createdAt)
return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt ||
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
} else {
createdAt < cursor.createdAt ||
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
}
}
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> =
if (order == AdminSortOrder.ASC) {
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id)
} else {
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id)
}
private fun summary(
id: UUID,
createdAt: Instant,
restricted: Boolean = false,
) = AdminUserSummaryDto(
id = id.toString(),
createdAt = createdAt.toString(),
antiAbuseRestricted = false,
antiAbuseRestricted = restricted,
creditBalance = 0,
consumedCredits = 0,
manualGrantedCredits = 0,
@@ -316,10 +500,11 @@ private fun ledgerEntry(
id: UUID,
createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
type: String = "MANUAL_GRANT",
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = "MANUAL_GRANT",
type = type,
amountDelta = 10,
balanceAfter = 10,
referenceId = null,