529 lines
19 KiB
Go
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
|
|
}
|