feat(limits): unconditional usage accounting

buildLimitScopes previously omitted a user or group scope entirely
whenever that entity had no configured Limits, so a limit-less
user/group never accumulated ANY usage counters. Root-caused live:
the admin dashboard's total kept climbing while every per-user row
stayed zero until an operator added phantom, unreachable
requestsPerDay limits everywhere just to make counters build at all.

buildLimitScopes now always builds a user and a group scope. Limits
still gate enforcement only: evaluateScope already no-ops on a nil
Limits field, so a limit-less scope is counted but never violates.

Update stale doc comments coupling accounting to configured limits
(evaluateScope, the integration alice fixture, routes_media_test.go,
routes_unified_test.go, admin_test.go's admin1 assertion) and add:
- TestBuildLimitScopes_AlwaysBuildsBothScopes: table-driven over
  every nil/non-nil user/group limits combination.
- TestAdminUsage_LimitlessUserAndGroup_AccumulateRealTraffic:
  end-to-end regression proving a limit-less user and group
  accumulate real request/token counters visible via
  GET /admin/api/usage.
This commit is contained in:
2026-08-21 18:13:46 +01:00
parent 90545df588
commit 1183b6fa3c
6 changed files with 178 additions and 41 deletions
+9 -8
View File
@@ -860,14 +860,15 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation {
// evaluateScope checks one scope's already-set limits against its
// just-incremented request counts and its accumulated token/cost counts.
// A nil limits field means nothing to evaluate for this scope. In
// principle a nil-limits scope can still reach here with its req counters
// already incremented by checkAndCount, since that increment runs before
// any evaluation; in practice the unified route's buildLimitScopes
// (routes_unified.go, ruling e) never builds one — it omits a user or
// group scope from the slice entirely whenever that scope's own limits
// are nil — so this check is a defensive no-op today, not a path any
// current caller exercises.
// A nil limits field means nothing to evaluate for this scope — the real,
// commonly-exercised path since the v0.21 accounting fix: buildLimitScopes
// (routes_unified.go) now ALWAYS builds a user and a group scope, so a
// limit-less user or group reaches here on every request, with its req
// counters already incremented by checkAndCount (that increment runs before
// any evaluation) and nothing for this function to enforce against them.
// This is exactly the mechanism that decouples accounting from
// enforcement: the scope is counted unconditionally, evaluated only when it
// actually has a limit configured.
func (l *limiter) evaluateScope(sc limitScope, minCount, dayCount int64, now time.Time) *limitViolation {
if sc.limits == nil {
return nil