Files
RenewCMS/test/account_export_data_scope_test.go
T

232 lines
8.1 KiB
Go

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)
}
})
}