Files
meshcore-analyzer/cmd/server/admin_audit_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

116 lines
4.2 KiB
Go

package main
import (
"fmt"
"net/url"
"strings"
"testing"
"github.com/meshcore-analyzer/users"
)
func auditPage(t *testing.T, f *authFixture, c *client, query string) auditListJSON {
t.Helper()
w := f.do("GET", "/api/admin/audit"+query, nil, as(c))
expectStatus(t, w, 200)
return decode[auditListJSON](t, w)
}
func entryActions(p auditListJSON) string {
parts := make([]string, len(p.Entries))
for i, e := range p.Entries {
parts[i] = e.Action
}
return strings.Join(parts, ",")
}
func TestAdminAuditRequiresAdmin(t *testing.T) {
f, _, uma := adminFixture(t)
expectStatus(t, f.do("GET", "/api/admin/audit", nil), 401)
expectStatus(t, f.do("GET", "/api/admin/audit", nil, as(uma)), 403)
}
func TestAdminAuditFiltersAndUsers(t *testing.T) {
f, boss, uma := adminFixture(t)
for i := 0; i < 2; i++ {
expectStatus(t, f.do("POST", "/api/auth/login", loginRequest{Email: "uma@example.org", Password: "wrong password!"}), 401)
}
f.login(t, "uma@example.org", pw)
p := auditPage(t, f, boss, "?action=user.login.failed")
if len(p.Entries) != 2 || p.Next != nil {
t.Fatalf("failed logins = %+v", p)
}
e := p.Entries[0]
if e.Actor != nil || e.Target == nil || e.Target.ID != uma.me.ID || e.Target.DisplayName != "Uma" ||
e.Target.Email != "uma@example.org" || e.Target.Deleted || e.Detail["reason"] != "wrong_password" || e.At == "" {
t.Fatalf("entry = %+v (target %+v)", e, e.Target)
}
if got := entryActions(auditPage(t, f, boss, "?action=user.login.*")); got != "user.login,user.login.failed,user.login.failed" {
t.Fatalf("group = %s", got)
}
if got := entryActions(auditPage(t, f, boss, fmt.Sprintf("?user=%d", boss.me.ID))); got != "user.activate,user.register" {
t.Fatalf("user filter = %s", got)
}
future := url.QueryEscape("2100-01-01T00:00:00Z")
if p := auditPage(t, f, boss, "?from="+future); len(p.Entries) != 0 || p.Next != nil {
t.Fatalf("future period = %+v", p)
}
}
func TestAdminAuditPaginates(t *testing.T) {
f, boss, _ := adminFixture(t) // 4 rows: register and activate for each user
first := auditPage(t, f, boss, "?limit=3")
if len(first.Entries) != 3 || first.Next == nil || *first.Next != first.Entries[2].ID {
t.Fatalf("first page = %+v", first)
}
second := auditPage(t, f, boss, fmt.Sprintf("?limit=3&before=%d", *first.Next))
if len(second.Entries) != 1 || second.Next != nil || second.Entries[0].ID >= *first.Next {
t.Fatalf("second page = %+v", second)
}
}
func TestAdminAuditDeletedUser(t *testing.T) {
f, boss, uma := adminFixture(t)
expectStatus(t, f.do("DELETE", userPath(uma.me.ID, ""), nil, as(boss)), 200)
p := auditPage(t, f, boss, fmt.Sprintf("?user=%d&action=user.register", uma.me.ID))
if len(p.Entries) != 1 || p.Entries[0].Target == nil || !p.Entries[0].Target.Deleted ||
p.Entries[0].Target.ID != uma.me.ID || p.Entries[0].Target.Email != "" || p.Entries[0].Target.DisplayName != "" {
t.Fatalf("deleted target = %+v", p)
}
del := auditPage(t, f, boss, "?action=user.delete")
if len(del.Entries) != 1 || del.Entries[0].Actor == nil || del.Entries[0].Actor.ID != boss.me.ID || del.Entries[0].Actor.Deleted {
t.Fatalf("delete row = %+v", del)
}
}
func TestAdminAuditRejectsBadParameters(t *testing.T) {
f, boss, _ := adminFixture(t)
for _, q := range []string{
"?action=DROP%20TABLE", "?action=user.*.x", "?action=*", "?user=abc", "?user=0",
"?from=yesterday", "?to=2026-13-01T00:00:00Z", "?before=-1", "?before=x", "?limit=0", "?limit=ten",
"?from=2026-10-02T00:00:00Z&to=2026-10-01T00:00:00Z",
} {
if w := f.do("GET", "/api/admin/audit"+q, nil, as(boss)); w.Code != 400 {
t.Errorf("%s = %d; want 400", q, w.Code)
}
}
}
func TestParseAuditFilterDefaultsAndCap(t *testing.T) {
flt, err := parseAuditFilter(url.Values{"limit": {"100000"}})
if err != nil || flt.Limit != users.AuditListMax {
t.Fatalf("limit 100000 = %+v, %v; want %d", flt, err, users.AuditListMax)
}
flt, err = parseAuditFilter(url.Values{})
if err != nil || flt.Limit != 100 || flt.Actions != nil || flt.UserID != nil || flt.From != nil || flt.BeforeID != 0 {
t.Fatalf("defaults = %+v, %v", flt, err)
}
}
func TestAdminAuditStoreErrorIs500(t *testing.T) {
f, boss, _ := adminFixture(t)
f.breakTable(t, "audit_log")
expectStatus(t, f.do("GET", "/api/admin/audit", nil, as(boss)), 500)
}