Files
meshcore-analyzer/cmd/server/readonly_invariant_test.go
T
efitenandClaude Opus 5.5 d232072f85 feat: optional user accounts (part A: foundation) (#2129)
Part A of #2128: optional, off-by-default user accounts. With the
feature off nothing changes; with it on, visitors can register and log
in, and admins manage users and use the operator actions without the API
key.

PR #2130 (settings sync) builds on this one. The two are meant to be
merged together.

## The situation

- Operator actions (geofilter save and prune, backup, perf reset) need
the shared `apiKey`. There is no per-person right.
- Nothing in CoreScope knows who a visitor is, so the requests in #2128
that need that (#1835, #2092, #1508, #730) have nothing to build on.

## What this PR adds

**Two new Go modules**
- `internal/users`: a separate `users.db` (SQLite through
`modernc.org/sqlite`) with users, sessions, single-use tokens, an audit
log and a mail log. Passwords use argon2id.
- `internal/mailer`: a `Mailer` interface with a Brevo client (send,
delivery events, webhook parsing) and an in-memory fake for tests.

**Server (`cmd/server`)**, active only with `userManagement.enabled`
- 24 routes, all documented in OpenAPI under the `users` tag
([`auth_routes.go`](https://github.com/efiten/CoreScope/blob/feat/user-management/cmd/server/auth_routes.go)):
  - auth: register, activate, login, logout, me, forgot, reset;
- account: profile, password, email change with confirmation, sessions,
self-delete;
- admin: list, detail, disable, enable, delete, role, resend activation,
manual activation, mail status refresh;
  - a Brevo webhook, registered only when `mail.webhookSecret` is set.
- `requireAdmin` replaces `requireAPIKey` at the 7 operator call sites:
the API key **or** an admin session. With the feature off it is the old
API-key gate (`TestRequireAdminWithoutUserManagementIsAPIKeyGate`).
- `/api/config/client` gets `userManagement: {enabled: true}` only when
the service started; with the feature off the response is
byte-identical.

**Frontend**
- `auth.js` (header account control, request helper that adds the CSRF
header), `account.js` (login, register, activate, forgot, reset, confirm
email, my account), `admin-users.js` (`#/admin/users`, deep-linked
filters), `account.css` (theme tokens only).
- On phones the top-bar control is hidden, so a conditional entry goes
into the bottom-nav "More" sheet and the nav drawer.
- The customizer geofilter tab and the Perf "Reset stats" button use the
admin session when there is one.

**Config.** A `userManagement` block (`config.example.json`,
[`docs/user-guide/accounts.md`](https://github.com/efiten/CoreScope/blob/feat/user-management/docs/user-guide/accounts.md)).
The Brevo key can come from `CORESCOPE_BREVO_API_KEY`. The server
refuses to start when the block is enabled but incomplete.

## Security choices

- Session cookie `cs_session`: HttpOnly, SameSite=Lax, Secure when
`publicBaseUrl` is https. Every cookie-authenticated state change needs
the `X-CS-CSRF` header and a matching Origin.
- Activation needs the token **and** the account password. Without the
password, an attacker who keeps re-registering a known address could get
the owner to activate an account that carries the attacker's password.
- Register, forgot and email change answer identically for known and
unknown addresses. A password reset ends all sessions, a password change
ends all other sessions, and both end outstanding email-change links.
- Rate limits: login 10 per 15 minutes, register and forgot 5 per hour,
per IP and per address. The bucket count is capped. `trustedProxies`
makes the per-IP limits see real client IPs behind a proxy.
- Server logs carry `#<user id>`, never addresses, tokens or passwords;
mail-provider error texts are redacted before logging.

## Performance

No change to an existing hot path with the feature off. With it on:
- One `users.db` lookup per authenticated request (session by token
hash).
- The admin user table rebuilds its `tbody` on each filter change.
`users.List` caps the result at 1000 rows (`internal/users/users.go`),
which bounds the rebuild.
- `map[string]interface{}` in `openapi.go`: 79 before, 78 after.

## Verification

- `internal/users`, `internal/mailer` and `cmd/server`: `go vet` and `go
test -race` pass locally. 121 new Go tests.
- `cmd/server` with `-tags e2etest`: vet and the e2e hook tests pass.
- `sh test-all.sh` exits 0. `tests/unit/test-user-management-ui.js`: 67
passing (vm, real modules).
- `tests/e2e/test-user-management-e2e.js` (6 steps) passed locally
against an `e2etest` build with the fake mailer and against a
feature-off build. CI builds the `e2etest` binary and runs the suite on
a second server (`deploy.yml`).
- On a staging instance with a real Brevo key: register, activation mail
delivered, activate, admin table, "Refresh status" showing sent,
deferred, delivered, opened and clicked.

## Not in this PR

- Settings sync (#2130), the admin dashboard, approval flows and
notifications (parts B to E of #2128).
- A `requireReadAuth` mode (#1835). Sessions from this PR are what such
a mode would accept.
- Binary size and build time with `modernc.org/sqlite` linked next to
`mattn/go-sqlite3` were not measured. Their driver names do not collide.
#1992 discusses the driver choice.
- No Brevo webhook was configured on staging; delivery status there came
from "Refresh status".

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:25:29 +02:00

280 lines
10 KiB
Go

package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// TestServerSourceHasNoCachedRWCalls enforces issue #1287: after the
// follow-up to #1283, cmd/server/ must contain ZERO writer call sites.
// Specifically, no `cachedRW(`, no `mode=rw`, and no `sql.Open(...rw...)`
// in non-test source files. All schema migrations, backfills, and
// neighbor-edge persistence must live in cmd/ingestor or a shared
// package — the server is the read path.
func TestServerSourceHasNoCachedRWCalls(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read cmd/server dir: %v", err)
}
// Patterns that indicate write-side DB usage on the server.
patterns := []*regexp.Regexp{
regexp.MustCompile(`\bcachedRW\s*\(`),
regexp.MustCompile(`mode=rw`),
regexp.MustCompile(`sql\.Open\([^)]*\?[^)]*_journal_mode=WAL[^)]*\)`),
// The node directory is ingestor-owned (#1283/#1287); the
// server opens mode=ro since #1289 and may never write it.
//
// This deliberately matches ANY column rather than naming
// them. The column-specific form is what let #1598 through:
// #1324 added `UPDATE nodes SET multibyte_` after relocating
// PR #903's writer, then touchRelayLastSeen introduced a
// second write shape (`SET last_seen`) that the grep did not
// cover. It failed on every call for months with the error
// discarded at the call site, so nodes.last_seen silently
// degraded into an advert-age proxy. Enumerating shapes does
// not scale — forbid the table instead.
//
// Writers live in cmd/ingestor: Store.TouchRelayNodes (#1598)
// and RunMultibyteCapPersist (#1324, fed by a snapshot the
// server publishes via internal/mbcapqueue).
// Shapes are normalised before matching (see nodeTableWritePattern):
// optional OR-conflict clause, optional quoting, optional alias.
nodeTableWritePattern(`UPDATE(\s+OR\s+\w+)?`, `SET`),
nodeTableWritePattern(`INSERT\s+(OR\s+\w+\s+)?INTO`, ``),
nodeTableWritePattern(`REPLACE\s+INTO`, ``),
nodeTableWritePattern(`DELETE\s+FROM`, ``),
regexp.MustCompile(`\bpersistMultibyteCapability\s*\(`),
regexp.MustCompile(`\bmaybePersistMultibyteCapability\s*\(`),
}
violations := []string{}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if !strings.HasSuffix(name, ".go") {
continue
}
if strings.HasSuffix(name, "_test.go") {
continue
}
b, err := os.ReadFile(filepath.Join(".", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, p := range patterns {
if loc := p.FindIndex(b); loc != nil {
// Get line number
line := 1 + strings.Count(string(b[:loc[0]]), "\n")
violations = append(violations, fmt.Sprintf("%s:%d: %s", name, line, p.String()))
}
}
}
if len(violations) > 0 {
t.Errorf("cmd/server/ contains forbidden writer call sites (#1287):\n %s",
strings.Join(violations, "\n "))
}
}
// TestServerDBHasNoWriteMethods enforces the architectural invariant from
// issue #1283: cmd/server is the read path. All write/maintenance methods
// (PruneOldPackets, PruneOldMetrics, RemoveStaleObservers) MUST live on
// the ingestor's *Store, not on the server's *DB.
//
// Before the fix, these methods existed on cmd/server/*DB and used
// cachedRW(db.path) to acquire a write lock, racing with the ingestor's
// concurrent INSERTs and producing SQLITE_BUSY (the bug in #1283).
// After the fix, this test passes because the methods are gone.
func TestServerDBHasNoWriteMethods(t *testing.T) {
forbidden := []string{
"PruneOldPackets",
"PruneOldMetrics",
"RemoveStaleObservers",
"PurgeStaleObservers",
// #738 / one-click geo-prune: the DELETE must live on the
// ingestor's *Store. The server's HTTP handler now enqueues a
// marker file (see internal/prunequeue); it does not write.
"DeleteNodesByPubkeys",
// #1324 follow-up: PR #903 originally added these to *PacketStore
// (not *DB), and they UPDATEd nodes/inactive_nodes from a
// mode=ro handle. After relocation, the methods live in the
// ingestor's *Store (cmd/ingestor/multibyte_persist.go). Server
// must expose neither on *DB nor on *PacketStore — see the
// dedicated test below for *PacketStore.
}
typ := reflect.TypeOf((*DB)(nil))
for _, name := range forbidden {
if _, ok := typ.MethodByName(name); ok {
t.Errorf("server *DB exposes forbidden write method %q — must be relocated to ingestor (#1283)", name)
}
}
}
// TestServerDBConnIsReadOnly asserts that the *sql.DB the server opens
// cannot acquire a write lock. The server has always opened mode=ro, but
// before #1283 it routed around that by calling cachedRW(path) to get a
// second RW handle. After the fix, server-side writes are impossible
// because there is no helper to open a writable connection.
func TestServerDBConnIsReadOnly(t *testing.T) {
dir := t.TempDir()
path := dir + "/ro_invariant.db"
// Bootstrap a minimal DB with the ingestor-style WAL opener so the
// server can attach in read-only mode.
if err := bootstrapMinimalDB(t, path); err != nil {
t.Fatalf("bootstrap: %v", err)
}
d, err := OpenDB(path)
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer d.conn.Close()
_, err = d.conn.Exec(`INSERT INTO nodes (public_key, name) VALUES ('x','y')`)
if err == nil {
t.Fatalf("expected INSERT via server *DB to fail (read-only invariant)")
}
}
// bootstrapMinimalDB creates a tiny DB with the columns these tests
// need, opened with WAL so the read-only opener in OpenDB can attach.
// Kept in *_test.go so it does NOT add any write capability to the
// production server binary.
func bootstrapMinimalDB(tb testing.TB, path string) error {
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=5000", path)
rw, err := sql.Open("sqlite3", dsn)
if err != nil {
return err
}
defer rw.Close()
if _, err := rw.Exec(`CREATE TABLE IF NOT EXISTS nodes (public_key TEXT PRIMARY KEY, name TEXT)`); err != nil {
return err
}
// prepareStatements compiles eagerly under mattn; give it the rest of the
// surface it references so OpenDB gets far enough to test read-onlyness.
ensurePreparable(tb, rw)
return nil
}
// TestPacketStoreHasNoMultibytePersistMethods enforces the #1324 follow-up:
// PR #903 wired persistMultibyteCapability + maybePersistMultibyteCapability
// onto *PacketStore in cmd/server. Both executed UPDATEs on
// nodes/inactive_nodes from a mode=ro DB handle — impossible since #1289.
// After relocation the persistence lives in cmd/ingestor/*Store; the
// server only publishes a snapshot via internal/mbcapqueue. This test
// fails if a future change re-introduces these methods on *PacketStore.
func TestPacketStoreHasNoMultibytePersistMethods(t *testing.T) {
forbidden := []string{
"persistMultibyteCapability",
"maybePersistMultibyteCapability",
}
typ := reflect.TypeOf((*PacketStore)(nil))
for _, name := range forbidden {
if _, ok := typ.MethodByName(name); ok {
t.Errorf("server *PacketStore exposes forbidden write method %q — must be relocated to ingestor (#1324)", name)
}
}
}
// nodeTableWritePattern builds a matcher for DML against the
// ingestor-owned node directory. verb is the leading keyword(s); trailer
// is what must follow the table name (e.g. SET for UPDATE), or empty.
//
// Covers the shapes a plain `UPDATE nodes SET` regex misses:
//
// UPDATE OR REPLACE nodes SET ...
// UPDATE "nodes" SET ... / `nodes` / [nodes]
// UPDATE nodes AS n SET ...
// REPLACE INTO nodes ...
//
// Residual gap, stated rather than papered over: SQL assembled at
// runtime (fmt.Sprintf("UPDATE %s SET ...", tbl)) cannot be caught by
// source grepping. That is what TestServerDBConnIsReadOnly and
// TestServerDBHasNoWriteMethods are for — the handle physically cannot
// write and the write helpers do not exist on the type. This test is the
// cheap first line that names the offending file and line; those two are
// the structural backstop.
func nodeTableWritePattern(verb, trailer string) *regexp.Regexp {
const table = "[\"`\\[]?(nodes|inactive_nodes)[\"`\\]]?"
const alias = `(\s+(AS\s+)?[a-z]\w*)?`
expr := `(?i)` + verb + `\s+` + table + alias
if trailer != "" {
expr += `\s+` + trailer
} else {
expr += `\b`
}
return regexp.MustCompile(expr)
}
// TestOpenDBRefusesMissingDatabase pins the behaviour that makes mode=ro
// meaningful: OpenDB must fail on a path that does not exist rather than
// creating an empty database there.
//
// This is worth a test of its own because the guarantee is not obvious from the
// call site. github.com/mattn/go-sqlite3 always passes
// SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE to sqlite3_open_v2; what restricts it
// is the mode=ro parameter in the URI, which only takes effect because mattn's
// C wrapper ORs SQLITE_OPEN_URI into the flags (sqlite3.go _sqlite3_open_v2).
// Lose the file: prefix, or the URI flag, and this silently degrades into a
// read-write open that manufactures a fresh empty database — which the server
// would then happily serve. cmd/decrypt shipped exactly that bug for a while by
// building its DSN without the file: prefix.
func TestOpenDBRefusesMissingDatabase(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "absent.db")
db, err := OpenDB(missing)
if err == nil {
db.Close()
t.Fatal("OpenDB succeeded against a nonexistent database; mode=ro is not in effect")
}
// Neither the database itself nor a file literally named after the DSN
// (what happens when URI handling is off) may have been created.
entries, rerr := os.ReadDir(dir)
if rerr != nil {
t.Fatalf("read temp dir: %v", rerr)
}
for _, e := range entries {
t.Errorf("OpenDB created %q; a read-only open must not write anything", e.Name())
}
}
// TestUsersOpenIsTheOnlyServerWritePath pins the single exception to the
// read-only invariant (user management, opt-in): cmd/server may open a
// writable store only via users.Open, exactly once, and always passing the
// measurement DB path as forbidden.
func TestUsersOpenIsTheOnlyServerWritePath(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
call := regexp.MustCompile(`users\.Open\(`)
want := regexp.MustCompile(`users\.Open\(set\.dbPath, measurementDBPath\)`)
calls, good := 0, 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
calls += len(call.FindAll(src, -1))
good += len(want.FindAll(src, -1))
}
if calls != 1 || good != 1 {
t.Fatalf("users.Open calls in cmd/server: %d total, %d with the measurement DB forbidden; want exactly 1 and 1", calls, good)
}
}