mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-11 06:37:45 +00:00
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>
116 lines
4.2 KiB
Go
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)
|
|
}
|