package test import ( "RenewCMS/api/handlers/staticimages" domainArticle "RenewCMS/internal/domain/article" domainImage "RenewCMS/internal/domain/image" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/storage" "RenewCMS/internal/infrastructure/useCases" "bytes" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) type streamOnlyStorage struct { data []byte } func (s streamOnlyStorage) Put(string, io.Reader) (string, error) { return "", errors.New("read only") } func (s streamOnlyStorage) Delete(string) error { return nil } func (s streamOnlyStorage) Open(name string) (domainImage.Blob, error) { if name != "remote.png" { return domainImage.Blob{}, domainImage.ErrBlobNotFound } return domainImage.Blob{ Content: io.NopCloser(bytes.NewReader(s.data)), Size: int64(len(s.data)), ModTime: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), }, nil } type failingStorage struct{ streamOnlyStorage } func (failingStorage) Open(string) (domainImage.Blob, error) { return domainImage.Blob{}, errors.New("boom") } func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder { t.Helper() handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store), newInertServeVariantUseCase(store)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) return rec } func TestLocalStorage(t *testing.T) { t.Run("Put then Open returns the stored bytes and Delete removes them", func(t *testing.T) { store := storage.NewLocalStorage(t.TempDir() + "/nested") name, err := store.Put("image/png", strings.NewReader("hello")) if err != nil || !strings.HasSuffix(name, ".png") { t.Fatalf("Put returned %q, %v", name, err) } blob, err := store.Open(name) if err != nil { t.Fatalf("Open failed: %v", err) } got, _ := io.ReadAll(blob.Content) _ = blob.Content.Close() if string(got) != "hello" || blob.Size != 5 { t.Errorf("unexpected blob %q size %d", got, blob.Size) } if err := store.Delete(name); err != nil { t.Fatalf("Delete failed: %v", err) } if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) { t.Errorf("expected ErrBlobNotFound after delete, got %v", err) } if err := store.Delete(name); err != nil { t.Errorf("deleting a missing file must not fail, got %v", err) } }) t.Run("Names that are not plain file names are never opened or deleted", func(t *testing.T) { dir := t.TempDir() store := storage.NewLocalStorage(dir) for _, name := range []string{"", ".", "..", "../x.png", "a/b.png", `a\b.png`} { if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) { t.Errorf("Open(%q) = %v, want ErrBlobNotFound", name, err) } if err := store.Delete(name); err != nil { t.Errorf("Delete(%q) = %v, want nil", name, err) } } }) } func TestStaticImagesWithNonSeekableStorage(t *testing.T) { store := streamOnlyStorage{data: []byte("remote-bytes")} t.Run("GET streams the body with length and modification headers", func(t *testing.T) { rec := serveFrom(t, store, http.MethodGet, "/remote.png") if rec.Code != http.StatusOK || rec.Body.String() != "remote-bytes" { t.Fatalf("got %d %q", rec.Code, rec.Body.String()) } if rec.Header().Get("Content-Length") != "12" || rec.Header().Get("Content-Type") != "image/png" { t.Errorf("unexpected headers %v", rec.Header()) } if rec.Header().Get("Last-Modified") == "" { t.Error("expected Last-Modified") } }) t.Run("HEAD sends the headers without a body", func(t *testing.T) { rec := serveFrom(t, store, http.MethodHead, "/remote.png") if rec.Code != http.StatusOK || rec.Body.Len() != 0 { t.Errorf("got %d with %d body bytes", rec.Code, rec.Body.Len()) } }) t.Run("A missing blob answers 404 and any other storage error answers 500", func(t *testing.T) { if rec := serveFrom(t, store, http.MethodGet, "/other.png"); rec.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", rec.Code) } if rec := serveFrom(t, failingStorage{}, http.MethodGet, "/remote.png"); rec.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d", rec.Code) } }) } func TestMediaSearchTreatsWildcardsLiterally(t *testing.T) { db, repo, _ := newMediaPersistence(t) if err := db.AutoMigrate(&models.Article{}, &models.Author{}, &models.ArticleAuthor{}); err != nil { t.Fatalf("failed to migrate: %v", err) } percent := createStoredImage(t, repo, "100%_done.png", pngBytes(t, 1, 1), "image/png") createStoredImage(t, repo, "plain.png", pngBytes(t, 2, 2), "image/png") for _, search := range []string{"%", "_", "!", "100%_"} { found, err := repo.List(domainImage.SearchQuery{Search: search}) if err != nil { t.Fatalf("List(%q) failed: %v", search, err) } if search == "!" { if len(found) != 0 { t.Errorf("List(%q) matched %d images, want 0", search, len(found)) } continue } if len(found) != 1 || found[0].ID != percent.ID { t.Errorf("List(%q) = %+v, want only the 100%%_done image", search, found) } } articles := persistence.NewArticleRepository(db) if _, err := articles.Create(domainArticle.Article{Title: "t", Body: "uses 50%_off.png here"}); err != nil { t.Fatalf("article create failed: %v", err) } if found, _ := articles.FindByBodyContaining("0%_o"); len(found) != 1 { t.Errorf("expected the literal match, got %d", len(found)) } if found, _ := articles.FindByBodyContaining("0x_o"); len(found) != 0 { t.Errorf("underscore must not act as a wildcard, got %d", len(found)) } }