Files
meshcore-analyzer/internal/users/export_test.go
T
efitenandClaude Opus 5.5 2456762179 feat: account data export and users.db backup (#2141)
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>
2026-10-08 22:16:49 +02:00

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)
}
}