Add admin dashboard filtering and sorting
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:
@@ -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
|
||||
|
||||
+85
@@ -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(),
|
||||
|
||||
+102
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user