mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 13:38:52 +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>
178 lines
7.2 KiB
Go
178 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func validUM() *UserManagementConfig {
|
|
return &UserManagementConfig{
|
|
Enabled: true,
|
|
AdminEmails: []string{" Boss@Example.org "},
|
|
PublicBaseURL: "https://scope.example.org/",
|
|
Mail: UserMailConfig{BrevoAPIKey: "xkeysib-abc", FromEmail: "noreply@example.org"},
|
|
}
|
|
}
|
|
|
|
func noEnv(string) string { return "" }
|
|
|
|
func TestResolveUserManagementDefaults(t *testing.T) {
|
|
set, err := resolveUserManagement(validUM(), filepath.Join("data", "meshcore.db"), noEnv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if set.dbPath != filepath.Join("data", "users.db") {
|
|
t.Errorf("dbPath = %q", set.dbPath)
|
|
}
|
|
if !set.adminEmails["boss@example.org"] {
|
|
t.Errorf("adminEmails not normalized: %v", set.adminEmails)
|
|
}
|
|
if set.baseURL.String() != "https://scope.example.org" || !set.secureCookie {
|
|
t.Errorf("baseURL = %q secure=%v", set.baseURL, set.secureCookie)
|
|
}
|
|
if set.sessionTTL != 30*24*time.Hour || set.provider != "brevo" || set.fromName != "CoreScope" {
|
|
t.Errorf("defaults: ttl=%v provider=%q fromName=%q", set.sessionTTL, set.provider, set.fromName)
|
|
}
|
|
}
|
|
|
|
func TestResolveUserManagementEnvWins(t *testing.T) {
|
|
env := map[string]string{"CORESCOPE_BREVO_API_KEY": "from-env", "CORESCOPE_BREVO_WEBHOOK_SECRET": "env-secret-0123456789"}
|
|
set, err := resolveUserManagement(validUM(), "meshcore.db", func(k string) string { return env[k] })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if set.brevoAPIKey != "from-env" || set.webhookSecret != "env-secret-0123456789" {
|
|
t.Fatalf("env not applied: %q %q", set.brevoAPIKey, set.webhookSecret)
|
|
}
|
|
}
|
|
|
|
func TestResolveUserManagementErrors(t *testing.T) {
|
|
cases := map[string]func(u *UserManagementConfig){
|
|
"publicBaseUrl": func(u *UserManagementConfig) { u.PublicBaseURL = "scope.example.org" },
|
|
"fromEmail": func(u *UserManagementConfig) { u.Mail.FromEmail = "" },
|
|
"Brevo API key": func(u *UserManagementConfig) { u.Mail.BrevoAPIKey = "" },
|
|
"adminEmails": func(u *UserManagementConfig) { u.AdminEmails = []string{"not-an-address"} },
|
|
"not supported": func(u *UserManagementConfig) { u.Mail.Provider = "smtp" },
|
|
"e2etest builds": func(u *UserManagementConfig) { u.Mail.Provider = "fake" },
|
|
"webhookSecret": func(u *UserManagementConfig) { u.Mail.WebhookSecret = "short" },
|
|
}
|
|
for want, mutate := range cases {
|
|
if want == "e2etest builds" && fakeMailerAllowed {
|
|
continue // the e2etest build accepts the fake provider by design
|
|
}
|
|
u := validUM()
|
|
mutate(u)
|
|
_, err := resolveUserManagement(u, "meshcore.db", noEnv)
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("%s: err = %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUserManagementEnabled(t *testing.T) {
|
|
var nilCfg *Config
|
|
if nilCfg.UserManagementEnabled() || (&Config{}).UserManagementEnabled() ||
|
|
(&Config{UserManagement: &UserManagementConfig{}}).UserManagementEnabled() {
|
|
t.Fatal("off states reported as enabled")
|
|
}
|
|
if !(&Config{UserManagement: &UserManagementConfig{Enabled: true}}).UserManagementEnabled() {
|
|
t.Fatal("enabled not reported")
|
|
}
|
|
}
|
|
|
|
func TestResolveChannelProposals(t *testing.T) {
|
|
u := validUM()
|
|
set, err := resolveUserManagement(u, "meshcore.db", noEnv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if set.proposals != (proposalSettings{}) {
|
|
t.Fatalf("absent block = %+v; want off", set.proposals)
|
|
}
|
|
u.ChannelProposals = &ChannelProposalsConfig{Enabled: true}
|
|
set, _ = resolveUserManagement(u, "meshcore.db", noEnv)
|
|
if set.proposals != (proposalSettings{enabled: true, maxPending: 100, maxApproved: 128, perUserPerDay: 5}) {
|
|
t.Fatalf("defaults = %+v", set.proposals)
|
|
}
|
|
u.ChannelProposals = &ChannelProposalsConfig{Enabled: true, MaxPending: 3, MaxApproved: 7, PerUserPerDay: 1}
|
|
set, _ = resolveUserManagement(u, "meshcore.db", noEnv)
|
|
if set.proposals != (proposalSettings{enabled: true, maxPending: 3, maxApproved: 7, perUserPerDay: 1}) {
|
|
t.Fatalf("explicit = %+v", set.proposals)
|
|
}
|
|
u.ChannelProposals = &ChannelProposalsConfig{Enabled: true, MaxPending: -1, MaxApproved: -1, PerUserPerDay: -1}
|
|
set, _ = resolveUserManagement(u, "meshcore.db", noEnv)
|
|
if set.proposals != (proposalSettings{enabled: true, maxPending: 100, maxApproved: 128, perUserPerDay: 5}) {
|
|
t.Fatalf("negative values = %+v; want defaults", set.proposals)
|
|
}
|
|
u.ChannelProposals = &ChannelProposalsConfig{Enabled: false, MaxApproved: 7}
|
|
set, _ = resolveUserManagement(u, "meshcore.db", noEnv)
|
|
if set.proposals.enabled {
|
|
t.Fatal("enabled false resolved as on")
|
|
}
|
|
}
|
|
|
|
func TestResolveNotifications(t *testing.T) {
|
|
u := validUM()
|
|
set, err := resolveUserManagement(u, "meshcore.db", noEnv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if set.notify != (notifySettings{}) {
|
|
t.Fatalf("absent block = %+v; want off", set.notify)
|
|
}
|
|
defaults := notifySettings{enabled: true, interval: 5 * time.Minute, perUserPerDay: 20, maxMailsPerDay: 100, maxWatchesPerUser: 50}
|
|
u.Notifications = &NotificationsConfig{Enabled: true}
|
|
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != defaults {
|
|
t.Fatalf("defaults = %+v", set.notify)
|
|
}
|
|
u.Notifications = &NotificationsConfig{Enabled: true, IntervalMinutes: 1, PerUserPerDay: 3, MaxMailsPerDay: 9, MaxWatchesPerUser: 2}
|
|
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != (notifySettings{enabled: true, interval: time.Minute, perUserPerDay: 3, maxMailsPerDay: 9, maxWatchesPerUser: 2}) {
|
|
t.Fatalf("explicit = %+v", set.notify)
|
|
}
|
|
u.Notifications = &NotificationsConfig{Enabled: true, IntervalMinutes: -5, PerUserPerDay: -1, MaxWatchesPerUser: -2}
|
|
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != defaults {
|
|
t.Fatalf("zero and negative values = %+v; want defaults", set.notify)
|
|
}
|
|
u.Notifications = &NotificationsConfig{Enabled: false, PerUserPerDay: 3}
|
|
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify.enabled {
|
|
t.Fatal("enabled false resolved as on")
|
|
}
|
|
}
|
|
|
|
func TestResolveUsersBackup(t *testing.T) {
|
|
u := validUM()
|
|
meas := filepath.Join("data", "meshcore.db")
|
|
defaults := backupSettings{enabled: true, dir: filepath.Join("data", "backups"), keep: 7}
|
|
set, err := resolveUserManagement(u, meas, noEnv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if set.backup != defaults {
|
|
t.Fatalf("absent block = %+v; want %+v", set.backup, defaults)
|
|
}
|
|
u.DBPath = filepath.Join("accounts", "users.db")
|
|
if set, _ = resolveUserManagement(u, meas, noEnv); set.backup.dir != filepath.Join("accounts", "backups") {
|
|
t.Fatalf("dir follows dbPath: %q", set.backup.dir)
|
|
}
|
|
u.DBPath = ""
|
|
off, on := false, true
|
|
u.Backup = &UsersBackupConfig{Enabled: &off, Dir: "x", Keep: 3}
|
|
if set, _ = resolveUserManagement(u, meas, noEnv); set.backup != (backupSettings{}) {
|
|
t.Fatalf("enabled false = %+v; want off", set.backup)
|
|
}
|
|
u.Backup = &UsersBackupConfig{Dir: " bk ", Keep: 3}
|
|
if set, _ = resolveUserManagement(u, meas, noEnv); set.backup != (backupSettings{enabled: true, dir: "bk", keep: 3}) {
|
|
t.Fatalf("enabled omitted = %+v; want on with dir bk, keep 3", set.backup)
|
|
}
|
|
u.Backup = &UsersBackupConfig{Enabled: &on, Dir: " ", Keep: -1}
|
|
if set, _ = resolveUserManagement(u, meas, noEnv); set.backup != defaults {
|
|
t.Fatalf("blank dir and negative keep = %+v; want defaults", set.backup)
|
|
}
|
|
u.Backup = &UsersBackupConfig{Keep: 0}
|
|
if set, _ = resolveUserManagement(u, meas, noEnv); set.backup.keep != 7 {
|
|
t.Fatalf("keep 0 = %d; want 7", set.backup.keep)
|
|
}
|
|
}
|