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,231 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func exportedArticleIDs(t *testing.T, f *exportFixture) []float64 {
|
||||
t.Helper()
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
ids := []float64{}
|
||||
for _, raw := range exportList(t, exportJSON(t, files, "authored-content.json"), "articles") {
|
||||
ids = append(ids, raw.(map[string]any)["id"].(float64))
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestAccountExportDataArticleScope(t *testing.T) {
|
||||
t.Run("a draft by someone else credited to the user is excluded", 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: "Ines draft", CreatedBy: exportInesID}
|
||||
f.articles.credit(4, 1)
|
||||
|
||||
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) {
|
||||
t.Errorf("article ids %v", ids)
|
||||
}
|
||||
})
|
||||
t.Run("a published article by someone else credited to the user is included", 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: "Ines published", CreatedBy: exportInesID, IsOnline: true}
|
||||
f.articles.credit(4, 1)
|
||||
|
||||
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2, 4}) {
|
||||
t.Errorf("article ids %v", ids)
|
||||
}
|
||||
})
|
||||
t.Run("the user's own draft is included", func(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
|
||||
if ids := exportedArticleIDs(t, f); !slices.Equal(ids, []float64{1, 2}) || f.articles.articles[2].IsOnline {
|
||||
t.Errorf("article ids %v", ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountExportDataCoAuthorsAreReduced(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
|
||||
coAuthor := f.authors.seed(2, "Dario Costa", "dario-costa")
|
||||
coAuthor.Bio = "Plasterer from Porto"
|
||||
coAuthor.Website = new(string)
|
||||
coAuthor.AvatarID = idPointer(9)
|
||||
coAuthor.SocialLinks = []author.SocialLink{{URL: "https://example.com/dario"}}
|
||||
f.authors.authors[2] = coAuthor
|
||||
f.articles.credit(1, 1, 2)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
var keys []string
|
||||
collectKeys(exportJSON(t, files, "authored-content.json"), &keys)
|
||||
for _, key := range keys {
|
||||
if slices.Contains([]string{"bio", "website", "social_links", "avatar_id"}, key) {
|
||||
t.Errorf("authored-content.json exposes %q", key)
|
||||
}
|
||||
}
|
||||
first := exportList(t, exportJSON(t, files, "authored-content.json"), "articles")[0].(map[string]any)
|
||||
credits := first["authors"].([]any)
|
||||
if len(credits) != 2 || credits[1].(map[string]any)["name"] != "Dario Costa" || credits[1].(map[string]any)["slug"] != "dario-costa" {
|
||||
t.Errorf("credits %v", credits)
|
||||
}
|
||||
profile := exportJSON(t, files, "profile.json")["author"].(map[string]any)
|
||||
if _, ok := profile["social_links"]; !ok {
|
||||
t.Errorf("profile.json author must stay complete: %v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConcernsOtherPerson(t *testing.T) {
|
||||
ownAuthor := idPointer(5)
|
||||
targeted := func(kind string, id uint32) audit.Event {
|
||||
event := audit.NewEvent("x", audit.CategoryUsers)
|
||||
event.Target = audit.Target{Type: kind, ID: strconv.Itoa(int(id))}
|
||||
return event
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
event audit.Event
|
||||
own *uint32
|
||||
want bool
|
||||
}{
|
||||
{"own author", targeted(audit.TargetAuthor, 5), ownAuthor, false},
|
||||
{"another author", targeted(audit.TargetAuthor, 6), ownAuthor, true},
|
||||
{"an author when the user has none", targeted(audit.TargetAuthor, 5), nil, true},
|
||||
{"an author target without an id", audit.Event{Target: audit.Target{Type: audit.TargetAuthor, Name: "Dario"}}, ownAuthor, true},
|
||||
{"the user themself", targeted(audit.TargetUser, 7), ownAuthor, false},
|
||||
{"another user", targeted(audit.TargetUser, 8), ownAuthor, true},
|
||||
{"an invite", targeted(audit.TargetInvite, 3), ownAuthor, true},
|
||||
{"an article", targeted(audit.TargetArticle, 3), ownAuthor, false},
|
||||
{"no target", audit.Event{}, nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.event.ConcernsOtherPerson(7, tc.own); got != tc.want {
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataDropsEventsOnOtherAuthors(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
f.authors.seedLinked(1, "Marta Alves", "marta-alves", exportMartaID)
|
||||
own := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors)
|
||||
own.ID, own.Actor = 200, audit.Actor{Type: audit.ActorUser, ID: idPointer(exportMartaID)}
|
||||
own.Target = audit.NewTarget(audit.TargetAuthor, 1, "Marta Alves")
|
||||
other := own
|
||||
other.ID = 201
|
||||
other.Target = audit.NewTarget(audit.TargetAuthor, 2, "Dario Costa")
|
||||
f.events.events = append(f.events.events, own, other)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
ids := []float64{}
|
||||
for _, raw := range exportList(t, exportJSON(t, files, "account-activity.json"), "events") {
|
||||
ids = append(ids, raw.(map[string]any)["id"].(float64))
|
||||
}
|
||||
if !slices.Contains(ids, 200) || slices.Contains(ids, 201) {
|
||||
t.Errorf("event ids %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataEncodesStraightIntoTheArchive(t *testing.T) {
|
||||
f := newExportFixture(t)
|
||||
|
||||
files := openExportArchive(t, f.export(t, exportMartaID))
|
||||
|
||||
for name, content := range files {
|
||||
if !bytes.HasSuffix(content, []byte("}\n")) || bytes.HasSuffix(content, []byte("\n\n")) || !bytes.HasPrefix(content, []byte("{\n \"")) {
|
||||
t.Errorf("%s is not indented JSON ending with a single newline", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExportDataConcurrencyCap(t *testing.T) {
|
||||
newBlocked := func(t *testing.T) (*exportFixture, chan struct{}, chan struct{}) {
|
||||
f := newExportFixtureWithCap(t, 1)
|
||||
entered := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
once := false
|
||||
f.images.onListByUploader = func() {
|
||||
if once {
|
||||
return
|
||||
}
|
||||
once = true
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
}
|
||||
return f, entered, release
|
||||
}
|
||||
t.Run("a build waits for a free slot and gives up when its request ends", func(t *testing.T) {
|
||||
f, entered, release := newBlocked(t)
|
||||
firstDone := make(chan *httptest.ResponseRecorder)
|
||||
go func() { firstDone <- f.export(t, exportMartaID) }()
|
||||
<-entered
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodGet, exportRoute, nil)
|
||||
req.AddCookie(displayNameCookie(t, f.token, exportInesID, exportTokenVer))
|
||||
waiting := httptest.NewRecorder()
|
||||
|
||||
f.router.ServeHTTP(waiting, req)
|
||||
|
||||
if waiting.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("waiting build = %d, want 503", waiting.Code)
|
||||
}
|
||||
close(release)
|
||||
if first := <-firstDone; first.Code != http.StatusOK {
|
||||
t.Errorf("first build = %d", first.Code)
|
||||
}
|
||||
if len(f.recorder.events) != 1 {
|
||||
t.Errorf("events %d, want only the first build's", len(f.recorder.events))
|
||||
}
|
||||
if retried := f.export(t, exportInesID); retried.Code != http.StatusOK {
|
||||
t.Errorf("refused user must keep the slot free, got %d", retried.Code)
|
||||
}
|
||||
})
|
||||
t.Run("the slot is released when a build fails", func(t *testing.T) {
|
||||
f := newExportFixtureWithCap(t, 1)
|
||||
f.images.fail.failNext("ListByUploader")
|
||||
|
||||
failed := f.export(t, exportMartaID)
|
||||
next := f.export(t, exportMartaID)
|
||||
|
||||
if failed.Code != http.StatusInternalServerError || next.Code != http.StatusOK {
|
||||
t.Errorf("failed %d then %d", failed.Code, next.Code)
|
||||
}
|
||||
})
|
||||
t.Run("the use case returns the context error", func(t *testing.T) {
|
||||
f, entered, release := newBlocked(t)
|
||||
usecase := 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, 1)
|
||||
f.images.onListByUploader = func() { entered <- struct{}{}; <-release }
|
||||
done := make(chan error)
|
||||
go func() { _, err := usecase.Export(context.Background(), exportMartaID); done <- err }()
|
||||
<-entered
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := usecase.Export(ctx, exportInesID)
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("first build %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user