1138 lines
43 KiB
Go
1138 lines
43 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api"
|
|
validator "RenewCMS/api/pkg/validator"
|
|
"RenewCMS/internal/domain/article"
|
|
"RenewCMS/internal/domain/audit"
|
|
"RenewCMS/internal/domain/author"
|
|
"RenewCMS/internal/domain/contenttype"
|
|
"RenewCMS/internal/domain/entry"
|
|
"RenewCMS/internal/domain/image"
|
|
"RenewCMS/internal/domain/purge"
|
|
"RenewCMS/internal/domain/site"
|
|
"RenewCMS/internal/domain/user"
|
|
"RenewCMS/internal/infrastructure/config"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"RenewCMS/api/pkg/authtoken"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
hardeningAdminID uint32 = 2
|
|
hardeningInternalBody = "Internal server error\n"
|
|
hardeningEventTypeID uint32 = 1
|
|
hardeningGalleryID uint32 = 2
|
|
)
|
|
|
|
var hardeningEpoch = time.Date(2026, time.September, 1, 8, 0, 0, 0, time.UTC)
|
|
|
|
type hardeningArticles struct {
|
|
*mockArticleRepo
|
|
repointCalls int
|
|
failRepointOn int
|
|
}
|
|
|
|
func (a *hardeningArticles) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
|
|
a.repointCalls++
|
|
if a.failRepointOn > 0 && a.repointCalls == a.failRepointOn {
|
|
return nil, errRepositoryFailure
|
|
}
|
|
return a.mockArticleRepo.RepointImage(fromImageIDs, toImageID)
|
|
}
|
|
|
|
type hardeningEnv struct {
|
|
images *mockImageRepo
|
|
articles *hardeningArticles
|
|
sites *mockSiteRepo
|
|
authors *mockAuthorRepo
|
|
types *mockContentTypeRepo
|
|
entries *mockEntryRepo
|
|
transactor *mockMergeTransactor
|
|
storage *mergeRecordingStorage
|
|
variants *mergeRecordingVariantFiles
|
|
recorder *recordingRecorder
|
|
settings *mockPurgeSettingsRepo
|
|
logs *bytes.Buffer
|
|
users *mockUserRepo
|
|
token *authtoken.Auth
|
|
router http.Handler
|
|
}
|
|
|
|
func newHardeningEnv(t *testing.T) *hardeningEnv {
|
|
t.Helper()
|
|
validator.InitValidator()
|
|
store := newMockArticleRepo()
|
|
env := &hardeningEnv{
|
|
images: newMockImageRepo(),
|
|
articles: &hardeningArticles{mockArticleRepo: store},
|
|
sites: newMockSiteRepo(store),
|
|
authors: newMockAuthorRepo(store),
|
|
types: newMockContentTypeRepo(),
|
|
entries: newMockEntryRepo(),
|
|
recorder: &recordingRecorder{},
|
|
settings: newMockPurgeSettingsRepo(),
|
|
logs: &bytes.Buffer{},
|
|
users: newMockUserRepo(),
|
|
token: authtoken.New([]byte("test-secret")),
|
|
}
|
|
env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, RoleID: adminRoleID}
|
|
env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries)
|
|
env.storage = newMergeRecordingStorage(env.transactor)
|
|
env.variants = newMergeRecordingVariantFiles(env.transactor)
|
|
env.mount()
|
|
return env
|
|
}
|
|
|
|
func (e *hardeningEnv) mount() {
|
|
logger := log.New(e.logs, "", 0)
|
|
merge := useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.variants, e.recorder, e.types, e.entries, e.transactor, logger)
|
|
container := &api.UseCases{
|
|
Token: e.token,
|
|
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
|
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
|
|
MergeMediaUseCase: merge,
|
|
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(e.settings),
|
|
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(e.settings, time.Now),
|
|
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(e.images, e.articles, e.sites, e.authors, e.types, e.entries),
|
|
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, e.sites, e.authors, e.types, e.entries),
|
|
RunPurgeUseCase: useCases.NewRunPurgeUseCase(e.images, e.articles, merge, e.settings, func() time.Time { return hardeningEpoch }, logger, e.recorder, e.sites, e.authors),
|
|
}
|
|
router := chi.NewRouter()
|
|
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
|
|
e.router = router
|
|
}
|
|
|
|
func (e *hardeningEnv) send(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
tokenString, err := e.token.Encode(map[string]any{"user_id": hardeningAdminID, "token_version": 0})
|
|
if err != nil {
|
|
t.Fatalf("failed to encode jwt: %v", err)
|
|
}
|
|
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
|
|
rec := httptest.NewRecorder()
|
|
e.router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func (e *hardeningEnv) merge(t *testing.T, keepID uint32, mergeIDs ...uint32) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
ids := make([]string, 0, len(mergeIDs))
|
|
for _, id := range mergeIDs {
|
|
ids = append(ids, strconv.Itoa(int(id)))
|
|
}
|
|
return e.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": `+strconv.Itoa(int(keepID))+`, "merge_ids": [`+strings.Join(ids, ",")+`]}`)
|
|
}
|
|
|
|
func (e *hardeningEnv) seedImage(id uint32, hash string) {
|
|
name := e.storedName(id)
|
|
e.images.images[id] = image.Image{ID: id, Path: "/v1/static/uploadedImages/" + name, Filename: name, Size: 10, ContentType: "image/png", Width: 10, Height: 10, SHA256: hash, UploadedByID: hardeningAdminID, CreatedAt: hardeningEpoch.Add(time.Duration(id) * time.Minute)}
|
|
e.storage.blobs[name] = []byte("blob")
|
|
if id > e.images.nextID {
|
|
e.images.nextID = id
|
|
}
|
|
}
|
|
|
|
func (e *hardeningEnv) storedName(id uint32) string {
|
|
return "stored-" + strconv.Itoa(int(id)) + ".png"
|
|
}
|
|
|
|
func (e *hardeningEnv) imgTag(id uint32) string {
|
|
return `<img src="/v1/static/uploadedImages/` + e.storedName(id) + `">`
|
|
}
|
|
|
|
func (e *hardeningEnv) seedArticle(id uint32, body string) {
|
|
e.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), IsOnline: true, Body: body}
|
|
if id > e.articles.nextID {
|
|
e.articles.nextID = id
|
|
}
|
|
}
|
|
|
|
func (e *hardeningEnv) seedTypes() {
|
|
text := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true}
|
|
thumbnail := contenttype.Field{Key: "thumbnail", Label: "Thumbnail", Type: contenttype.TypeMedia}
|
|
poster := contenttype.Field{Key: "poster", Label: "Poster", Type: contenttype.TypeMedia}
|
|
e.types.types[hardeningEventTypeID] = contenttype.ContentType{ID: hardeningEventTypeID, Key: "event", Name: "Event", Fields: []contenttype.Field{text, thumbnail}}
|
|
e.types.types[hardeningGalleryID] = contenttype.ContentType{ID: hardeningGalleryID, Key: "gallery", Name: "Gallery", Fields: []contenttype.Field{text, thumbnail, poster}}
|
|
}
|
|
|
|
func (e *hardeningEnv) seedEntry(id uint32, typeID uint32, data map[string]any) entry.Entry {
|
|
seeded := entry.Entry{ID: id, ContentTypeID: typeID, Data: data, CreatedBy: 3, UpdatedBy: 3, CreatedAt: hardeningEpoch, UpdatedAt: hardeningEpoch.Add(time.Hour)}
|
|
e.entries.entries[id] = seeded
|
|
return seeded
|
|
}
|
|
|
|
func (e *hardeningEnv) seedMergeFixture() {
|
|
e.seedTypes()
|
|
for _, id := range []uint32{7, 8, 9} {
|
|
e.seedImage(id, hashA)
|
|
}
|
|
e.seedImage(2, hashB)
|
|
e.seedImage(3, hashB)
|
|
e.seedArticle(1, "one "+e.imgTag(9))
|
|
e.seedArticle(2, "two "+e.imgTag(9))
|
|
e.seedArticle(3, "three")
|
|
cover := uint32(9)
|
|
three := e.articles.articles[3]
|
|
three.CoverImageID = &cover
|
|
e.articles.articles[3] = three
|
|
e.sites.seed(1, "Main", "main", true)
|
|
seededSite := e.sites.sites[1]
|
|
seededSite.DefaultSocialImageID = idPointer(9)
|
|
e.sites.sites[1] = seededSite
|
|
e.authors.seed(4, "Dana", "dana")
|
|
e.authors.update(4, func(a *author.Author) { a.AvatarID = idPointer(9) })
|
|
e.seedEntry(41, hardeningEventTypeID, map[string]any{"title": "Workshop", "thumbnail": float64(9)})
|
|
e.seedEntry(42, hardeningEventTypeID, map[string]any{"title": "Meetup", "thumbnail": float64(9)})
|
|
e.variants.rows[1] = image.VariantFile{ID: 1, VariantName: "small", SourceName: e.storedName(9), CachedName: "cache-9-small.png"}
|
|
e.variants.rows[2] = image.VariantFile{ID: 2, VariantName: "small", SourceName: e.storedName(8), CachedName: "cache-8-small.png"}
|
|
e.variants.nextID = 2
|
|
}
|
|
|
|
type libraryState struct {
|
|
Images map[uint32]image.Image
|
|
Articles map[uint32]article.Article
|
|
Sites map[uint32]site.Site
|
|
Authors map[uint32]author.Author
|
|
Entries map[uint32]entry.Entry
|
|
}
|
|
|
|
func (e *hardeningEnv) state() libraryState {
|
|
state := libraryState{Images: map[uint32]image.Image{}, Articles: map[uint32]article.Article{}, Sites: map[uint32]site.Site{}, Authors: map[uint32]author.Author{}, Entries: map[uint32]entry.Entry{}}
|
|
for id, value := range e.images.images {
|
|
state.Images[id] = value
|
|
}
|
|
for id, value := range e.articles.articles {
|
|
state.Articles[id] = value
|
|
}
|
|
for id, value := range e.sites.sites {
|
|
state.Sites[id] = value
|
|
}
|
|
for id, value := range e.authors.authors {
|
|
state.Authors[id] = value
|
|
}
|
|
for id, value := range e.entries.entries {
|
|
copied := value
|
|
copied.Data = map[string]any{}
|
|
for key, field := range value.Data {
|
|
copied.Data[key] = field
|
|
}
|
|
state.Entries[id] = copied
|
|
}
|
|
return state
|
|
}
|
|
|
|
func (e *hardeningEnv) expectUnchanged(t *testing.T, before libraryState) {
|
|
t.Helper()
|
|
if after := e.state(); !reflect.DeepEqual(before, after) {
|
|
t.Errorf("expected the library to be exactly as before\nbefore %+v\nafter %+v", before, after)
|
|
}
|
|
}
|
|
|
|
func (e *hardeningEnv) expectNoFileTouched(t *testing.T) {
|
|
t.Helper()
|
|
if len(e.storage.calls) != 0 || len(e.variants.calls) != 0 || len(e.storage.deleted) != 0 {
|
|
t.Errorf("expected no file or variant row to be touched, got storage %v variants %v", e.storage.calls, e.variants.calls)
|
|
}
|
|
}
|
|
|
|
func (e *hardeningEnv) expectEvents(t *testing.T, action string, outcome audit.Outcome, count int) audit.Event {
|
|
t.Helper()
|
|
var matching []audit.Event
|
|
for _, event := range e.recorder.events {
|
|
if event.Action == action {
|
|
matching = append(matching, event)
|
|
}
|
|
}
|
|
if len(matching) != count {
|
|
t.Fatalf("expected %d %s events, got %d", count, action, len(matching))
|
|
}
|
|
if count == 0 {
|
|
return audit.Event{}
|
|
}
|
|
if matching[0].Outcome != outcome {
|
|
t.Errorf("expected a %s %s event, got %s", outcome, action, matching[0].Outcome)
|
|
}
|
|
return matching[0]
|
|
}
|
|
|
|
func (e *hardeningEnv) thumbnailOf(id uint32) any {
|
|
return e.entries.entries[id].Data["thumbnail"]
|
|
}
|
|
|
|
const mergeSuccessBody = `{"articles_updated":3,"sites_updated":1,"authors_updated":1,"merged":1}`
|
|
|
|
func TestMediaMergeIsAllOrNothing(t *testing.T) {
|
|
failures := []struct {
|
|
name string
|
|
arm func(env *hardeningEnv)
|
|
}{
|
|
{"the UpdateBody of the first article", func(env *hardeningEnv) {
|
|
env.articles.failUpdateBodyFor = map[uint32]error{1: errRepositoryFailure}
|
|
}},
|
|
{"the UpdateBody of the second article", func(env *hardeningEnv) {
|
|
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
|
|
}},
|
|
{"the article repository RepointImage", func(env *hardeningEnv) { env.articles.failRepointOn = 1 }},
|
|
{"the site repository RepointImage", func(env *hardeningEnv) { env.sites.fail.failNext("RepointImage") }},
|
|
{"the author repository RepointAvatar", func(env *hardeningEnv) { env.authors.fail.failNext("RepointAvatar") }},
|
|
{"the entry repository Get of the second entry", func(env *hardeningEnv) { env.entries.failGetOn = 2 }},
|
|
{"the entry repository Update of the second entry", func(env *hardeningEnv) { env.entries.failUpdateOn = 2 }},
|
|
{"the image repository Delete of image 9", func(env *hardeningEnv) {
|
|
env.images.failDeleteFor = map[uint32]error{9: errRepositoryFailure}
|
|
}},
|
|
{"the commit of the transaction", func(env *hardeningEnv) { env.transactor.failCommit = errRepositoryFailure }},
|
|
}
|
|
|
|
t.Run("Any failing step leaves the library exactly as it was", func(t *testing.T) {
|
|
for _, failure := range failures {
|
|
t.Run(failure.name, func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
before := env.state()
|
|
failure.arm(env)
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != hardeningInternalBody {
|
|
t.Fatalf("expected 500 %q, got %d %q", hardeningInternalBody, rec.Code, rec.Body.String())
|
|
}
|
|
env.expectUnchanged(t, before)
|
|
env.expectNoFileTouched(t)
|
|
env.expectEvents(t, audit.ActionMediaMerge, audit.OutcomeFailed, 1)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The transaction cannot start", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
before := env.state()
|
|
env.transactor.failBegin = errRepositoryFailure
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", rec.Code)
|
|
}
|
|
env.expectUnchanged(t, before)
|
|
env.expectNoFileTouched(t)
|
|
if env.entries.updateCalls != 0 || env.entries.getCalls != 0 || len(env.images.deleteCalls) != 0 || env.articles.repointCalls != 0 {
|
|
t.Errorf("expected no repository call after the planning reads")
|
|
}
|
|
})
|
|
|
|
t.Run("Several copies roll back together", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
before := env.state()
|
|
env.images.failDeleteFor = map[uint32]error{9: errRepositoryFailure}
|
|
|
|
rec := env.merge(t, 7, 8, 9)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", rec.Code)
|
|
}
|
|
if !reflect.DeepEqual(env.images.deleteCalls, []uint32{8, 9}) {
|
|
t.Errorf("expected image 8 to be deleted before 9 failed, got %v", env.images.deleteCalls)
|
|
}
|
|
if _, back := env.images.images[8]; !back {
|
|
t.Errorf("expected image 8 back after the rollback")
|
|
}
|
|
env.expectUnchanged(t, before)
|
|
})
|
|
|
|
t.Run("A successful merge still does everything", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
original := env.entries.entries[41]
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if rec.Code != http.StatusOK || rec.Body.String() != mergeSuccessBody {
|
|
t.Fatalf("expected 200 %q, got %d %q", mergeSuccessBody, rec.Code, rec.Body.String())
|
|
}
|
|
for _, id := range []uint32{41, 42} {
|
|
stored := env.entries.entries[id]
|
|
if stored.Data["thumbnail"] != float64(7) || stored.CreatedBy != original.CreatedBy || stored.UpdatedBy != original.UpdatedBy || !stored.UpdatedAt.Equal(original.UpdatedAt) || !stored.CreatedAt.Equal(original.CreatedAt) {
|
|
t.Errorf("entry %d: unexpected %+v", id, stored)
|
|
}
|
|
}
|
|
if _, exists := env.images.images[9]; exists {
|
|
t.Errorf("expected image 9 gone")
|
|
}
|
|
if len(env.storage.calls) == 0 || env.storage.calls[len(env.storage.calls)-1].name != env.storedName(9) {
|
|
t.Errorf("expected the stored file of image 9 to be removed, got %v", env.storage.calls)
|
|
}
|
|
event := env.expectEvents(t, audit.ActionMediaMerge, audit.OutcomeSuccess, 1)
|
|
if event.Params[audit.ParamMerged] != 1 || event.Params[audit.ParamArticlesUpdated] != 3 {
|
|
t.Errorf("unexpected audit params %v", event.Params)
|
|
}
|
|
})
|
|
|
|
t.Run("A validation or lookup error never opens a transaction", func(t *testing.T) {
|
|
requests := []struct {
|
|
name string
|
|
body string
|
|
status int
|
|
}{
|
|
{"unreadable body", `nope`, http.StatusBadRequest},
|
|
{"empty merge list", `{"keep_id": 7, "merge_ids": []}`, http.StatusBadRequest},
|
|
{"keeping a copy of itself", `{"keep_id": 7, "merge_ids": [7]}`, http.StatusBadRequest},
|
|
{"unknown kept image", `{"keep_id": 999, "merge_ids": [9]}`, http.StatusNotFound},
|
|
{"missing copy", `{"keep_id": 7, "merge_ids": [999]}`, http.StatusConflict},
|
|
{"hash mismatch", `{"keep_id": 7, "merge_ids": [2]}`, http.StatusConflict},
|
|
}
|
|
for _, request := range requests {
|
|
t.Run(request.name, func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/merge", request.body)
|
|
|
|
if rec.Code != request.status {
|
|
t.Errorf("expected %d, got %d", request.status, rec.Code)
|
|
}
|
|
if env.transactor.runCalls != 0 {
|
|
t.Errorf("expected the transactor not to run, got %d", env.transactor.runCalls)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A rollback leaves updated_at and updated_by of entries untouched", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
first, second := env.entries.entries[41], env.entries.entries[42]
|
|
env.entries.failUpdateOn = 2
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", rec.Code)
|
|
}
|
|
if !reflect.DeepEqual(env.entries.entries[41], first) || !reflect.DeepEqual(env.entries.entries[42], second) {
|
|
t.Errorf("expected both entries byte-identical, got %+v and %+v", env.entries.entries[41], env.entries.entries[42])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMediaMergeRemovesFilesOnlyAfterTheCommit(t *testing.T) {
|
|
t.Run("Files are touched after the commit and never before", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
names := map[string]bool{}
|
|
for _, call := range env.storage.calls {
|
|
names[call.name] = true
|
|
if !call.committed {
|
|
t.Errorf("storage Delete(%s) ran before the commit", call.name)
|
|
}
|
|
}
|
|
if !names[env.storedName(9)] || !names["cache-9-small.png"] {
|
|
t.Errorf("expected the file and the variant file of image 9 to be removed, got %v", env.storage.calls)
|
|
}
|
|
if len(env.variants.calls) != 1 || !env.variants.calls[0].committed {
|
|
t.Errorf("expected the variant cache row of image 9 to be removed after the commit, got %v", env.variants.calls)
|
|
}
|
|
if _, kept := env.variants.rows[2]; !kept {
|
|
t.Errorf("expected the variant row of the unmerged image 8 to stay")
|
|
}
|
|
})
|
|
|
|
fileFailures := []struct {
|
|
name string
|
|
arm func(env *hardeningEnv)
|
|
}{
|
|
{"image.Storage.Delete fails for image 9", func(env *hardeningEnv) {
|
|
env.storage.failFor[env.storedName(9)] = errors.New("disk gone")
|
|
}},
|
|
{"the variant file purge of image 9 fails", func(env *hardeningEnv) {
|
|
env.variants.failSource[env.storedName(9)] = errors.New("disk gone")
|
|
}},
|
|
}
|
|
|
|
t.Run("A file failure after the commit is logged, not rolled back", func(t *testing.T) {
|
|
baseline := newHardeningEnv(t)
|
|
baseline.seedMergeFixture()
|
|
expected := baseline.merge(t, 7, 8, 9)
|
|
if expected.Code != http.StatusOK {
|
|
t.Fatalf("baseline merge failed: %d", expected.Code)
|
|
}
|
|
|
|
for _, failure := range fileFailures {
|
|
t.Run(failure.name, func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
failure.arm(env)
|
|
|
|
rec := env.merge(t, 7, 8, 9)
|
|
|
|
if rec.Code != http.StatusOK || rec.Body.String() != expected.Body.String() {
|
|
t.Fatalf("expected 200 %q, got %d %q", expected.Body.String(), rec.Code, rec.Body.String())
|
|
}
|
|
_, goneEight := env.images.images[8]
|
|
_, goneNine := env.images.images[9]
|
|
if goneEight || goneNine {
|
|
t.Errorf("expected images 8 and 9 gone")
|
|
}
|
|
if env.thumbnailOf(41) != float64(7) || env.thumbnailOf(42) != float64(7) || env.sites.sites[1].DefaultSocialImageID == nil || *env.sites.sites[1].DefaultSocialImageID != 7 || *env.authors.authors[4].AvatarID != 7 || *env.articles.articles[3].CoverImageID != 7 {
|
|
t.Errorf("expected every reference to stay repointed")
|
|
}
|
|
attempted := false
|
|
for _, call := range env.storage.calls {
|
|
attempted = attempted || call.name == env.storedName(8)
|
|
}
|
|
if !attempted {
|
|
t.Errorf("expected the removal of image 8 to be attempted")
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(env.logs.String()), "\n")
|
|
if len(lines) != 1 || !strings.Contains(lines[0], env.storedName(9)) || !strings.Contains(lines[0], "disk gone") {
|
|
t.Errorf("expected one log line naming the stored name and the error, got %q", env.logs.String())
|
|
}
|
|
event := env.expectEvents(t, audit.ActionMediaMerge, audit.OutcomeSuccess, 1)
|
|
if event.Params[audit.ParamMerged] != 2 || event.Params[audit.ParamArticlesUpdated] != 3 {
|
|
t.Errorf("unexpected audit params %v", event.Params)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A purge group with a file failure after the commit counts as purged", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedPurgeGroups(2)
|
|
env.storage.failFor[env.storedName(11)] = errors.New("disk gone")
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
|
|
|
expectPurgeBody(t, rec, 2, 2, 2, 4, 0)
|
|
if !strings.Contains(env.logs.String(), env.storedName(11)) || !strings.Contains(env.logs.String(), "disk gone") {
|
|
t.Errorf("expected the file failure in the log, got %q", env.logs.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMediaMergeKeepsConcurrentEntryEdits(t *testing.T) {
|
|
afterScanOnce := func(env *hardeningEnv, change func()) {
|
|
done := false
|
|
env.entries.afterListPage = func() {
|
|
if !done {
|
|
done = true
|
|
change()
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("An edit made after the scan is kept", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
editedAt := hardeningEpoch.Add(48 * time.Hour)
|
|
afterScanOnce(env, func() {
|
|
edited := env.entries.entries[41]
|
|
edited.Data = map[string]any{"title": "Workshop 2026", "thumbnail": float64(9)}
|
|
edited.UpdatedAt, edited.UpdatedBy = editedAt, 5
|
|
env.entries.entries[41] = edited
|
|
})
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
stored := env.entries.entries[41]
|
|
if rec.Code != http.StatusOK || stored.Data["title"] != "Workshop 2026" || stored.Data["thumbnail"] != float64(7) || !stored.UpdatedAt.Equal(editedAt) || stored.UpdatedBy != 5 {
|
|
t.Errorf("expected the concurrent edit to survive, got %d %+v", rec.Code, stored)
|
|
}
|
|
})
|
|
|
|
t.Run("A concurrent edit that moved the entry off the merged copy is not overwritten", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
afterScanOnce(env, func() {
|
|
moved := env.entries.entries[41]
|
|
moved.Data = map[string]any{"title": "Workshop", "thumbnail": float64(8)}
|
|
env.entries.entries[41] = moved
|
|
})
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if rec.Code != http.StatusOK || env.thumbnailOf(41) != float64(8) {
|
|
t.Errorf("expected entry 41 to keep thumbnail 8, got %d %v", rec.Code, env.thumbnailOf(41))
|
|
}
|
|
if !reflect.DeepEqual(env.entries.updatedIDs, []uint32{42}) {
|
|
t.Errorf("expected Update for entry 42 only, got %v", env.entries.updatedIDs)
|
|
}
|
|
})
|
|
|
|
t.Run("An entry deleted after the scan is skipped", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
afterScanOnce(env, func() { delete(env.entries.entries, 41) })
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if _, exists := env.images.images[9]; rec.Code != http.StatusOK || exists {
|
|
t.Errorf("expected 200 with image 9 gone, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("Entries are written from the re-read data", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
env.seedEntry(51, hardeningGalleryID, map[string]any{"title": "A", "thumbnail": float64(9), "poster": float64(9)})
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
stored := env.entries.entries[51]
|
|
if rec.Code != http.StatusOK || stored.Data["thumbnail"] != float64(7) || stored.Data["poster"] != float64(7) || stored.Data["title"] != "A" {
|
|
t.Errorf("unexpected entry %+v", stored)
|
|
}
|
|
updates := 0
|
|
for _, id := range env.entries.updatedIDs {
|
|
if id == 51 {
|
|
updates++
|
|
}
|
|
}
|
|
if updates != 1 {
|
|
t.Errorf("expected one Update for entry 51, got %d", updates)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMediaMergeKeepsConcurrentArticleEdits(t *testing.T) {
|
|
afterPlanOnce := func(env *hardeningEnv, change func()) {
|
|
done := false
|
|
env.articles.afterFindByBody = func() {
|
|
if !done {
|
|
done = true
|
|
change()
|
|
}
|
|
}
|
|
}
|
|
rewrite := func(env *hardeningEnv, id uint32, body string) {
|
|
edited := env.articles.articles[id]
|
|
edited.Body = body
|
|
env.articles.articles[id] = edited
|
|
}
|
|
|
|
t.Run("A body edited after planning is kept", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
afterPlanOnce(env, func() { rewrite(env, 1, env.articles.articles[1].Body+" new paragraph") })
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
body := env.articles.articles[1].Body
|
|
if rec.Code != http.StatusOK || !strings.Contains(body, "new paragraph") || !strings.Contains(body, env.storedName(7)) || strings.Contains(body, env.storedName(9)) {
|
|
t.Errorf("unexpected body %q after %d", body, rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("A body edited so that it no longer holds the copy name is not rewritten", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
afterPlanOnce(env, func() { rewrite(env, 1, "edited text") })
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if rec.Code != http.StatusOK || env.articles.articles[1].Body != "edited text" {
|
|
t.Errorf("unexpected body %q after %d", env.articles.articles[1].Body, rec.Code)
|
|
}
|
|
for _, id := range env.articles.updateBodyIDs {
|
|
if id == 1 {
|
|
t.Errorf("expected no UpdateBody for article 1")
|
|
}
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"articles_updated":2`) {
|
|
t.Errorf("expected article 1 not to be counted, got %q", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("An article deleted after planning is skipped", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
afterPlanOnce(env, func() { delete(env.articles.articles, 1) })
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
if _, exists := env.images.images[9]; rec.Code != http.StatusOK || exists {
|
|
t.Errorf("expected 200 with image 9 gone, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("Only whole stored names are replaced", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedMergeFixture()
|
|
nine := env.images.images[9]
|
|
nine.Path = "/v1/static/uploadedImages/a.jpg"
|
|
env.images.images[9] = nine
|
|
seven := env.images.images[7]
|
|
seven.Path = "/v1/static/uploadedImages/kept.jpg"
|
|
env.images.images[7] = seven
|
|
rewrite(env, 1, `<img src="/v1/static/uploadedImages/ba.jpg"> <img src="/v1/static/uploadedImages/a.jpg">`)
|
|
|
|
rec := env.merge(t, 7, 9)
|
|
|
|
want := `<img src="/v1/static/uploadedImages/ba.jpg"> <img src="/v1/static/uploadedImages/kept.jpg">`
|
|
if rec.Code != http.StatusOK || env.articles.articles[1].Body != want {
|
|
t.Errorf("expected %q, got %q after %d", want, env.articles.articles[1].Body, rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPurgeCancellationStopsBetweenGroups(t *testing.T) {
|
|
setup := func(t *testing.T) (*hardeningEnv, *useCases.RunPurgeUseCase, context.Context) {
|
|
t.Helper()
|
|
env := newHardeningEnv(t)
|
|
env.seedPurgeGroups(3)
|
|
logger := log.New(env.logs, "", 0)
|
|
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, env.variants, env.recorder, env.types, env.entries, env.transactor, logger)
|
|
run := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.settings, func() time.Time { return hardeningEpoch }, logger, env.recorder, env.sites, env.authors)
|
|
ctx, cancel := context.WithCancel(testCtx)
|
|
t.Cleanup(cancel)
|
|
env.transactor.afterCommit = func() {
|
|
if env.transactor.runCalls == 1 {
|
|
cancel()
|
|
}
|
|
}
|
|
return env, run, ctx
|
|
}
|
|
expectOnlyGroupAMerged := func(t *testing.T, env *hardeningEnv) {
|
|
t.Helper()
|
|
if _, exists := env.images.images[11]; exists {
|
|
t.Errorf("expected group A merged")
|
|
}
|
|
for _, id := range []uint32{21, 31} {
|
|
if _, exists := env.images.images[id]; !exists {
|
|
t.Errorf("expected image %d untouched", id)
|
|
}
|
|
}
|
|
if env.transactor.runCalls != 1 || env.settings.saveCalls != 0 {
|
|
t.Errorf("expected one transaction and no saved run, got %d and %d", env.transactor.runCalls, env.settings.saveCalls)
|
|
}
|
|
for _, event := range env.recorder.events {
|
|
if event.Outcome == audit.OutcomeSuccess {
|
|
t.Errorf("expected no success event, got %s", event.Action)
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("A request cancelled between groups stops the run", func(t *testing.T) {
|
|
env, run, ctx := setup(t)
|
|
|
|
result, err := run.Run(ctx, nil)
|
|
|
|
if !errors.Is(err, context.Canceled) || result.FailedGroups != 0 {
|
|
t.Errorf("expected the context error and no failed group, got %v %+v", err, result)
|
|
}
|
|
expectOnlyGroupAMerged(t, env)
|
|
})
|
|
|
|
t.Run("The scheduler passes its context to the run", func(t *testing.T) {
|
|
env, run, ctx := setup(t)
|
|
enabledAt := hardeningEpoch.Add(-24 * time.Hour)
|
|
env.settings.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, EnabledAt: &enabledAt}
|
|
scheduler := useCases.NewPurgeSchedulerUseCase(env.settings, run, env.recorder)
|
|
|
|
_, ran, err := scheduler.Tick(ctx, enabledAt.Add(30*24*time.Hour))
|
|
|
|
if ran || !errors.Is(err, context.Canceled) {
|
|
t.Errorf("expected a cancelled run, got %v %v", ran, err)
|
|
}
|
|
expectOnlyGroupAMerged(t, env)
|
|
})
|
|
}
|
|
|
|
func (e *hardeningEnv) seedPurgeGroups(count int) {
|
|
e.seedTypes()
|
|
for group := 1; group <= count; group++ {
|
|
kept, copied := uint32(group*10), uint32(group*10+1)
|
|
hash := "hash-group-" + strconv.Itoa(group)
|
|
e.seedImage(kept, hash)
|
|
e.seedImage(copied, hash)
|
|
base := uint32(group * 100)
|
|
e.seedArticle(base+1, "copy one "+e.imgTag(copied))
|
|
e.seedArticle(base+2, "copy two "+e.imgTag(copied))
|
|
for offset := uint32(3); offset <= 6; offset++ {
|
|
e.seedArticle(base+offset, "kept "+e.imgTag(kept))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *hardeningEnv) seedKeptEntries(count int, groups int) {
|
|
for index := 1; index <= count; index++ {
|
|
typeID := hardeningEventTypeID
|
|
data := map[string]any{"title": "Entry " + strconv.Itoa(index), "thumbnail": float64(((index-1)%groups + 1) * 10)}
|
|
if index%2 == 0 {
|
|
typeID = hardeningGalleryID
|
|
}
|
|
e.seedEntry(uint32(1000+index), typeID, data)
|
|
}
|
|
}
|
|
|
|
func expectPurgeBody(t *testing.T, rec *httptest.ResponseRecorder, total int, purged int, merged int, articles int, failed int) {
|
|
t.Helper()
|
|
want := `{"groups_total":` + strconv.Itoa(total) + `,"groups_purged":` + strconv.Itoa(purged) + `,"merged":` + strconv.Itoa(merged) + `,"articles_updated":` + strconv.Itoa(articles) + `,"sites_updated":0,"authors_updated":0,"failed_groups":` + strconv.Itoa(failed) + `}`
|
|
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
|
t.Fatalf("expected 200 %q, got %d %q", want, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPurgeScansEntriesOnce(t *testing.T) {
|
|
seed := func(env *hardeningEnv) {
|
|
env.seedPurgeGroups(6)
|
|
env.seedKeptEntries(40, 6)
|
|
}
|
|
|
|
t.Run("A preview over many groups scans once", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
seed(env)
|
|
|
|
rec := env.send(t, http.MethodGet, "/v1/media/purge-preview", "")
|
|
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"groups":6`) || !strings.Contains(rec.Body.String(), `"copies":6`) {
|
|
t.Fatalf("unexpected preview %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if env.types.listCalls != 1 || env.entries.listPageCalls != 2 {
|
|
t.Errorf("expected 1 content type list and 2 entry pages, got %d and %d", env.types.listCalls, env.entries.listPageCalls)
|
|
}
|
|
})
|
|
|
|
t.Run("A run over many groups scans once", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
seed(env)
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
|
|
|
expectPurgeBody(t, rec, 6, 6, 6, 12, 0)
|
|
if env.types.listCalls != 1 || env.entries.listPageCalls != 2 {
|
|
t.Errorf("expected 1 content type list and 2 entry pages, got %d and %d", env.types.listCalls, env.entries.listPageCalls)
|
|
}
|
|
stored := env.settings.settings
|
|
if stored.LastPurgeMerged != 6 || stored.LastPurgeArticlesUpdated != 12 || stored.LastPurgeAt == nil {
|
|
t.Errorf("unexpected stored settings %+v", stored)
|
|
}
|
|
})
|
|
|
|
t.Run("The index is stale after a group but the result is right", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedPurgeGroups(2)
|
|
env.seedEntry(41, hardeningGalleryID, map[string]any{"title": "Both", "thumbnail": float64(11), "poster": float64(21)})
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
|
|
|
stored := env.entries.entries[41]
|
|
if rec.Code != http.StatusOK || stored.Data["thumbnail"] != float64(10) || stored.Data["poster"] != float64(20) {
|
|
t.Errorf("expected both kept ids, got %d %+v", rec.Code, stored.Data)
|
|
}
|
|
if !reflect.DeepEqual(env.entries.updatedIDs, []uint32{41, 41}) {
|
|
t.Errorf("expected two Update calls for entry 41, got %v", env.entries.updatedIDs)
|
|
}
|
|
})
|
|
|
|
t.Run("The usage counts and the candidates come from the same scan", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedTypes()
|
|
delete(env.types.types, hardeningGalleryID)
|
|
env.seedImage(7, hashA)
|
|
env.seedImage(9, hashA)
|
|
env.seedEntry(41, hardeningEventTypeID, map[string]any{"title": "Workshop", "thumbnail": float64(9)})
|
|
|
|
preview := env.send(t, http.MethodGet, "/v1/media/purge-preview", "")
|
|
if preview.Code != http.StatusOK || !strings.Contains(preview.Body.String(), `"keep_id":9`) {
|
|
t.Fatalf("expected 9 to be the kept image, got %d %q", preview.Code, preview.Body.String())
|
|
}
|
|
if env.entries.listPageCalls != 1 {
|
|
t.Errorf("expected one scan for the preview, got %d", env.entries.listPageCalls)
|
|
}
|
|
|
|
run := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
|
|
|
expectPurgeBody(t, run, 1, 1, 1, 0, 0)
|
|
if _, merged := env.images.images[7]; merged || env.entries.listPageCalls != 2 {
|
|
t.Errorf("expected image 7 merged with one more scan, got %d scans", env.entries.listPageCalls)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPurgeRunCommitsOneGroupAtATime(t *testing.T) {
|
|
failures := []struct {
|
|
name string
|
|
arm func(env *hardeningEnv)
|
|
}{
|
|
{"the UpdateBody of the second article", func(env *hardeningEnv) {
|
|
env.articles.failUpdateBodyFor = map[uint32]error{202: errRepositoryFailure}
|
|
}},
|
|
{"the article repository RepointImage", func(env *hardeningEnv) { env.articles.failRepointOn = 2 }},
|
|
{"the entry repository Update", func(env *hardeningEnv) { env.entries.failUpdateOn = 2 }},
|
|
{"the image repository Delete of a merged copy", func(env *hardeningEnv) {
|
|
env.images.failDeleteFor = map[uint32]error{21: errRepositoryFailure}
|
|
}},
|
|
}
|
|
|
|
seedThree := func(env *hardeningEnv) {
|
|
env.seedPurgeGroups(3)
|
|
for group := 1; group <= 3; group++ {
|
|
env.seedEntry(uint32(40+group), hardeningEventTypeID, map[string]any{"title": "Entry", "thumbnail": float64(group*10 + 1)})
|
|
}
|
|
}
|
|
|
|
t.Run("A failing group rolls back alone", func(t *testing.T) {
|
|
for _, failure := range failures {
|
|
t.Run(failure.name, func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
seedThree(env)
|
|
before := env.state()
|
|
failure.arm(env)
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
|
|
|
expectPurgeBody(t, rec, 3, 2, 2, 4, 1)
|
|
after := env.state()
|
|
for _, id := range []uint32{20, 21} {
|
|
if !reflect.DeepEqual(before.Images[id], after.Images[id]) {
|
|
t.Errorf("expected image %d back to its original state", id)
|
|
}
|
|
}
|
|
for id := uint32(201); id <= 206; id++ {
|
|
if before.Articles[id].Body != after.Articles[id].Body {
|
|
t.Errorf("expected article %d back to its original body", id)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(before.Entries[42], after.Entries[42]) {
|
|
t.Errorf("expected entry 42 back to its original state")
|
|
}
|
|
for _, copied := range []uint32{11, 31} {
|
|
if _, exists := after.Images[copied]; exists {
|
|
t.Errorf("expected the copy %d to be merged", copied)
|
|
}
|
|
}
|
|
if after.Entries[41].Data["thumbnail"] != float64(10) || after.Entries[43].Data["thumbnail"] != float64(30) {
|
|
t.Errorf("expected groups A and C to be repointed")
|
|
}
|
|
stored := env.settings.settings
|
|
if stored.LastPurgeMerged != 2 || stored.LastPurgeArticlesUpdated != 4 {
|
|
t.Errorf("unexpected stored settings %+v", stored)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Every group failing is still a 200", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
seedThree(env)
|
|
before := env.state()
|
|
env.transactor.failCommit = errRepositoryFailure
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
|
|
|
expectPurgeBody(t, rec, 3, 0, 0, 0, 3)
|
|
env.expectUnchanged(t, before)
|
|
if env.settings.settings.LastPurgeAt == nil {
|
|
t.Errorf("expected last_purge_at to be set")
|
|
}
|
|
})
|
|
|
|
t.Run("The run opens one transaction per planned group", func(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
seedThree(env)
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
|
|
|
|
expectPurgeBody(t, rec, 3, 3, 3, 6, 0)
|
|
if env.transactor.runCalls != 3 {
|
|
t.Errorf("expected 3 transactions, got %d", env.transactor.runCalls)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMergeHardeningLayering(t *testing.T) {
|
|
t.Run("internal/domain/transaction imports only the stdlib and internal/domain packages", func(t *testing.T) {
|
|
output, err := exec.Command("go", "list", "-tags", "nofrontend", "-deps", "RenewCMS/internal/domain/transaction").CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go list failed: %v: %s", err, output)
|
|
}
|
|
for _, dependency := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
|
if strings.HasPrefix(dependency, "RenewCMS/") && !strings.HasPrefix(dependency, "RenewCMS/internal/domain/") {
|
|
t.Errorf("transaction must not depend on %s", dependency)
|
|
}
|
|
if strings.Contains(dependency, "gorm") {
|
|
t.Errorf("transaction must not depend on %s", dependency)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("the use cases import no persistence, gorm, models or net/http package", func(t *testing.T) {
|
|
output, err := exec.Command("go", "list", "-tags", "nofrontend", "-f", `{{join .Imports "\n"}}`, "RenewCMS/internal/infrastructure/useCases").CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go list failed: %v: %s", err, output)
|
|
}
|
|
for _, dependency := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
|
for _, forbidden := range []string{"persistence", "gorm", "models", "net/http"} {
|
|
if strings.Contains(dependency, forbidden) {
|
|
t.Errorf("useCases must not import %s", dependency)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("the GORM transactor is built only in api/dependecyInjection.go", func(t *testing.T) {
|
|
root := ".."
|
|
err := filepath.WalkDir(root, func(path string, entryInfo os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entryInfo.IsDir() && (entryInfo.Name() == ".git" || entryInfo.Name() == "node_modules" || entryInfo.Name() == "web" || entryInfo.Name() == "tmp") {
|
|
return filepath.SkipDir
|
|
}
|
|
if entryInfo.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasPrefix(path, "../test/") {
|
|
return nil
|
|
}
|
|
content, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
allowed := path == filepath.Join(root, "api", "dependecyInjection.go") || path == filepath.Join(root, "internal", "infrastructure", "persistence", "repositories", "transactor.go")
|
|
if strings.Contains(string(content), "NewTransactor(") && !allowed {
|
|
t.Errorf("%s must not construct the transactor", path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk failed: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestReplaceStoredNameProperties(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
from string
|
|
to string
|
|
want string
|
|
}{
|
|
{"identity without the copy name", "<img src=\"/x/b.png\">", "a.png", "k.png", "<img src=\"/x/b.png\">"},
|
|
{"a suffix of another name is kept", "ba.jpg a.jpg", "a.jpg", "k.jpg", "ba.jpg k.jpg"},
|
|
{"a prefix of another name is kept", "a.jpg.bak a.jpg", "a.jpg", "k.jpg", "a.jpg.bak k.jpg"},
|
|
{"a name ending a sentence is replaced", "see a.jpg.", "a.jpg", "k.jpg", "see k.jpg."},
|
|
{"every whole occurrence is replaced", "a.jpg,a.jpg", "a.jpg", "k.jpg", "k.jpg,k.jpg"},
|
|
{"the empty name changes nothing", "text", "", "k.jpg", "text"},
|
|
{"a name followed by a sentence dot and a space is replaced", "see a.jpg. Next", "a.jpg", "k.jpg", "see k.jpg. Next"},
|
|
{"a name followed by a dot and a name character is kept", "a.jpg.b", "a.jpg", "k.jpg", "a.jpg.b"},
|
|
{"a name followed by a dot and a hyphen is kept", "a.jpg.-", "a.jpg", "k.jpg", "a.jpg.-"},
|
|
{"a name followed by two dots is kept", "a.jpg..", "a.jpg", "k.jpg", "a.jpg.."},
|
|
{"a name at the very start is replaced", "a.jpg end", "a.jpg", "k.jpg", "k.jpg end"},
|
|
{"a name at the very end is replaced", "start a.jpg", "a.jpg", "k.jpg", "start k.jpg"},
|
|
{"0 before the name keeps it", "0a.jpg", "a.jpg", "k.jpg", "0a.jpg"},
|
|
{"0 after the name keeps it", "a.jpg0", "a.jpg", "k.jpg", "a.jpg0"},
|
|
{"9 before the name keeps it", "9a.jpg", "a.jpg", "k.jpg", "9a.jpg"},
|
|
{"9 after the name keeps it", "a.jpg9", "a.jpg", "k.jpg", "a.jpg9"},
|
|
{"a before the name keeps it", "aa.jpg", "a.jpg", "k.jpg", "aa.jpg"},
|
|
{"a after the name keeps it", "a.jpga", "a.jpg", "k.jpg", "a.jpga"},
|
|
{"z before the name keeps it", "za.jpg", "a.jpg", "k.jpg", "za.jpg"},
|
|
{"z after the name keeps it", "a.jpgz", "a.jpg", "k.jpg", "a.jpgz"},
|
|
{"A before the name keeps it", "Aa.jpg", "a.jpg", "k.jpg", "Aa.jpg"},
|
|
{"A after the name keeps it", "a.jpgA", "a.jpg", "k.jpg", "a.jpgA"},
|
|
{"Z before the name keeps it", "Za.jpg", "a.jpg", "k.jpg", "Za.jpg"},
|
|
{"Z after the name keeps it", "a.jpgZ", "a.jpg", "k.jpg", "a.jpgZ"},
|
|
{"- before the name keeps it", "-a.jpg", "a.jpg", "k.jpg", "-a.jpg"},
|
|
{"- after the name keeps it", "a.jpg-", "a.jpg", "k.jpg", "a.jpg-"},
|
|
{"_ before the name keeps it", "_a.jpg", "a.jpg", "k.jpg", "_a.jpg"},
|
|
{"_ after the name keeps it", "a.jpg_", "a.jpg", "k.jpg", "a.jpg_"},
|
|
{"a tilde around the name is a boundary", "~a.jpg~", "a.jpg", "k.jpg", "~k.jpg~"},
|
|
{"a brace around the name is a boundary", "{a.jpg{", "a.jpg", "k.jpg", "{k.jpg{"},
|
|
{"a bracket around the name is a boundary", "[a.jpg[", "a.jpg", "k.jpg", "[k.jpg["},
|
|
{"a at sign around the name is a boundary", "@a.jpg@", "a.jpg", "k.jpg", "@k.jpg@"},
|
|
{"a backtick around the name is a boundary", "`a.jpg`", "a.jpg", "k.jpg", "`k.jpg`"},
|
|
{"a slash around the name is a boundary", "/a.jpg/", "a.jpg", "k.jpg", "/k.jpg/"},
|
|
{"a colon around the name is a boundary", ":a.jpg:", "a.jpg", "k.jpg", ":k.jpg:"},
|
|
{"a quote around the name is a boundary", "\"a.jpg\"", "a.jpg", "k.jpg", "\"k.jpg\""},
|
|
{"a angle around the name is a boundary", "<a.jpg<", "a.jpg", "k.jpg", "<k.jpg<"},
|
|
{"a closing angle around the name is a boundary", ">a.jpg>", "a.jpg", "k.jpg", ">k.jpg>"},
|
|
{"a space around the name is a boundary", " a.jpg ", "a.jpg", "k.jpg", " k.jpg "},
|
|
{"a newline around the name is a boundary", "\na.jpg\n", "a.jpg", "k.jpg", "\nk.jpg\n"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := image.ReplaceStoredName(c.body, c.from, c.to)
|
|
if got != c.want {
|
|
t.Errorf("expected %q, got %q", c.want, got)
|
|
}
|
|
if image.ReplaceStoredName(got, c.from, c.to) != got {
|
|
t.Errorf("expected idempotence")
|
|
}
|
|
if strings.Contains(c.body, c.to) && !strings.Contains(got, c.to) {
|
|
t.Errorf("expected the kept name never to be removed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPurgePreviewCountsOnlyWholeStoredNames(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedImage(30, "hash-whole")
|
|
env.seedImage(31, "hash-whole")
|
|
env.seedArticle(1, "keeps "+env.imgTag(30))
|
|
env.seedArticle(2, "longer name x"+env.storedName(31))
|
|
env.seedArticle(3, "uses "+env.imgTag(31))
|
|
|
|
preview := decodeContentObject(t, env.send(t, http.MethodGet, "/v1/media/purge-preview", ""))
|
|
list, _ := preview["list"].([]any)
|
|
if len(list) != 1 {
|
|
t.Fatalf("expected one group, got %v", preview)
|
|
}
|
|
group, _ := list[0].(map[string]any)
|
|
if preview["copies"] != float64(1) || preview["articles"] != float64(1) || group["keep_id"] != float64(30) || group["used_in"] != float64(1) {
|
|
t.Fatalf("expected image 30 kept with one use and one article touched, got %v", preview)
|
|
}
|
|
|
|
rec := env.send(t, http.MethodPost, "/v1/media/purge", "")
|
|
expectPurgeBody(t, rec, 1, 1, 1, 1, 0)
|
|
if env.articles.articles[2].Body != "longer name x"+env.storedName(31) {
|
|
t.Errorf("expected article 2 untouched, got %q", env.articles.articles[2].Body)
|
|
}
|
|
}
|
|
|
|
func TestMediaUsageCountsOnlyWholeStoredNames(t *testing.T) {
|
|
env := newHardeningEnv(t)
|
|
env.seedImage(30, "hash-usage")
|
|
env.seedArticle(1, "uses "+env.imgTag(30))
|
|
env.seedArticle(2, "longer name x"+env.storedName(30))
|
|
|
|
rec := env.send(t, http.MethodGet, "/v1/media/30/usage", "")
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
var usage []map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &usage); err != nil {
|
|
t.Fatalf("failed to decode usage: %v", err)
|
|
}
|
|
if len(usage) != 1 || usage[0]["id"] != float64(1) {
|
|
t.Fatalf("expected only article 1, got %v", usage)
|
|
}
|
|
}
|