mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 17:09:54 +00:00
A small follow-up to parts A to E of #2128: users can download all data the instance holds about them, and the server backs up `users.db`. Stacked on #2140. Review the commits after that branch. ## The situation - An instance with accounts holds personal data, but a user has no way to get a copy (GDPR articles 15 and 20). Self-delete already exists. - `users.db` is the only file the server writes, and nothing backs it up: `GET /api/backup` snapshots the analyzer database only. ## What this PR adds **Data export.** `GET /api/account/export` and a "Download my data" button on the account page give one JSON file with: - the profile, including activation and a pending email change; - sessions (times and user agent); - synced settings, own proposals, notification prefs and watches; - audit rows where the user is actor or target, with other accounts as an id only; - the mail log. Password hash, session and link tokens and the unsubscribe token are left out: they are credentials. Each export is audited as `user.export`. **users.db backup** - Daily `VACUUM INTO` snapshots through `internal/users`, on by default when user management is on. They go to `backups/` next to `users.db`, the 7 newest are kept, the directory is 0700 and the files 0600. Rotation only touches `users-<timestamp>.db` files and never the snapshot it just wrote. Config: `userManagement.backup {enabled, dir, keep}`. - `GET /api/admin/users-backup`: an admin downloads a fresh snapshot to keep off the server; audited as `user.backup`. - `docs/user-guide/accounts.md` gains a restore procedure. It covers what a restore brings back (accounts deleted after the snapshot, old passwords, revoked sessions) and how to clean that up afterwards. Spec: [`docs/specs/2026-10-08-account-export-and-users-backup-design.md`](https://github.com/efiten/CoreScope/blob/feat/account-export-backup/docs/specs/2026-10-08-account-export-and-users-backup-design.md). ## Verification - `internal/users` with `-race` and the `cmd/server` suite pass locally, 30 new Go tests. The file-mode tests skip on Windows. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 27 of 27 steps locally. - On our production instance since 8 October 2026; the first snapshot there was 143 kB. ## Not in this PR - Importing an export into another instance. - Off-site upload of the snapshots. The admin download covers that by hand. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
3.6 KiB
Go
103 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
func TestUserManagementOffIsUnchanged(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
if srv.auth != nil {
|
|
t.Fatal("auth built without config")
|
|
}
|
|
for _, p := range []string{"/api/auth/me", "/api/admin/users", "/api/admin/audit", "/api/admin/stats", "/api/account/sessions", "/api/account/settings", "/api/account/export", "/api/admin/users-backup"} {
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
|
|
if w.Code != 404 {
|
|
t.Errorf("%s = %d with the feature off; want 404", p, w.Code)
|
|
}
|
|
}
|
|
base := httptest.NewRecorder()
|
|
router.ServeHTTP(base, httptest.NewRequest("GET", "/api/config/client", nil))
|
|
if strings.Contains(base.Body.String(), "userManagement") {
|
|
t.Fatal("client config mentions userManagement while off")
|
|
}
|
|
// An explicit {"enabled": false} block is byte-identical to no block.
|
|
dir := t.TempDir()
|
|
srv.cfg.UserManagement = &UserManagementConfig{Enabled: false}
|
|
if err := srv.initUserManagement(filepath.Join(dir, "meshcore.db")); err != nil || srv.auth != nil {
|
|
t.Fatalf("initUserManagement off: auth=%v err=%v", srv.auth, err)
|
|
}
|
|
off := httptest.NewRecorder()
|
|
router.ServeHTTP(off, httptest.NewRequest("GET", "/api/config/client", nil))
|
|
if off.Body.String() != base.Body.String() {
|
|
t.Fatal("client config differs between absent and disabled block")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "users.db")); !os.IsNotExist(err) {
|
|
t.Fatalf("users.db created next to the measurement DB with the feature off: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientConfigAdvertisesUserManagement(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
a, _ := newTestAuthService(t)
|
|
srv.auth = a
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/config/client", nil))
|
|
if !strings.Contains(w.Body.String(), `"userManagement":{"enabled":true}`) {
|
|
t.Fatalf("client config = %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Through the real router (RegisterRoutes, feature on): auth responses must
|
|
// never be cached by a CDN or browser.
|
|
func TestAuthRoutesThroughRealRouterAreNoStore(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
a, _ := newTestAuthService(t)
|
|
srv.auth = a
|
|
router := mux.NewRouter()
|
|
srv.RegisterRoutes(router)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/auth/me", nil))
|
|
if w.Code == 404 {
|
|
t.Fatalf("/api/auth/me = 404 with the feature on")
|
|
}
|
|
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
|
t.Fatalf("Cache-Control = %q; want no-store", got)
|
|
}
|
|
}
|
|
|
|
func TestInitUserManagementRefusesMeasurementDB(t *testing.T) {
|
|
dir := t.TempDir()
|
|
measurement := filepath.Join(dir, "meshcore.db")
|
|
os.WriteFile(measurement, nil, 0o644)
|
|
srv := &Server{cfg: &Config{UserManagement: &UserManagementConfig{
|
|
Enabled: true, DBPath: measurement, PublicBaseURL: testBase,
|
|
Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"},
|
|
}}}
|
|
err := srv.initUserManagement(measurement)
|
|
if err == nil || !strings.Contains(err.Error(), "measurement database") {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInitUserManagementCreatesUsersDB(t *testing.T) {
|
|
dir := t.TempDir()
|
|
srv := &Server{cfg: &Config{UserManagement: &UserManagementConfig{
|
|
Enabled: true, PublicBaseURL: testBase,
|
|
Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"},
|
|
}}}
|
|
if err := srv.initUserManagement(filepath.Join(dir, "meshcore.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer srv.closeUserManagement()
|
|
if _, err := os.Stat(filepath.Join(dir, "users.db")); err != nil {
|
|
t.Fatalf("users.db not created next to the measurement DB: %v", err)
|
|
}
|
|
}
|