Files
RenewCMS/test/account_export_data_http_test.go
T

529 lines
19 KiB
Go

package test
import (
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
const (
exportMartaID uint32 = 7
exportInesID uint32 = 8
exportUnverifID uint32 = 9
exportTokenVer uint32 = 3
exportSecret = "hashed:secret-hash"
exportRoute = "/v1/user/me/export"
)
var exportFiles = []string{"account-activity.json", "authored-content.json", "profile.json", "uploaded-media.json"}
type exportClock struct{ current time.Time }
func (c *exportClock) now() time.Time { return c.current }
func (c *exportClock) advance(d time.Duration) { c.current = c.current.Add(d) }
type exportAuditFake struct {
events []audit.Event
}
var _ audit.Repository = &exportAuditFake{}
func (f *exportAuditFake) Append(event audit.Event) (audit.Event, error) {
f.events = append(f.events, event)
return event, nil
}
func (f *exportAuditFake) List(filter audit.Filter) (audit.Page, error) {
items := []audit.Event{}
for _, event := range f.events {
if filter.Actor != nil {
if event.Actor.Type != filter.Actor.Type || filter.Actor.ID != nil && (event.Actor.ID == nil || *event.Actor.ID != *filter.Actor.ID) {
continue
}
}
items = append(items, event)
}
return audit.Page{Items: items, Total: len(items)}, nil
}
func (f *exportAuditFake) Summary(audit.SummaryQuery) (audit.Summary, error) {
return audit.Summary{}, nil
}
func (f *exportAuditFake) DeleteOlderThan(time.Time) (int, error) { return 0, nil }
func (f *exportAuditFake) DeleteOldest(int, time.Time) (int, error) { return 0, nil }
func (f *exportAuditFake) SizeBytes() (int64, error) { return 0, nil }
type exportFixture struct {
clock *exportClock
users *mockUserRepo
roles *mockRoleRepo
authors *mockAuthorRepo
articles *mockArticleRepo
entries *mockEntryRepo
images *mockImageRepo
keys *mockAPIKeyRepo
types *mockContentTypeRepo
events *exportAuditFake
recorder *recordingRecorder
token *authtoken.Auth
router *chi.Mux
}
const exportDefaultCap = 2
func newExportFixture(t *testing.T) *exportFixture {
t.Helper()
return newExportFixtureWithCap(t, exportDefaultCap)
}
func newExportFixtureWithCap(t *testing.T, maxConcurrent int) *exportFixture {
t.Helper()
validator.InitValidator()
articles := newMockArticleRepo()
f := &exportFixture{
clock: &exportClock{current: time.Date(2026, time.October, 29, 9, 41, 0, 0, time.UTC)},
users: newMockUserRepo(),
roles: newSeededMockRoleRepo(),
authors: newMockAuthorRepo(articles),
articles: articles,
entries: newMockEntryRepo(),
images: newMockImageRepo(),
keys: newMockAPIKeyRepo(),
types: newMockContentTypeRepo(),
events: &exportAuditFake{},
recorder: &recordingRecorder{},
token: authtoken.New([]byte("test-secret")),
}
editor := f.roles.byName(role.WriterName)
created := time.Date(2026, time.March, 2, 10, 0, 0, 0, time.UTC)
f.users.users[exportMartaID] = domainUser.User{
ID: exportMartaID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@renew.example",
Password: exportSecret, PasswordResetCode: "reset-code-xyz", VerificationCode: "verification-code-xyz",
EmailChangeCode: "email-change-code-xyz", PendingEmail: "marta.new@renew.example", TokenVersion: exportTokenVer,
RoleID: editor.ID, IsVerified: true, CreatedAt: created, UpdatedAt: created,
}
f.users.users[exportInesID] = domainUser.User{
ID: exportInesID, Username: "ines", DisplayName: "Ines Duarte", Email: "ines@renew.example",
Password: "hashed:ines", RoleID: editor.ID, IsVerified: true, TokenVersion: exportTokenVer,
}
f.users.users[exportUnverifID] = domainUser.User{ID: exportUnverifID, Username: "newbie", Email: "newbie@renew.example", Password: "hashed:n", RoleID: editor.ID}
f.users.nextID = exportUnverifID
f.types.types[1] = contenttype.ContentType{ID: 1, Key: "testimonial", Name: "Testimonial"}
f.seedMartaContent()
f.seedInesContent()
getUser := useCases.NewGetUserUseCase(f.users)
handler := user.NewExportHandler(useCases.NewExportUserDataUseCase(
f.users, f.roles, f.authors, f.keys, f.articles, f.entries, f.types, f.images, f.events, f.clock.now, f.recorder, maxConcurrent,
))
f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(f.token))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.With(middleware.NewUserSuccessLimiterAt(15*time.Minute, f.clock.now)).Get("/me/export", handler.Export)
})
return f
}
func (f *exportFixture) seedMartaContent() {
stamp := time.Date(2026, time.April, 3, 8, 0, 0, 0, time.UTC)
f.articles.articles[1] = article.Article{ID: 1, Title: "Limewash basics", Body: "Mix lime putty.", Slug: "limewash-basics", SiteID: 1, IsOnline: true, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
f.articles.articles[2] = article.Article{ID: 2, Title: "Draft on tadelakt", Body: "Soap and polish.", Slug: "draft-tadelakt", SiteID: 1, CreatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
f.entries.entries[1] = entry.Entry{ID: 1, ContentTypeID: 1, Data: map[string]any{"quote": "Loved it"}, CreatedBy: exportMartaID, UpdatedBy: exportMartaID, CreatedAt: stamp, UpdatedAt: stamp}
for i, name := range []string{"wall.png", "floor.jpg", "ceiling.webp"} {
f.images.images[uint32(i+1)] = image.Image{ID: uint32(i + 1), Path: "/static/uploadedImages/stored-" + name, Filename: name, Size: 2048, ContentType: "image/png", SHA256: strings.Repeat("a", 63) + string(rune('1'+i)), UploadedByID: exportMartaID, CreatedAt: stamp}
}
f.keys.keys[1] = apikey.APIKey{ID: 1, UserID: exportMartaID, Name: "CI deploy", Prefix: "rcms_ab12", HashedKey: "sha-of-the-key", CreatedAt: stamp}
for i := range 5 {
event := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
event.ID = uint64(i + 1)
event.CreatedAt = stamp.Add(time.Duration(i) * time.Hour)
event.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
event.Target = audit.Target{Type: audit.TargetUser, ID: "7", Name: "Marta Alves"}
event.Source = audit.SourceWeb
f.events.events = append(f.events.events, event)
}
}
func (f *exportFixture) seedInesContent() {
stamp := time.Date(2026, time.May, 3, 8, 0, 0, 0, time.UTC)
f.articles.articles[3] = article.Article{ID: 3, Title: "Ines private plan", Body: "Secret ines text", Slug: "ines-plan", SiteID: 1, CreatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
f.entries.entries[2] = entry.Entry{ID: 2, ContentTypeID: 1, Data: map[string]any{"quote": "Ines quote"}, CreatedBy: exportInesID, UpdatedBy: exportInesID, CreatedAt: stamp, UpdatedAt: stamp}
f.images.images[10] = image.Image{ID: 10, Path: "/static/uploadedImages/ines.png", Filename: "ines.png", Size: 10, ContentType: "image/png", UploadedByID: exportInesID, CreatedAt: stamp}
f.keys.keys[2] = apikey.APIKey{ID: 2, UserID: exportInesID, Name: "ines key", Prefix: "rcms_zz99", HashedKey: "ines-hash", CreatedAt: stamp}
targeted := audit.NewEvent(audit.ActionUserChangeRole, audit.CategoryUsers)
targeted.ID = 100
targeted.CreatedAt = stamp
targeted.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID), Name: "Marta Alves"}
targeted.Target = audit.Target{Type: audit.TargetUser, ID: "8", Name: "Ines Duarte"}
targeted.Source = audit.SourceWeb
inesOwn := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
inesOwn.ID = 101
inesOwn.Actor = audit.Actor{Type: audit.ActorUser, ID: idPointer(exportInesID), Name: "Ines Duarte"}
inesOwn.Source = audit.SourceWeb
anonymous := audit.NewEvent(audit.ActionLoginFailed, audit.CategoryAuth)
anonymous.ID = 102
anonymous.Actor = audit.AnonymousActor("marta")
anonymous.Outcome = audit.OutcomeFailed
anonymous.Source = audit.SourceWeb
f.events.events = append(f.events.events, targeted, inesOwn, anonymous)
}
func (f *exportFixture) export(t *testing.T, userID uint32) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, userID, exportTokenVer))
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func openExportArchive(t *testing.T, rec *httptest.ResponseRecorder) map[string][]byte {
t.Helper()
body := rec.Body.Bytes()
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("response is not a zip: %v", err)
}
files := map[string][]byte{}
for _, file := range reader.File {
opened, err := file.Open()
if err != nil {
t.Fatalf("open %s: %v", file.Name, err)
}
content, err := io.ReadAll(opened)
_ = opened.Close()
if err != nil {
t.Fatalf("read %s: %v", file.Name, err)
}
files[file.Name] = content
}
return files
}
func exportJSON(t *testing.T, files map[string][]byte, name string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(files[name], &decoded); err != nil {
t.Fatalf("%s is not a JSON object: %v", name, err)
}
return decoded
}
func exportList(t *testing.T, decoded map[string]any, key string) []any {
t.Helper()
list, ok := decoded[key].([]any)
if !ok {
t.Fatalf("expected a list under %q, got %T", key, decoded[key])
}
return list
}
func collectKeys(value any, into *[]string) {
switch typed := value.(type) {
case map[string]any:
for key, nested := range typed {
*into = append(*into, key)
collectKeys(nested, into)
}
case []any:
for _, nested := range typed {
collectKeys(nested, into)
}
}
}
func assertExportStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status || strings.TrimSpace(rec.Body.String()) != body {
t.Fatalf("expected %d %q, got %d %q", status, body, rec.Code, rec.Body.String())
}
}
func TestAccountExportDataEndpointHTTP(t *testing.T) {
t.Run("A first export returns the archive", func(t *testing.T) {
f := newExportFixture(t)
rec := f.export(t, exportMartaID)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="renewcms-export-marta-2026-10-29.zip"` {
t.Errorf("Content-Disposition %q", got)
}
if rec.Header().Get("Content-Type") != "application/zip" || rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("headers %v", rec.Header())
}
if rec.Header().Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length %q, body %d", rec.Header().Get("Content-Length"), rec.Body.Len())
}
files := openExportArchive(t, rec)
names := []string{}
for name := range files {
names = append(names, name)
}
slices.Sort(names)
if !slices.Equal(names, exportFiles) {
t.Fatalf("files %v", names)
}
profile := exportJSON(t, files, "profile.json")
if profile["username"] != "marta" || profile["email"] != "marta@renew.example" || profile["pending_email"] != "marta.new@renew.example" {
t.Errorf("profile %v", profile)
}
if roleBlock, _ := profile["role"].(map[string]any); roleBlock["name"] != role.WriterName {
t.Errorf("role %v", profile["role"])
}
if profile["author"] != nil {
t.Errorf("author should be null, got %v", profile["author"])
}
keys := exportList(t, profile, "api_keys")
if len(keys) != 1 || keys[0].(map[string]any)["prefix"] != "rcms_ab12" || keys[0].(map[string]any)["name"] != "CI deploy" {
t.Errorf("api keys %v", keys)
}
authored := exportJSON(t, files, "authored-content.json")
if len(exportList(t, authored, "articles")) != 2 || len(exportList(t, authored, "entries")) != 1 {
t.Errorf("authored %v", authored)
}
entryBlock := exportList(t, authored, "entries")[0].(map[string]any)
if entryBlock["content_type"] != "testimonial" {
t.Errorf("entry %v", entryBlock)
}
media := exportJSON(t, files, "uploaded-media.json")
references := exportList(t, media, "images")
if len(references) != 3 || references[0].(map[string]any)["mime"] != "image/png" {
t.Errorf("media %v", media)
}
activity := exportJSON(t, files, "account-activity.json")
if got := len(exportList(t, activity, "events")); got != 5 {
t.Errorf("expected 5 events, got %d", got)
}
if len(f.recorder.events) != 1 {
t.Fatalf("expected one audit event, got %+v", f.recorder.events)
}
recorded := f.recorder.events[0]
if recorded.Action != "user.export_data" || recorded.Category != audit.CategoryUsers || recorded.Target.ID != "7" {
t.Errorf("event %+v", recorded)
}
})
t.Run("The archive never contains secrets and is pretty-printed", func(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
forbiddenKeys := []string{"password", "token_version", "hashed_key", "verification_code", "password_reset_code", "email_change_code"}
forbiddenText := []string{exportSecret, "reset-code-xyz", "verification-code-xyz", "email-change-code-xyz", "sha-of-the-key"}
for name, content := range files {
var decoded any
if err := json.Unmarshal(content, &decoded); err != nil {
t.Fatalf("%s: %v", name, err)
}
var keys []string
collectKeys(decoded, &keys)
for _, key := range keys {
if slices.Contains(forbiddenKeys, key) || strings.Contains(key, "code") || strings.Contains(key, "token") {
t.Errorf("%s exposes key %q", name, key)
}
}
for _, text := range forbiddenText {
if bytes.Contains(content, []byte(text)) {
t.Errorf("%s contains %q", name, text)
}
}
if !bytes.Contains(content, []byte("\n ")) {
t.Errorf("%s is not pretty-printed", name)
}
}
})
t.Run("A second export within 15 minutes is refused", func(t *testing.T) {
f := newExportFixture(t)
f.export(t, exportMartaID)
f.clock.advance(3 * time.Minute)
listsBefore := f.images.uploaderLists
rec := f.export(t, exportMartaID)
assertExportStatusAndBody(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.")
if rec.Header().Get("Retry-After") != "720" {
t.Errorf("Retry-After %q", rec.Header().Get("Retry-After"))
}
if f.images.uploaderLists != listsBefore || len(f.recorder.events) != 1 {
t.Errorf("an archive was built or an event recorded: lists %d events %d", f.images.uploaderLists, len(f.recorder.events))
}
})
t.Run("The slot is per user and expires", func(t *testing.T) {
f := newExportFixture(t)
f.export(t, exportMartaID)
f.clock.advance(15 * time.Minute)
f.export(t, exportInesID)
f.clock.advance(time.Minute)
marta := f.export(t, exportMartaID)
ines := f.export(t, exportInesID)
if marta.Code != http.StatusOK {
t.Errorf("marta expected 200, got %d", marta.Code)
}
if ines.Code != http.StatusTooManyRequests {
t.Errorf("ines expected 429, got %d", ines.Code)
}
})
t.Run("A failed export does not consume the slot", func(t *testing.T) {
f := newExportFixture(t)
f.images.fail.failNext("ListByUploader")
failed := f.export(t, exportMartaID)
if failed.Code != http.StatusInternalServerError || strings.HasPrefix(failed.Header().Get("Content-Type"), "application/zip") || failed.Header().Get("Content-Disposition") != "" {
t.Errorf("expected a plain 500, got %d %v", failed.Code, failed.Header())
}
if strings.Contains(failed.Body.String(), "PK") || len(f.recorder.events) != 0 {
t.Errorf("body %q events %d", failed.Body.String(), len(f.recorder.events))
}
retried := f.export(t, exportMartaID)
if retried.Code != http.StatusOK {
t.Errorf("expected 200 after the failure is gone, got %d", retried.Code)
}
})
t.Run("Only a real verified session may export", func(t *testing.T) {
cases := []struct {
name string
status int
text string
build func(f *exportFixture) *http.Request
}{
{"nobody (no cookie)", http.StatusUnauthorized, "You must be logged in to access this resource.", func(f *exportFixture) *http.Request {
return httptest.NewRequest(http.MethodGet, exportRoute, nil)
}},
{"an API key bearer", http.StatusForbidden, "This action needs a real session.", func(f *exportFixture) *http.Request {
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.Header.Set("Authorization", "Bearer rcms_anything")
return req
}},
{"an unverified user", http.StatusForbidden, "You must verify your account to access this resource.", func(f *exportFixture) *http.Request {
req := httptest.NewRequest(http.MethodGet, exportRoute, nil)
req.AddCookie(displayNameCookie(t, f.token, exportUnverifID, 0))
return req
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newExportFixture(t)
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, c.build(f))
assertExportStatusAndBody(t, rec, c.status, c.text)
})
}
})
t.Run("Someone else's data is never included", func(t *testing.T) {
f := newExportFixture(t)
files := openExportArchive(t, f.export(t, exportMartaID))
for name, content := range files {
lowered := strings.ToLower(string(content))
for _, leak := range []string{"ines", "duarte", "secret ines"} {
if strings.Contains(lowered, leak) {
t.Errorf("%s mentions %q", name, leak)
}
}
}
activity := exportJSON(t, files, "account-activity.json")
for _, raw := range exportList(t, activity, "events") {
event := raw.(map[string]any)
if event["action"] == "auth.login_failed" || event["action"] == "user.change_role" {
t.Errorf("unexpected event %v", event)
}
}
})
t.Run("A user with a linked author profile", func(t *testing.T) {
f := newExportFixture(t)
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
f.articles.articles[4] = article.Article{ID: 4, Title: "Credited piece", Body: "Co-written", Slug: "credited", SiteID: 1, CreatedBy: exportInesID, IsOnline: true}
f.articles.credit(4, 1)
f.articles.credit(3)
files := openExportArchive(t, f.export(t, exportMartaID))
profile := exportJSON(t, files, "profile.json")
authorBlock, _ := profile["author"].(map[string]any)
if authorBlock["slug"] != "marta-alves" {
t.Errorf("author %v", profile["author"])
}
articles := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")
ids := []float64{}
for _, raw := range articles {
ids = append(ids, raw.(map[string]any)["id"].(float64))
}
if !slices.Contains(ids, 4) || len(ids) != 3 {
t.Errorf("article ids %v", ids)
}
})
}
func (f *exportAuditFake) AnonymiseUser(userID uint32, _ *uint32, name string) error {
for i, event := range f.events {
if event.Actor.Type == audit.ActorUser && event.Actor.ID != nil && *event.Actor.ID == userID {
f.events[i].Actor.Name = name
f.events[i].IP = nil
f.events[i].UserAgent = nil
}
if event.Target.Type == audit.TargetUser && event.Target.ID == strconv.FormatUint(uint64(userID), 10) {
f.events[i].Target.Name = name
}
}
return nil
}