Files
RenewCMS/test/media_merge_hardening_test.go
T

1137 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, Role: user.RoleAdmin}
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),
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)
}
}