Files
meshcore-analyzer/internal/users/users_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

138 lines
4.4 KiB
Go

package users
import (
"errors"
"testing"
)
func mustCreate(t *testing.T, st *Store, email, name string) *User {
t.Helper()
u, err := st.CreatePending(email, name, "$argon2id$placeholder")
if err != nil {
t.Fatalf("CreatePending(%s): %v", email, err)
}
return u
}
func TestCreatePendingAndDuplicate(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "alice@example.org", "Alice")
if u.ID == 0 || u.Status != StatusPending || u.Role != RoleUser || !u.CreatedAt.Equal(clk.Now()) {
t.Fatalf("unexpected user: %+v", u)
}
if _, err := st.CreatePending("alice@example.org", "Other", "x"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("duplicate err = %v; want ErrEmailTaken", err)
}
got, err := st.GetByEmail("alice@example.org")
if err != nil || got.ID != u.ID {
t.Fatalf("GetByEmail = %+v, %v", got, err)
}
if _, err := st.GetByID(9999); !errors.Is(err, ErrNotFound) {
t.Fatalf("GetByID(missing) err = %v", err)
}
}
func TestActivateOnlyPending(t *testing.T) {
st, _ := newTestStore(t)
admin := mustCreate(t, st, "admin@example.org", "Admin")
u := mustCreate(t, st, "bob@example.org", "Bob")
by := admin.ID
if err := st.Activate(u.ID, RoleAdmin, &by); err != nil {
t.Fatal(err)
}
got, _ := st.GetByID(u.ID)
if got.Status != StatusActive || got.Role != RoleAdmin || got.ActivatedAt == nil || got.ActivatedBy == nil || *got.ActivatedBy != admin.ID {
t.Fatalf("after Activate: %+v", got)
}
if err := st.Activate(u.ID, RoleUser, nil); !errors.Is(err, ErrNotFound) {
t.Fatalf("second Activate err = %v; want ErrNotFound", err)
}
}
func TestSettersAndEmailUniqueness(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "Aa")
mustCreate(t, st, "b@example.org", "Bb")
if err := st.SetEmail(a.ID, "b@example.org"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("SetEmail to taken = %v", err)
}
if err := st.SetEmail(a.ID, "c@example.org"); err != nil {
t.Fatal(err)
}
if err := st.SetDisplayName(a.ID, "New name"); err != nil {
t.Fatal(err)
}
if err := st.SetPassword(a.ID, "newhash"); err != nil {
t.Fatal(err)
}
if err := st.SetStatus(a.ID, StatusDisabled); err != nil {
t.Fatal(err)
}
if err := st.SetRole(a.ID, RoleAdmin); err != nil {
t.Fatal(err)
}
if err := st.SetEmailBouncing(a.ID, true); err != nil {
t.Fatal(err)
}
if err := st.TouchLogin(a.ID); err != nil {
t.Fatal(err)
}
got, _ := st.GetByID(a.ID)
if got.Email != "c@example.org" || got.DisplayName != "New name" || got.PasswordHash != "newhash" ||
got.Status != StatusDisabled || got.Role != RoleAdmin || !got.EmailBouncing ||
got.LastLoginAt == nil || !got.LastLoginAt.Equal(clk.Now()) {
t.Fatalf("after setters: %+v", got)
}
if err := st.SetStatus(9999, StatusActive); !errors.Is(err, ErrNotFound) {
t.Fatalf("SetStatus(missing) = %v", err)
}
}
func TestListFiltersAndAdminCount(t *testing.T) {
st, _ := newTestStore(t)
a := mustCreate(t, st, "alice@example.org", "Alice")
b := mustCreate(t, st, "bob@example.org", "Bob_1")
mustCreate(t, st, "carol@example.org", "Carol")
st.Activate(a.ID, RoleAdmin, nil)
st.Activate(b.ID, RoleUser, nil)
all, err := st.List(ListFilter{})
if err != nil || len(all) != 3 {
t.Fatalf("List all = %d, %v", len(all), err)
}
pending, _ := st.List(ListFilter{Status: StatusPending})
if len(pending) != 1 || pending[0].Email != "carol@example.org" {
t.Fatalf("pending = %+v", pending)
}
admins, _ := st.List(ListFilter{Role: RoleAdmin})
if len(admins) != 1 || admins[0].ID != a.ID {
t.Fatalf("admins = %+v", admins)
}
q, _ := st.List(ListFilter{Query: "BOB"})
if len(q) != 1 || q[0].ID != b.ID {
t.Fatalf("query BOB = %+v", q)
}
// LIKE wildcards in the query are literal.
if w, _ := st.List(ListFilter{Query: "_"}); len(w) != 1 || w[0].ID != b.ID {
t.Fatalf("query _ = %+v", w)
}
// Bouncing narrows on the server, combined with the other filters.
if err := st.SetEmailBouncing(b.ID, true); err != nil {
t.Fatal(err)
}
if bo, err := st.List(ListFilter{Bouncing: true}); err != nil || len(bo) != 1 || bo[0].ID != b.ID {
t.Fatalf("bouncing = %+v, %v", bo, err)
}
if bo, _ := st.List(ListFilter{Bouncing: true, Role: RoleAdmin}); len(bo) != 0 {
t.Fatalf("bouncing admins = %+v", bo)
}
n, err := st.CountActiveAdmins()
if err != nil || n != 1 {
t.Fatalf("CountActiveAdmins = %d, %v", n, err)
}
st.SetStatus(a.ID, StatusDisabled)
if n, _ := st.CountActiveAdmins(); n != 0 {
t.Fatalf("disabled admin still counted: %d", n)
}
}