feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -0,0 +1,528 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user