mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 06:58:42 +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>
162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
package users
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAuditAllForHasNoCap(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "a@example.org", "Aaa")
|
|
uid := u.ID
|
|
for i := 0; i < 120; i++ {
|
|
if err := st.Audit(&uid, "user.login", &uid, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clk.Advance(time.Second)
|
|
}
|
|
capped, err := st.AuditFor(uid, 0)
|
|
if err != nil || len(capped) != 100 {
|
|
t.Fatalf("AuditFor default = %d, %v; want 100", len(capped), err)
|
|
}
|
|
all, err := st.AuditAllFor(uid)
|
|
if err != nil || len(all) != 120 {
|
|
t.Fatalf("AuditAllFor = %d, %v; want 120", len(all), err)
|
|
}
|
|
if !all[0].At.After(all[119].At) {
|
|
t.Fatal("AuditAllFor is not newest first")
|
|
}
|
|
}
|
|
|
|
func TestMailAllForUserHasNoCap(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "a@example.org", "Aaa")
|
|
uid := u.ID
|
|
for i := 0; i < 60; i++ {
|
|
if _, err := st.LogMail(&uid, "a@example.org", "reset", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := st.LogMail(&uid, "a@example.org", "activate", "<m1>"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := st.RecordMailEvent("<m1>", "delivered", clk.Now(), ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capped, err := st.MailForUser(uid, 0)
|
|
if err != nil || len(capped) != 50 {
|
|
t.Fatalf("MailForUser default = %d, %v; want 50", len(capped), err)
|
|
}
|
|
all, err := st.MailAllForUser(uid)
|
|
if err != nil || len(all) != 61 {
|
|
t.Fatalf("MailAllForUser = %d, %v; want 61", len(all), err)
|
|
}
|
|
if all[0].Purpose != "activate" || len(all[0].Events) != 1 || all[0].Events[0].Event != "delivered" {
|
|
t.Fatalf("newest mail = %+v", all[0])
|
|
}
|
|
}
|
|
|
|
func TestAllProposalsByUserHasNoCap(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "a@example.org", "Aaa")
|
|
tx, err := st.db.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i <= ProposalListMax; i++ {
|
|
if _, err := tx.Exec(`INSERT INTO proposals (kind, subject, status, proposer_id, created_at) VALUES (?, ?, 'rejected', ?, ?)`,
|
|
KindHashtagChannel, fmt.Sprintf("#p%d", i), u.ID, unix(clk.Now())); err != nil {
|
|
tx.Rollback()
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capped, err := st.ProposalsByUser(u.ID)
|
|
if err != nil || len(capped) != ProposalListMax {
|
|
t.Fatalf("ProposalsByUser = %d, %v; want %d", len(capped), err, ProposalListMax)
|
|
}
|
|
all, err := st.AllProposalsByUser(u.ID)
|
|
if err != nil || len(all) != ProposalListMax+1 {
|
|
t.Fatalf("AllProposalsByUser = %d, %v; want %d", len(all), err, ProposalListMax+1)
|
|
}
|
|
}
|
|
|
|
func TestSettingsRecordFor(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "a@example.org", "Aaa")
|
|
if r, err := st.SettingsRecordFor(u.ID); err != nil || r != nil {
|
|
t.Fatalf("without a document = %+v, %v; want nil, nil", r, err)
|
|
}
|
|
doc := `{"v":1,"keys":{"meshcore-theme":"dark"}}`
|
|
if _, err := st.PutSettings(u.ID, SettingsVersion{}, doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r, err := st.SettingsRecordFor(u.ID)
|
|
if err != nil || r == nil {
|
|
t.Fatalf("SettingsRecordFor = %+v, %v", r, err)
|
|
}
|
|
if r.Doc != doc || r.Version.Revision != 1 || r.Version.Generation == "" || !r.UpdatedAt.Equal(clk.Now()) {
|
|
t.Fatalf("record = %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestStoredNotifyPrefsNeverCreates(t *testing.T) {
|
|
st, _ := newTestStore(t)
|
|
u := mustCreate(t, st, "a@example.org", "Aaa")
|
|
if p, err := st.StoredNotifyPrefs(u.ID); err != nil || p != nil {
|
|
t.Fatalf("before first use = %+v, %v; want nil, nil", p, err)
|
|
}
|
|
if _, err := st.getPrefs(u.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("StoredNotifyPrefs created a row: %v", err)
|
|
}
|
|
want, err := st.NotifyPrefsFor(u.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, err := st.StoredNotifyPrefs(u.ID)
|
|
if err != nil || p == nil || p.UnsubToken != want.UnsubToken || p.Enabled != want.Enabled {
|
|
t.Fatalf("after NotifyPrefsFor = %+v, %v; want %+v", p, err, want)
|
|
}
|
|
}
|
|
|
|
func TestPendingEmailChange(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "a@example.org", "Aaa")
|
|
if got, err := st.PendingEmailChange(u.ID); err != nil || got != "" {
|
|
t.Fatalf("none issued = %q, %v; want empty", got, err)
|
|
}
|
|
if _, err := st.IssueToken(u.ID, PurposeReset, time.Hour, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.IssueToken(u.ID, PurposeEmailChange, time.Hour, "first@example.org"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.IssueToken(u.ID, PurposeEmailChange, time.Hour, "second@example.org"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := st.PendingEmailChange(u.ID); err != nil || got != "second@example.org" {
|
|
t.Fatalf("pending = %q, %v; want the newest address", got, err)
|
|
}
|
|
clk.Advance(2 * time.Hour)
|
|
if got, err := st.PendingEmailChange(u.ID); err != nil || got != "" {
|
|
t.Fatalf("after expiry = %q, %v; want empty", got, err)
|
|
}
|
|
if _, err := st.IssueToken(u.ID, PurposeEmailChange, time.Hour, "third@example.org"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other := mustCreate(t, st, "b@example.org", "Bbb")
|
|
if got, err := st.PendingEmailChange(other.ID); err != nil || got != "" {
|
|
t.Fatalf("other user = %q, %v; want empty", got, err)
|
|
}
|
|
if err := st.InvalidateTokens(u.ID, PurposeEmailChange); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := st.PendingEmailChange(u.ID); err != nil || got != "" {
|
|
t.Fatalf("after invalidation = %q, %v; want empty", got, err)
|
|
}
|
|
}
|