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 }