Files
meshcore-analyzer/cmd/server/admin_stats_test.go
T
efitenandClaude Opus 5.5 18a634c115 feat: admin dashboard with overview and audit log (user management part C) (#2138)
Part C of #2128: an admin area with an overview, the user table and a
global audit log. Off by default like the rest of user management: with
`userManagement` off nothing changes, and non-admins get no new UI.

This is the first of 4 stacked PRs (C, D, E, then a small export/backup
follow-up). Each later one contains this branch; review them in order.

## The situation

- An admin could manage users one by one in `#/admin/users`, but nothing
showed whether the instance needs attention: accounts stuck in
activation, bouncing mail, someone guessing a password, an MQTT source
that dropped.
- The audit log existed (`internal/users/audit.go`) but could only be
read per user, and logins were not recorded.

## What this PR adds

**Store (`internal/users`)**
- Schema v3: an index on `audit_log(at)`.
- `AuditList` with filters (action or group prefix like `user.login.*`,
user as actor or target, period) and keyset pagination; `PruneAudit`;
`Stats` for the user figures.

**Server**
- `GET /api/admin/stats` (typed struct): accounts by status, admins,
registrations and active users over 7 and 30 days, logins and failed
logins in 24 hours, mail by final status, and the attention items
computed server-side.
- `GET /api/admin/audit`: filtered, newest first, `next` cursor.
- `GET /api/admin/users` gains `bouncing=1`.
- Logins are audited as `user.login` and `user.login.failed` (reason
`wrong_password`, `pending` or `disabled`). An unknown address writes no
row. The writes are asynchronous, so the login response does not wait on
`users.db`. Login rows are pruned after 90 days.

**Frontend**
- `#/admin?tab=overview|users|audit`: `admin.js` (tab shell),
`admin-overview.js` ("Needs attention", Users card, System card from the
existing health, MQTT and observer endpoints), `admin-audit.js` (filters
in the URL, "Load more"). `admin-users.js` becomes the Users tab; the
old `#/admin/users` link rewrites to it.
- `/api/healthz` is read on open and on Refresh only, not on the
60-second timer, because it walks every packet under a read lock.

Spec:
[`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md).

## Verification

- `internal/users` and `cmd/server`: `go vet` and `go test` pass
locally, 26 new Go tests. One upstream test,
`TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain
`master` (file modes) and is unrelated.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E with the `e2etest` build: 13 of 13 steps locally.
- Running on our staging and production instance since 7 October 2026.

## Not in this PR

- Restricting existing pages (perf, MQTT status) to logged-in users or
admins. The admin area adds no access mechanism of its own, so that
stays a later router rule plus endpoint check.
- Server-enforced customizer tab restrictions (#1508).
- The 24-hour, 5-attempt and 10-minute attention thresholds are
constants for now (AGENTS.md rule 8: customizer later).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:38:30 +02:00

47 lines
1.6 KiB
Go

package main
import (
"strings"
"testing"
)
func TestAdminStatsRequiresAdmin(t *testing.T) {
f, _, uma := adminFixture(t)
expectStatus(t, f.do("GET", "/api/admin/stats", nil), 401)
expectStatus(t, f.do("GET", "/api/admin/stats", nil, as(uma)), 403)
}
func TestAdminStats(t *testing.T) {
f, boss, uma := adminFixture(t)
expectStatus(t, f.do("POST", "/api/auth/register", registerRequest{Email: "pending@example.org", DisplayName: "Pen", Password: pw}), 200)
for i := 0; i < 5; i++ {
expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "uma@example.org", Password: "wrong password!"}), 401)
}
w := f.do("GET", "/api/admin/stats", nil, as(boss))
expectStatus(t, w, 200)
st := decode[adminStatsJSON](t, w)
if st.Total != 3 || st.Active != 2 || st.Pending != 1 || st.Admins != 1 || st.StuckPending != 0 ||
st.New7d != 3 || st.New30d != 3 || len(st.NewPerDay) != 30 || st.Active7d != 2 ||
st.Logins24h != 0 || st.FailedLogins24h != 5 || st.Mail7d.Pending != 3 {
t.Fatalf("stats = %+v", st)
}
if len(st.Guessing) != 1 || st.Guessing[0].UserID != uma.me.ID || st.Guessing[0].DisplayName != "Uma" || st.Guessing[0].Failed != 5 {
t.Fatalf("guessing = %+v", st.Guessing)
}
}
func TestAdminStatsEmptyListsAreArrays(t *testing.T) {
f, boss, _ := adminFixture(t)
w := f.do("GET", "/api/admin/stats", nil, as(boss))
expectStatus(t, w, 200)
if !strings.Contains(w.Body.String(), `"guessing":[]`) {
t.Fatalf("body = %s; want \"guessing\":[]", w.Body.String())
}
}
func TestAdminStatsStoreErrorIs500(t *testing.T) {
f, boss, _ := adminFixture(t)
f.breakTable(t, "mail_log")
expectStatus(t, f.do("GET", "/api/admin/stats", nil, as(boss)), 500)
}