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 `` } 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, ` `) rec := env.merge(t, 7, 9) want := ` ` 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", "", "a.png", "k.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 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) } }