fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report)

This commit is contained in:
Florian Sylvain committed 2026-10-22 00:49:19 +02:00
1 parent e2812ee0b9
commit ea98c5ec05
118 files changed
+3828 -130

No files matched your search

+2
View File
@@ -370,6 +370,8 @@ func TestActivityExportUseCase(t *testing.T) {
{"target", "+1+1", "'+1+1"},
{"target", "-2", "'-2"},
{"actor", "@SUM(A1)", "'@SUM(A1)"},
{"target", "\t=cmd", "'\t=cmd"},
{"target", "\r=cmd", "'\r=cmd"},
{"target", "Spring release", "Spring release"},
{"target", "a=b", "a=b"},
}
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"crypto/sha256"
"encoding/hex"
@@ -125,7 +126,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
+176 -12
View File
@@ -126,7 +126,9 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
"nbf not a number": {signedAuthTokenSegments("test-secret", header, `{"nbf":"soon"}`), authtoken.ErrInvalid},
"expired": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`), authtoken.ErrExpired},
"expiring exactly now": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Unix())+`}`), authtoken.ErrExpired},
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
"without exp": {signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`), authtoken.ErrInvalid},
"jti not a string": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"jti":7}`), authtoken.ErrInvalid},
"larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid},
}
@@ -143,18 +145,8 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
})
}
t.Run("Accepts a token with no exp so sessions issued before expiry existed keep working", func(t *testing.T) {
legacy := signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`)
claims, err := auth.Decode(legacy)
if err != nil || claims["user_id"] != float64(4) {
t.Errorf("expected legacy token accepted, got %v, %v", claims, err)
}
})
t.Run("Accepts a token whose nbf has passed", func(t *testing.T) {
token := signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
token := signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
if _, err := auth.Decode(token); err != nil {
t.Errorf("expected acceptance, got %v", err)
@@ -179,3 +171,175 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
func authTokenUnix(value int64) string {
return strconv.FormatInt(value, 10)
}
type memoryRevocationStore struct {
revoked map[string]time.Time
failure error
}
func newMemoryRevocationStore() *memoryRevocationStore {
return &memoryRevocationStore{revoked: map[string]time.Time{}}
}
func (m *memoryRevocationStore) Revoke(id string, expiresAt time.Time) error {
if m.failure != nil {
return m.failure
}
m.revoked[id] = expiresAt
return nil
}
func (m *memoryRevocationStore) IsRevoked(id string) (bool, error) {
if m.failure != nil {
return false, m.failure
}
_, ok := m.revoked[id]
return ok, nil
}
func TestAuthTokenRevocation(t *testing.T) {
newAuth := func(store *memoryRevocationStore) *authtoken.Auth {
return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return authTokenEpoch }), authtoken.WithRevocationStore(store))
}
t.Run("Every encoded token carries a distinct jti", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
first, _ := auth.Encode(map[string]any{"user_id": 1})
second, _ := auth.Encode(map[string]any{"user_id": 1})
firstClaims, _ := auth.Decode(first)
secondClaims, _ := auth.Decode(second)
id, ok := firstClaims["jti"].(string)
if !ok || id == "" {
t.Fatalf("expected a jti, got %v", firstClaims["jti"])
}
if id == secondClaims["jti"] {
t.Errorf("expected distinct jti values, both were %q", id)
}
})
t.Run("An explicit jti is kept", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, _ := auth.Encode(map[string]any{"jti": "fixed-id"})
claims, _ := auth.Decode(encoded)
if claims["jti"] != "fixed-id" {
t.Errorf("expected jti fixed-id, got %v", claims["jti"])
}
})
t.Run("A revoked token is rejected until it would have expired anyway", func(t *testing.T) {
store := newMemoryRevocationStore()
auth := newAuth(store)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, err := auth.Decode(encoded)
if err != nil {
t.Fatal(err)
}
if err := auth.Revoke(claims); err != nil {
t.Fatal(err)
}
if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrRevoked) {
t.Errorf("expected ErrRevoked, got %v", err)
}
if got := store.revoked[claims["jti"].(string)]; !got.Equal(authTokenEpoch.Add(authtoken.SessionLifetime)) {
t.Errorf("expected the revocation to last until the token's exp, got %v", got)
}
})
t.Run("Revoking one token leaves the others valid", func(t *testing.T) {
auth := newAuth(newMemoryRevocationStore())
revoked, _ := auth.Encode(map[string]any{"user_id": 1})
kept, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(revoked)
_ = auth.Revoke(claims)
if _, err := auth.Decode(kept); err != nil {
t.Errorf("expected the other token to stay valid, got %v", err)
}
})
t.Run("A token is rejected when the revocation store cannot answer", func(t *testing.T) {
store := newMemoryRevocationStore()
auth := newAuth(store)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
store.failure = errors.New("database unavailable")
if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrInvalid) {
t.Errorf("expected ErrInvalid, got %v", err)
}
})
t.Run("Revoking surfaces a store failure", func(t *testing.T) {
store := newMemoryRevocationStore()
auth := newAuth(store)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(encoded)
store.failure = errors.New("database unavailable")
if err := auth.Revoke(claims); err == nil {
t.Error("expected an error")
}
})
t.Run("Revoking claims without a jti is a no-op", func(t *testing.T) {
store := newMemoryRevocationStore()
if err := newAuth(store).Revoke(authtoken.Claims{"user_id": float64(1), "exp": float64(authTokenEpoch.Unix())}); err != nil {
t.Fatal(err)
}
if len(store.revoked) != 0 {
t.Errorf("expected nothing revoked, got %v", store.revoked)
}
})
t.Run("Revoking claims without an exp is refused", func(t *testing.T) {
if err := newAuth(newMemoryRevocationStore()).Revoke(authtoken.Claims{"jti": "abc"}); !errors.Is(err, authtoken.ErrInvalid) {
t.Errorf("expected ErrInvalid, got %v", err)
}
})
t.Run("Without a store, revoking is a no-op and tokens stay valid", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(encoded)
if err := auth.Revoke(claims); err != nil {
t.Fatal(err)
}
if _, err := auth.Decode(encoded); err != nil {
t.Errorf("expected the token to stay valid, got %v", err)
}
})
}
func TestAuthTokenSizeBudget(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
sized := func(target int) string {
for padding := 0; padding < 4096; padding++ {
encoded, _ := auth.Encode(map[string]any{"jti": "fixed", "pad": strings.Repeat("a", padding)})
if len(encoded) == target {
return encoded
}
}
t.Fatalf("no token of exactly %d bytes", target)
return ""
}
t.Run("A token of exactly 4096 bytes is accepted", func(t *testing.T) {
if _, err := auth.Decode(sized(4096)); err != nil {
t.Errorf("expected acceptance, got %v", err)
}
})
t.Run("A token of 4097 bytes is refused before it is parsed", func(t *testing.T) {
if _, err := auth.Decode(sized(4097)); !errors.Is(err, authtoken.ErrInvalid) {
t.Errorf("expected ErrInvalid, got %v", err)
}
})
}
@@ -79,6 +79,7 @@ func TestContainerUpdateGuidanceHTTP(t *testing.T) {
}{{"true", true}, {"TRUE", true}, {"false", false}, {"", false}, {"yes", false}}
for _, tc := range cases {
t.Run("value "+tc.value, func(t *testing.T) {
t.Setenv("JWT_SECRET", testJWTSecret)
t.Setenv("SMTP_PORT", "1")
t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "1")
t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "1")
+105
View File
@@ -0,0 +1,105 @@
package test
import (
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newCrossOriginProtectedHandler(publicURL string) (http.Handler, *int) {
reached := 0
handler := middleware.CrossOriginProtection(publicURL)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached++
w.WriteHeader(http.StatusOK)
}))
return handler, &reached
}
func sendCrossOriginRequest(handler http.Handler, method string, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "https://cms.example.com/v1/user/invite", strings.NewReader(`{"email":"attacker@evil.example"}`))
req.Header.Set("Content-Type", "text/plain")
req.AddCookie(&http.Cookie{Name: "jwt", Value: "session"})
for name, value := range headers {
req.Header.Set(name, value)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestCrossOriginProtection(t *testing.T) {
refused := []struct {
name string
method string
headers map[string]string
}{
{"a cross-site form POST", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"a same-site but cross-origin POST from a sibling subdomain", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://blog.example.com"}},
{"a cross-site DELETE", http.MethodDelete, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"an old browser's cross-origin POST without Sec-Fetch-Site", http.MethodPost, map[string]string{"Origin": "https://evil.example"}},
}
for _, tc := range refused {
t.Run("Refuses "+tc.name, func(t *testing.T) {
handler, reached := newCrossOriginProtectedHandler("https://cms.example.com")
rec := sendCrossOriginRequest(handler, tc.method, tc.headers)
if rec.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), apperr.CrossOriginErrorMessage) {
t.Errorf("expected the cross-origin message, got %q", rec.Body.String())
}
if *reached != 0 {
t.Error("the handler must not run")
}
})
}
allowed := []struct {
name string
method string
headers map[string]string
}{
{"a same-origin POST from the SPA", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"}},
{"a request typed in the address bar", http.MethodPost, map[string]string{"Sec-Fetch-Site": "none"}},
{"a non-browser client with neither header", http.MethodPost, nil},
{"a cross-site GET", http.MethodGet, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"a cross-site preflight", http.MethodOptions, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"a cross-origin POST carrying an explicit Authorization header", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://shop.example", "Authorization": "Bearer rcms_key"}},
{"an old browser's POST from the public URL behind a proxy that rewrites Host", http.MethodPost, map[string]string{"Origin": "https://cms.example.com", "Host": "127.0.0.1:8080"}},
}
for _, tc := range allowed {
t.Run("Allows "+tc.name, func(t *testing.T) {
handler, reached := newCrossOriginProtectedHandler("https://cms.example.com")
req := httptest.NewRequest(tc.method, "https://cms.example.com/v1/user/invite", nil)
for name, value := range tc.headers {
if name == "Host" {
req.Host = value
continue
}
req.Header.Set(name, value)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || *reached != 1 {
t.Errorf("expected the handler to run, got %d (reached %d)", rec.Code, *reached)
}
})
}
t.Run("An unparsable public URL still protects against cross-site requests", func(t *testing.T) {
handler, reached := newCrossOriginProtectedHandler("::not a url")
rec := sendCrossOriginRequest(handler, http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"})
if rec.Code != http.StatusForbidden || *reached != 0 {
t.Errorf("expected 403 without reaching the handler, got %d (reached %d)", rec.Code, *reached)
}
})
}
+27 -6
View File
@@ -106,17 +106,38 @@ func TestAuthenticateUserUseCase(t *testing.T) {
}
})
t.Run("unknown username never calls the hasher", func(t *testing.T) {
t.Run("unknown username spends the same hashing work as a wrong password", func(t *testing.T) {
repo := newMockUserRepo()
hasher := &spyHasher{}
uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder)
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
for attempt := 1; attempt <= 2; attempt++ {
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("attempt %d: expected user.ErrUserNotFound, got %v", attempt, err)
}
if hasher.compareCalls != attempt {
t.Errorf("attempt %d: expected %d Compare calls, got %d", attempt, attempt, hasher.compareCalls)
}
}
if hasher.compareCalls != 0 {
t.Errorf("expected the hasher's Compare to never be called, got %d calls", hasher.compareCalls)
if hasher.hashCalls != 1 {
t.Errorf("expected the decoy to be hashed once, got %d Hash calls", hasher.hashCalls)
}
})
t.Run("a repository failure other than not-found skips the decoy", func(t *testing.T) {
repo := newMockUserRepo()
repo.fail.failNextWith("GetByUsername", errStubHashFailure)
hasher := &spyHasher{}
uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder)
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
if !errors.Is(err, errStubHashFailure) {
t.Errorf("expected the repository error, got %v", err)
}
if hasher.compareCalls != 0 || hasher.hashCalls != 0 {
t.Errorf("expected no hashing, got %d Compare and %d Hash calls", hasher.compareCalls, hasher.hashCalls)
}
})
}
+727
View File
@@ -0,0 +1,727 @@
package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"hash/crc32"
"image"
"image/color"
"image/jpeg"
"image/png"
"strings"
"testing"
"github.com/gen2brain/webp"
xwebp "golang.org/x/image/webp"
)
const metadataSecret = "GPS-48.8584N-2.2945E"
func metadataTestImage() *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.NRGBA{R: uint8(x * 30), G: uint8(y * 40), B: 90, A: 255})
}
}
return img
}
type exifByteOrder interface {
binary.ByteOrder
binary.AppendByteOrder
}
func exifTIFF(order exifByteOrder, orientation uint16) []byte {
marker := []byte("II")
if order.String() == binary.BigEndian.String() {
marker = []byte("MM")
}
text := append([]byte(metadataSecret), 0)
if len(text)%2 == 1 {
text = append(text, 0)
}
ifd := uint32(8 + len(text))
tiff := append(marker, 0, 0)
order.PutUint16(tiff[2:], 42)
tiff = order.AppendUint32(tiff, ifd)
tiff = append(tiff, text...)
tiff = order.AppendUint16(tiff, 2)
tiff = order.AppendUint16(tiff, 0x010F)
tiff = order.AppendUint16(tiff, 2)
tiff = order.AppendUint32(tiff, uint32(len(metadataSecret)+1))
tiff = order.AppendUint32(tiff, 8)
tiff = order.AppendUint16(tiff, 0x0112)
tiff = order.AppendUint16(tiff, 3)
tiff = order.AppendUint32(tiff, 1)
tiff = order.AppendUint16(tiff, orientation)
tiff = order.AppendUint16(tiff, 0)
return order.AppendUint32(tiff, 0)
}
func jpegSegmentBytes(marker byte, payload []byte) []byte {
segment := []byte{0xFF, marker}
segment = binary.BigEndian.AppendUint16(segment, uint16(len(payload)+2))
return append(segment, payload...)
}
func encodedJPEG(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, metadataTestImage(), &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func jpegWithMetadata(t *testing.T, orientation uint16, extra ...[]byte) []byte {
t.Helper()
plain := encodedJPEG(t)
out := append([]byte{}, plain[:2]...)
for _, segment := range extra {
out = append(out, segment...)
}
out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), exifTIFF(binary.LittleEndian, orientation)...))...)
out = append(out, jpegSegmentBytes(0xE1, []byte("http://ns.adobe.com/xap/1.0/\x00<x:xmpmeta>"+metadataSecret+"</x:xmpmeta>"))...)
out = append(out, jpegSegmentBytes(0xED, []byte("Photoshop 3.0\x008BIM"+metadataSecret))...)
out = append(out, jpegSegmentBytes(0xFE, []byte("comment "+metadataSecret))...)
out = append(out, jpegSegmentBytes(0xE2, []byte("MPF\x00"+metadataSecret))...)
out = append(out, jpegSegmentBytes(0xE2, []byte("ICC_PROFILE\x00\x01\x01profile-bytes"))...)
out = append(out, plain[2:]...)
return append(out, []byte("\xFF\xD8trailing image "+metadataSecret+"\xFF\xD9")...)
}
func pngChunkBytes(kind string, payload []byte) []byte {
chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload)))
chunk = append(chunk, kind...)
chunk = append(chunk, payload...)
return binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:]))
}
func encodedPNG(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, metadataTestImage()); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func pngWithMetadata(t *testing.T, orientation uint16) []byte {
t.Helper()
plain := encodedPNG(t)
afterIHDR := 8 + 8 + 13 + 4
out := append([]byte{}, plain[:afterIHDR]...)
out = append(out, pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, orientation))...)
out = append(out, pngChunkBytes("tEXt", []byte("Comment\x00"+metadataSecret))...)
out = append(out, pngChunkBytes("iTXt", []byte("XML:com.adobe.xmp\x00\x00\x00\x00\x00"+metadataSecret))...)
out = append(out, pngChunkBytes("zTXt", []byte("Author\x00\x00"+metadataSecret))...)
out = append(out, pngChunkBytes("tIME", []byte{0x07, 0xEA, 10, 22, 9, 0, 0})...)
out = append(out, plain[afterIHDR:]...)
return append(out, []byte("trailing "+metadataSecret)...)
}
func riffChunkBytes(kind string, payload []byte) []byte {
chunk := append([]byte(kind), binary.LittleEndian.AppendUint32(nil, uint32(len(payload)))...)
chunk = append(chunk, payload...)
if len(payload)%2 == 1 {
chunk = append(chunk, 0)
}
return chunk
}
func webpWithMetadata(t *testing.T, orientation uint16) []byte {
t.Helper()
var buf bytes.Buffer
if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil {
t.Fatal(err)
}
simple := buf.Bytes()
vp8x := []byte{0x08 | 0x04, 0, 0, 0}
vp8x = append(vp8x, 7, 0, 0, 5, 0, 0)
body := []byte("WEBP")
body = append(body, riffChunkBytes("VP8X", vp8x)...)
body = append(body, simple[12:]...)
body = append(body, riffChunkBytes("EXIF", exifTIFF(binary.LittleEndian, orientation))...)
body = append(body, riffChunkBytes("XMP ", []byte("<x:xmpmeta>"+metadataSecret+"</x:xmpmeta>!"))...)
out := append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...)
out = append(out, body...)
return append(out, []byte("trailing "+metadataSecret)...)
}
func assertSamePixels(t *testing.T, want image.Image, got image.Image) {
t.Helper()
if want.Bounds() != got.Bounds() {
t.Fatalf("bounds changed: %v vs %v", want.Bounds(), got.Bounds())
}
for y := want.Bounds().Min.Y; y < want.Bounds().Max.Y; y++ {
for x := want.Bounds().Min.X; x < want.Bounds().Max.X; x++ {
if want.At(x, y) != got.At(x, y) {
t.Fatalf("pixel (%d,%d) changed: %v vs %v", x, y, want.At(x, y), got.At(x, y))
}
}
}
}
func assertNoSecret(t *testing.T, data []byte) {
t.Helper()
if bytes.Contains(data, []byte(metadataSecret)) {
t.Errorf("the metadata secret survived stripping")
}
}
func orientationOnlyEntry(orientation uint16) []byte {
entry := []byte{0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01}
return binary.BigEndian.AppendUint16(entry, orientation)
}
func TestMetadataStripperJPEG(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("Drops EXIF, XMP, IPTC, comments, MPF and trailing images but keeps the pixels and the ICC profile", func(t *testing.T) {
original := jpegWithMetadata(t, 1)
stripped, err := stripper.Strip("image/jpeg", original)
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if !bytes.Contains(stripped, []byte("ICC_PROFILE\x00")) {
t.Error("the ICC profile must be kept")
}
if bytes.Contains(stripped, []byte("Exif\x00\x00")) {
t.Error("an upright image needs no EXIF at all")
}
if !bytes.HasSuffix(stripped, []byte{0xFF, 0xD9}) || bytes.Count(stripped, []byte{0xFF, 0xD8}) != 1 {
t.Error("the output must end at the primary image's EOI")
}
want, _ := jpeg.Decode(bytes.NewReader(encodedJPEG(t)))
got, err := jpeg.Decode(bytes.NewReader(stripped))
if err != nil {
t.Fatalf("the stripped JPEG does not decode: %v", err)
}
assertSamePixels(t, want, got)
})
t.Run("Keeps only the orientation of a rotated photo, right after SOI", func(t *testing.T) {
stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 6))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if !bytes.Equal(stripped[2:4], []byte{0xFF, 0xE1}) || !bytes.Contains(stripped[:64], orientationOnlyEntry(6)) {
t.Errorf("expected an orientation-only APP1 after SOI, got % x", stripped[:48])
}
if _, err := jpeg.Decode(bytes.NewReader(stripped)); err != nil {
t.Errorf("the stripped JPEG does not decode: %v", err)
}
})
t.Run("Puts the orientation after a leading JFIF header", func(t *testing.T) {
jfif := jpegSegmentBytes(0xE0, []byte("JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"))
stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 8, jfif))
if err != nil {
t.Fatal(err)
}
next := 2 + len(jfif)
if !bytes.Equal(stripped[2:next], jfif) || !bytes.Equal(stripped[next:next+2], []byte{0xFF, 0xE1}) {
t.Errorf("expected JFIF then the orientation APP1, got % x", stripped[:next+4])
}
})
t.Run("A JPEG cut off inside its scan keeps what it has", func(t *testing.T) {
plain := encodedJPEG(t)
truncated := plain[:len(plain)-20]
stripped, err := stripper.Strip("image/jpeg", truncated)
if err != nil || !bytes.Equal(stripped, truncated) {
t.Errorf("expected the truncated scan kept as is, got %d bytes, %v", len(stripped), err)
}
})
malformed := map[string][]byte{
"not a JPEG": []byte("GIF89a"),
"a segment longer than the file": {0xFF, 0xD8, 0xFF, 0xE1, 0x10, 0x00, 'E'},
"a segment length below two": {0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x01},
"a missing marker": {0xFF, 0xD8, 0x00, 0x00},
"a header that stops early": {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0},
}
for name, data := range malformed {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/jpeg", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperPNG(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("Drops text, time and EXIF chunks and trailing data but keeps the pixels", func(t *testing.T) {
stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 1))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
for _, kind := range []string{"eXIf", "tEXt", "iTXt", "zTXt", "tIME"} {
if bytes.Contains(stripped, []byte(kind)) {
t.Errorf("%s survived", kind)
}
}
if !bytes.Equal(stripped, encodedPNG(t)) {
t.Error("expected exactly the plain PNG back")
}
})
t.Run("Keeps only the orientation in an eXIf chunk right after IHDR", func(t *testing.T) {
stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 3))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
afterIHDR := 8 + 8 + 13 + 4
if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || !bytes.Contains(stripped, orientationOnlyEntry(3)) {
t.Errorf("expected an orientation-only eXIf after IHDR")
}
want, _ := png.Decode(bytes.NewReader(encodedPNG(t)))
got, err := png.Decode(bytes.NewReader(stripped))
if err != nil {
t.Fatalf("the stripped PNG does not decode: %v", err)
}
assertSamePixels(t, want, got)
})
plain := func(t *testing.T) []byte { return encodedPNG(t) }
malformed := map[string]func(t *testing.T) []byte{
"not a PNG": func(*testing.T) []byte { return []byte("GIF89a") },
"a PNG without IEND": func(t *testing.T) []byte { p := plain(t); return p[:len(p)-12] },
"a chunk longer than the file": func(t *testing.T) []byte { p := plain(t); binary.BigEndian.PutUint32(p[8:], 1<<30); return p },
"a chunk header that stops early": func(t *testing.T) []byte { return append(plain(t)[:8], 0, 0, 0) },
}
for name, build := range malformed {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/png", build(t)); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperWebP(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("Drops XMP and EXIF, clears their flags and keeps the pixels", func(t *testing.T) {
original := webpWithMetadata(t, 1)
stripped, err := stripper.Strip("image/webp", original)
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if bytes.Contains(stripped, []byte("EXIF")) || bytes.Contains(stripped, []byte("XMP ")) {
t.Error("metadata chunks survived")
}
if flags := stripped[20]; flags&0x0C != 0 {
t.Errorf("expected the EXIF and XMP flags cleared, got %#x", flags)
}
if size := binary.LittleEndian.Uint32(stripped[4:]); int(size) != len(stripped)-8 {
t.Errorf("RIFF size %d does not match %d bytes", size, len(stripped)-8)
}
want, _ := xwebp.Decode(bytes.NewReader(original))
got, err := xwebp.Decode(bytes.NewReader(stripped))
if err != nil {
t.Fatalf("the stripped WebP does not decode: %v", err)
}
assertSamePixels(t, want, got)
})
t.Run("Keeps only the orientation of a rotated image", func(t *testing.T) {
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 8))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if flags := stripped[20]; flags&0x0C != 0x08 {
t.Errorf("expected only the EXIF flag set, got %#x", flags)
}
if !bytes.Contains(stripped, orientationOnlyEntry(8)) {
t.Error("expected an orientation-only EXIF chunk")
}
if _, err := xwebp.Decode(bytes.NewReader(stripped)); err != nil {
t.Errorf("the stripped WebP does not decode: %v", err)
}
})
t.Run("A simple WebP without extended header comes back unchanged", func(t *testing.T) {
var buf bytes.Buffer
if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil {
t.Fatal(err)
}
stripped, err := stripper.Strip("image/webp", buf.Bytes())
if err != nil || !bytes.Equal(stripped, buf.Bytes()) {
t.Errorf("expected the same bytes back, got %v", err)
}
})
malformed := map[string][]byte{
"not a WebP": []byte("RIFF\x04\x00\x00\x00WAVE"),
"a RIFF size beyond the file": []byte("RIFF\xff\x00\x00\x00WEBP"),
"a chunk longer than the file": append([]byte("RIFF\x10\x00\x00\x00WEBPVP8L"), 0xff, 0, 0, 0),
"a container with no chunks": []byte("RIFF\x04\x00\x00\x00WEBP"),
"an empty extended header": append([]byte("RIFF\x0c\x00\x00\x00WEBPVP8X"), 0, 0, 0, 0),
"a chunk header that stops early": []byte("RIFF\x08\x00\x00\x00WEBPVP8X"),
}
for name, data := range malformed {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperLeavesSVGAlone(t *testing.T) {
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><metadata>keep</metadata></svg>`)
stripped, err := imaging.NewMetadataStripper().Strip("image/svg+xml", svg)
if err != nil || !bytes.Equal(stripped, svg) {
t.Errorf("expected the SVG untouched, got %v", err)
}
}
func TestUploadStoresTheStrippedImage(t *testing.T) {
repo := newMockImageRepo()
store := newMockImageStorage()
uc := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())
original := jpegWithMetadata(t, 6)
created, err := uc.CreateImage(testCtx, domainImage.ImageInput{
Content: bytes.NewReader(original),
Filename: "holiday.jpg",
Size: int64(len(original)),
ContentType: "image/jpeg",
})
if err != nil {
t.Fatal(err)
}
stored := store.lastContent
assertNoSecret(t, stored)
if created.Size != int64(len(stored)) {
t.Errorf("expected the recorded size %d to match the stored %d bytes", created.Size, len(stored))
}
sum := sha256.Sum256(stored)
if created.SHA256 != hex.EncodeToString(sum[:]) {
t.Error("expected the checksum of the stored bytes")
}
if created.Width != 8 || created.Height != 6 {
t.Errorf("expected 8x6, got %dx%d", created.Width, created.Height)
}
}
func TestUploadRefusesAnImageWhoseContainerIsBroken(t *testing.T) {
store := newMockImageStorage()
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), store, discardRecorder, imaging.NewMetadataStripper())
broken := encodedPNG(t)
broken = broken[:len(broken)-12]
_, err := uc.CreateImage(testCtx, domainImage.ImageInput{Content: bytes.NewReader(broken), Filename: "broken.png", ContentType: "image/png"})
if !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
if store.putCalls != 0 {
t.Error("nothing may be stored")
}
}
func hexBytes(t *testing.T, value string) []byte {
t.Helper()
decoded, err := hex.DecodeString(strings.ReplaceAll(value, " ", ""))
if err != nil {
t.Fatal(err)
}
return decoded
}
func jpegWithExif(tiff []byte) []byte {
out := []byte{0xFF, 0xD8}
out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), tiff...))...)
return append(out, 0xFF, 0xD9)
}
func TestMetadataStripperJPEGStructure(t *testing.T) {
stripper := imaging.NewMetadataStripper()
kept := map[string]string{
"the smallest JPEG, SOI then EOI": "FFD8 FFD9",
"standalone TEM and RST markers between segments": "FFD8 FF01 FFD0 FFD7 FFD9",
"restart markers and stuffed bytes inside a scan": "FFD8 FFDA 0002 11 FF00 22 FFD0 33 FFD7 44 FFD9",
"a scan cut off right after a 0xFF byte": "FFD8 FFDA 0002 11 22 FF",
"a scan cut off right after a stuffed byte": "FFD8 FFDA 0002 11 FF00",
"an Adobe APP14 segment": "FFD8 FFEE 0007 41646F6265 FFD9",
"an ICC profile in APP2": "FFD8 FFE2 000E 4943435F50524F46494C4500 FFD9",
"a quantisation table, which is not metadata at all": "FFD8 FFDB 0003 00 FFD9",
}
for name, value := range kept {
t.Run("Keeps "+name, func(t *testing.T) {
data := hexBytes(t, value)
stripped, err := stripper.Strip("image/jpeg", data)
if err != nil || !bytes.Equal(stripped, data) {
t.Errorf("expected the bytes back unchanged, got % X, %v", stripped, err)
}
})
}
removed := map[string]string{
"fill bytes before a marker": "FFD8 FFFFFF FFD9",
"an empty comment": "FFD8 FFFE 0002 FFD9",
"an APP3 segment": "FFD8 FFE3 0003 00 FFD9",
"an APP12 segment": "FFD8 FFEC 0003 00 FFD9",
"an APP15 segment": "FFD8 FFEF 0003 00 FFD9",
"a FlashPix APP2 segment": "FFD8 FFE2 0006 46505852 FFD9",
"an XMP APP1 segment": "FFD8 FFE1 0006 68747470 FFD9",
}
for name, value := range removed {
t.Run("Drops "+name, func(t *testing.T) {
stripped, err := stripper.Strip("image/jpeg", hexBytes(t, value))
if err != nil || !bytes.Equal(stripped, []byte{0xFF, 0xD8, 0xFF, 0xD9}) {
t.Errorf("expected only SOI and EOI, got % X, %v", stripped, err)
}
})
}
refused := map[string]string{
"only three bytes": "FFD8 FF",
"fill bytes up to the end": "FFD8 FFFF",
"a segment length cut in half": "FFD8 FFE1 00",
"a segment whose length runs past EOF": "FFD8 FFE1 0004 00",
"no EOI after the header": "FFD8 FFDB 0003 00",
}
for name, value := range refused {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/jpeg", hexBytes(t, value)); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperReadsOnlyAWellFormedOrientation(t *testing.T) {
stripper := imaging.NewMetadataStripper()
valid := exifTIFF(binary.BigEndian, 8)
ifd := int(binary.BigEndian.Uint32(valid[4:]))
withValue := func(value uint16) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint16(tiff[ifd+2+12+8:], value)
return tiff
}
withType := func(kind uint16) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint16(tiff[ifd+2+12+2:], kind)
return tiff
}
withIFD := func(offset uint32) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint32(tiff[4:], offset)
return tiff
}
withCount := func(count uint16) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint16(tiff[ifd:], count)
return tiff[:ifd+2+12]
}
cases := []struct {
name string
tiff []byte
want uint16
}{
{"a valid big-endian orientation", valid, 8},
{"a valid little-endian orientation", exifTIFF(binary.LittleEndian, 5), 5},
{"the smallest orientation that needs rotating", withValue(2), 2},
{"an orientation of 0", withValue(0), 0},
{"an orientation of 9", withValue(9), 0},
{"an orientation stored as a LONG", withType(4), 0},
{"a TIFF shorter than its header", valid[:7], 0},
{"an unknown byte order", append([]byte("XX"), valid[2:]...), 0},
{"a wrong magic number", append([]byte("MM\x00\x2B"), valid[4:]...), 0},
{"an IFD offset inside the header", withIFD(4), 0},
{"an IFD offset on the last byte", withIFD(uint32(len(valid) - 1)), 0},
{"an IFD offset two bytes before the end", withIFD(uint32(len(valid) - 2)), 0},
{"an entry count larger than the entries present", withCount(3), 0},
{"a TIFF cut inside the orientation entry", valid[:ifd+2+12+11], 0},
{"a TIFF that ends right after the orientation entry", valid[:ifd+2+24], 8},
{"an IFD that starts right after the header", hexBytes(t, "4D4D002A 00000008 0001 0112 0003 00000001 0003 0000 00000000"), 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stripped, err := stripper.Strip("image/jpeg", jpegWithExif(tc.tiff))
if err != nil {
t.Fatal(err)
}
hasOrientation := bytes.Contains(stripped, []byte("Exif\x00\x00"))
if tc.want == 0 && hasOrientation {
t.Errorf("expected no orientation kept, got % X", stripped)
}
if tc.want != 0 && !bytes.Contains(stripped, orientationOnlyEntry(tc.want)) {
t.Errorf("expected orientation %d kept, got % X", tc.want, stripped)
}
})
}
}
func TestMetadataStripperPNGStructure(t *testing.T) {
stripper := imaging.NewMetadataStripper()
plain := encodedPNG(t)
iend := plain[len(plain)-12:]
body := plain[:len(plain)-12]
t.Run("Keeps an empty ancillary chunk it does not know", func(t *testing.T) {
data := append(append(bytes.Clone(body), pngChunkBytes("abCd", nil)...), iend...)
stripped, err := stripper.Strip("image/png", data)
if err != nil || !bytes.Equal(stripped, data) {
t.Errorf("expected the bytes back unchanged, got %v", err)
}
})
t.Run("Refuses a chunk one byte longer than the file", func(t *testing.T) {
data := bytes.Clone(plain)
binary.BigEndian.PutUint32(data[len(data)-12:], 1)
if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
t.Run("Refuses an IEND missing its last CRC byte", func(t *testing.T) {
if _, err := stripper.Strip("image/png", plain[:len(plain)-1]); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
t.Run("Puts a kept orientation right after IHDR even when IHDR is followed by other chunks", func(t *testing.T) {
afterIHDR := 8 + 8 + 13 + 4
data := append(bytes.Clone(plain[:afterIHDR]), pngChunkBytes("gAMA", []byte{0, 0, 0xB1, 0x8F})...)
data = append(data, pngChunkBytes("eXIf", exifTIFF(binary.LittleEndian, 6))...)
data = append(data, plain[afterIHDR:]...)
stripped, err := stripper.Strip("image/png", data)
if err != nil {
t.Fatal(err)
}
if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || string(stripped[afterIHDR+12+26+4:afterIHDR+12+26+8]) != "gAMA" {
t.Errorf("expected eXIf then gAMA after IHDR")
}
if _, err := png.Decode(bytes.NewReader(stripped)); err != nil {
t.Errorf("the stripped PNG does not decode: %v", err)
}
})
t.Run("Refuses a rotated PNG with no IHDR to put the orientation after", func(t *testing.T) {
data := append(bytes.Clone(pngSignatureBytes), pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, 6))...)
data = append(data, pngChunkBytes("IEND", nil)...)
if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
var pngSignatureBytes = []byte("\x89PNG\r\n\x1a\n")
func riffChunkKinds(t *testing.T, data []byte) []string {
t.Helper()
if int(binary.LittleEndian.Uint32(data[4:]))+8 != len(data) {
t.Fatalf("RIFF size %d does not match %d bytes", binary.LittleEndian.Uint32(data[4:]), len(data)-8)
}
var kinds []string
for pos := 12; pos < len(data); {
size := int(binary.LittleEndian.Uint32(data[pos+4:]))
kinds = append(kinds, string(data[pos:pos+4]))
pos += 8 + size + size%2
if pos > len(data) {
t.Fatalf("chunk %s runs past the end", kinds[len(kinds)-1])
}
}
return kinds
}
func TestMetadataStripperWebPStructure(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("The rotated output is a well-formed chain of chunks", func(t *testing.T) {
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 6))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,EXIF" {
t.Errorf("expected VP8X,VP8L,EXIF, got %s", got)
}
})
t.Run("An upright output keeps only the image chunks", func(t *testing.T) {
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 1))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L" {
t.Errorf("expected VP8X,VP8L, got %s", got)
}
})
t.Run("Keeps an empty chunk it does not know at the very end", func(t *testing.T) {
original := webpWithMetadata(t, 1)
declared := int(binary.LittleEndian.Uint32(original[4:])) + 8
data := append(bytes.Clone(original[:declared]), riffChunkBytes("ABCD", nil)...)
binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8))
stripped, err := stripper.Strip("image/webp", data)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,ABCD" {
t.Errorf("expected VP8X,VP8L,ABCD, got %s", got)
}
})
t.Run("Refuses an odd-sized chunk missing its padding byte", func(t *testing.T) {
original := webpWithMetadata(t, 1)
declared := int(binary.LittleEndian.Uint32(original[4:])) + 8
data := append(bytes.Clone(original[:declared]), "ABCD\x01\x00\x00\x00X"...)
binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8))
if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
+2 -1
View File
@@ -2,6 +2,7 @@ package test
import (
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io"
@@ -280,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
t.Run("Create Image", func(t *testing.T) {
+6 -5
View File
@@ -2,6 +2,7 @@ package test
import (
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
@@ -39,7 +40,7 @@ func TestImageUploadRules(t *testing.T) {
for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} {
t.Run(contentType, func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
result, err := createUc.CreateImage(testCtx, inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
@@ -58,7 +59,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a disallowed content type before reaching the repository", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf([]byte("%PDF"), "application/pdf"))
@@ -72,7 +73,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a file that exceeds the size limit", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
@@ -86,7 +87,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A file exactly at the size limit is accepted, one byte over is not", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
if err != nil {
@@ -108,7 +109,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A repository failure unrelated to type or size still propagates unchanged", func(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNextWith("Create", errRepositoryFailure)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf(pngBytes(t, 3, 2), "image/png"))
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/usage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
@@ -128,7 +129,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(e.variants, e.images, e.files, e.files, e.storage, clock, recorder),
VariantUsageTracker: e.tracker,
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder, imaging.NewMetadataStripper()),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+2 -1
View File
@@ -7,6 +7,7 @@ import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
@@ -73,7 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
+4 -3
View File
@@ -3,6 +3,7 @@ package test
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
@@ -992,7 +993,7 @@ func TestMediaMerging(t *testing.T) {
func TestMediaSentinels(t *testing.T) {
t.Run("Create reports unreadable bytes as ErrImageUnreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
for _, contentType := range domainImage.AllowedContentTypes() {
_, err := uc.CreateImage(testCtx, inputOf([]byte("not an image"), contentType))
@@ -1003,7 +1004,7 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("An svg without an svg root is unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := uc.CreateImage(testCtx, inputOf([]byte("<html/>"), "image/svg+xml"))
@@ -1013,7 +1014,7 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("Bytes of another raster format than the declared type are unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := uc.CreateImage(testCtx, inputOf(jpegBytes(t, 3, 2), "image/png"))
+7 -6
View File
@@ -2,6 +2,7 @@ package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"RenewCMS/internal/infrastructure/storage"
@@ -35,7 +36,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
t.Run("The row points at the stored name and the blob holds the bytes", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()))
if !strings.HasPrefix(created.Path, domainImage.PublicPathPrefix) {
t.Errorf("unexpected path %q", created.Path)
@@ -49,7 +50,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
store.failPut = errRepositoryFailure
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t))
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()).CreateImage(testCtx, pngUpload(t))
if !errors.Is(err, errRepositoryFailure) || repo.createCalls != 0 {
t.Errorf("got %v with %d Create calls", err, repo.createCalls)
@@ -60,7 +61,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
repo.fail.failNextWith("Create", errRepositoryFailure)
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t))
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()).CreateImage(testCtx, pngUpload(t))
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err)
@@ -74,7 +75,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("Both disappear", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()))
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
@@ -96,7 +97,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("A blob failure keeps the row so the delete can be retried", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID)
@@ -109,7 +110,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
repo, articles, store := newMockImageRepo(), newMockArticleRepo(), newMockImageStorage()
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := newTestMergeMedia(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
+1 -1
View File
@@ -447,7 +447,7 @@ func startupEnvironment(t *testing.T, extra ...string) []string {
dataDir := t.TempDir()
return append(emptyEnvironment(),
append([]string{
"ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-startup",
"ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=" + testJWTSecret,
"SMTP_LOGIN=a@example.com", "SMTP_SENDER=a@example.com", "SMTP_PASSWORD=x", "SMTP_HOST=localhost", "SMTP_PORT=2525",
"DB_FILE=" + filepath.Join(dataDir, "renewcms.db"), "UPLOAD_DIR=" + filepath.Join(dataDir, "uploads"),
"UPDATE_FEED_URL=https://git.example",
+1 -1
View File
@@ -156,7 +156,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
func setJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth, userId uint32) {
t.Helper()
tokenString, err := token.Encode(map[string]any{"user_id": userId})
tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": 0})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
+96
View File
@@ -0,0 +1,96 @@
package test
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"errors"
"net/http"
"strings"
"testing"
"time"
)
func TestPasswordLengthLimit(t *testing.T) {
validator.InitValidator()
longest := strings.Repeat("p", user.MaxPasswordBytes)
tooLong := longest + "p"
multibyteTooLong := strings.Repeat("é", 37)
t.Run("The domain accepts 72 bytes and refuses 73, counting bytes rather than characters", func(t *testing.T) {
if err := user.ValidatePassword(longest); err != nil {
t.Errorf("72 bytes: expected no error, got %v", err)
}
if err := user.ValidatePassword(tooLong); !errors.Is(err, user.ErrPasswordTooLong) {
t.Errorf("73 bytes: expected ErrPasswordTooLong, got %v", err)
}
if err := user.ValidatePassword(multibyteTooLong); !errors.Is(err, user.ErrPasswordTooLong) {
t.Errorf("37 two-byte characters: expected ErrPasswordTooLong, got %v", err)
}
})
t.Run("Registering with a password over 72 bytes answers 400 and creates nothing", func(t *testing.T) {
userRepo := newMockUserRepo()
mailRepo := newMockMailRepo()
router := newRegistrationGateRouter(userRepo, mailRepo, "https://cms.example.com")
rec := registerRequest(t, router, "alice", multibyteTooLong, "alice@example.com")
if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != apperr.PasswordTooLongErrorMessage {
t.Errorf("expected 400 %q, got %d %q", apperr.PasswordTooLongErrorMessage, rec.Code, rec.Body.String())
}
if len(userRepo.users) != 0 || len(mailRepo.SentEmails) != 0 {
t.Errorf("expected no user and no mail, got %d users and %d mails", len(userRepo.users), len(mailRepo.SentEmails))
}
})
t.Run("Registering with exactly 72 bytes still works", func(t *testing.T) {
userRepo := newMockUserRepo()
router := newRegistrationGateRouter(userRepo, newMockMailRepo(), "https://cms.example.com")
if rec := registerRequest(t, router, "alice", longest, "alice@example.com"); rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("Resetting to a password over 72 bytes answers 400 and keeps the old password and code", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
router := newResetPasswordRouter(userRepo, &stubHasher{})
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "`+tooLong+`"}`)
if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != apperr.PasswordTooLongErrorMessage {
t.Errorf("expected 400 %q, got %d %q", apperr.PasswordTooLongErrorMessage, rec.Code, rec.Body.String())
}
stored := userRepo.users[1]
if stored.Password != "hashed:oldpassword" || stored.PasswordResetCode != "hashed:reset-code-1" {
t.Errorf("expected the account untouched, got %+v", stored)
}
})
t.Run("A wrong reset code is reported as such even with an over-long password", func(t *testing.T) {
userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour))
router := newResetPasswordRouter(userRepo, &stubHasher{})
rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "wrong", "password": "`+tooLong+`"}`)
if rec.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", rec.Code)
}
})
t.Run("Accepting an invite with a password over 72 bytes answers 400 and creates nothing", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "newadmin", tooLong))
if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != apperr.PasswordTooLongErrorMessage {
t.Errorf("expected 400 %q, got %d %q", apperr.PasswordTooLongErrorMessage, rec.Code, rec.Body.String())
}
if len(f.users.users) != usersBefore {
t.Errorf("expected no user created")
}
})
}
+240
View File
@@ -0,0 +1,240 @@
package test
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/retention"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"log"
"strings"
"testing"
"time"
)
var retentionNow = time.Date(2026, time.October, 22, 3, 0, 0, 0, time.UTC)
func TestUnverifiedAccountRetentionRule(t *testing.T) {
cases := []struct {
name string
user user.User
stale bool
}{
{"verified accounts are never stale", user.User{IsVerified: true, VerificationExpiration: retentionNow.Add(-365 * 24 * time.Hour)}, false},
{"a pending code is not stale", user.User{VerificationExpiration: retentionNow.Add(time.Hour)}, false},
{"an expired code inside the grace period is not stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention)}, false},
{"an expired code past the grace period is stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true},
{"without an expiry, the creation date starts the grace period", user.User{CreatedAt: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true},
{"without any date, an account is kept", user.User{}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.user.IsStaleUnverifiedAt(retentionNow); got != tc.stale {
t.Errorf("expected %v, got %v", tc.stale, got)
}
})
}
}
func TestInviteRetentionRule(t *testing.T) {
acceptedLongAgo := retentionNow.Add(-invite.Retention - time.Second)
acceptedRecently := retentionNow.Add(-invite.Retention)
cases := []struct {
name string
invite invite.Invite
stale bool
}{
{"a pending invite is kept", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour)}, false},
{"an invite that expired inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention)}, false},
{"an invite that expired before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention - time.Second)}, true},
{"an invite accepted inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention * 2), AcceptedAt: &acceptedRecently}, false},
{"an invite accepted before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour), AcceptedAt: &acceptedLongAgo}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.invite.IsStaleAt(retentionNow); got != tc.stale {
t.Errorf("expected %v, got %v", tc.stale, got)
}
})
}
}
type retentionWorld struct {
users *mockUserRepo
invites *inviteFakeRepo
sessions *memorySessionRepo
useCase *useCases.EnforceRetentionUseCase
}
func newRetentionWorld() *retentionWorld {
w := &retentionWorld{users: newMockUserRepo(), invites: newInviteFakeRepo(), sessions: newMemorySessionRepo()}
w.useCase = useCases.NewEnforceRetentionUseCase(w.users, w.invites, w.sessions)
return w
}
func (w *retentionWorld) seedEverything() {
longAgo := retentionNow.Add(-60 * 24 * time.Hour)
w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, VerificationExpiration: longAgo}
w.users.users[2] = user.User{ID: 2, Username: "abandoned", VerificationExpiration: longAgo}
w.users.users[3] = user.User{ID: 3, Username: "pending", VerificationExpiration: retentionNow.Add(time.Hour)}
w.invites.seed(invite.Invite{Email: "old-expired@example.com", ExpiresAt: longAgo})
w.invites.seed(invite.Invite{Email: "pending@example.com", ExpiresAt: retentionNow.Add(time.Hour)})
w.invites.seed(invite.Invite{Email: "old-accepted@example.com", ExpiresAt: longAgo, AcceptedAt: &longAgo})
w.sessions.revoked["expired"] = retentionNow.Add(-time.Minute)
w.sessions.revoked["live"] = retentionNow.Add(time.Hour)
}
func TestEnforceRetentionUseCase(t *testing.T) {
t.Run("Removes only what is past its retention period and reports the counts", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
result, err := w.useCase.Tick(retentionNow)
if err != nil {
t.Fatal(err)
}
if result != (retention.Result{RevokedSessions: 1, UnverifiedUsers: 1, Invites: 2}) {
t.Errorf("unexpected result %+v", result)
}
if _, kept := w.users.users[1]; !kept {
t.Error("the verified owner must be kept")
}
if _, kept := w.users.users[3]; !kept {
t.Error("the pending account must be kept")
}
if _, removed := w.users.users[2]; removed {
t.Error("the abandoned account must be removed")
}
remaining := w.invites.GetAll()
if len(remaining) != 1 || remaining[0].Email != "pending@example.com" {
t.Errorf("expected only the pending invite kept, got %+v", remaining)
}
if _, kept := w.sessions.revoked["live"]; !kept || len(w.sessions.revoked) != 1 {
t.Errorf("expected only the live revocation kept, got %v", w.sessions.revoked)
}
})
t.Run("A second run has nothing left to remove", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
_, _ = w.useCase.Tick(retentionNow)
result, err := w.useCase.Tick(retentionNow)
if err != nil || result.Total() != 0 {
t.Errorf("expected nothing removed, got %+v, %v", result, err)
}
})
t.Run("A session store failure stops the run before anything else is touched", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
w.sessions.failure = errors.New("database unavailable")
_, err := w.useCase.Tick(retentionNow)
if err == nil {
t.Fatal("expected an error")
}
if len(w.users.users) != 3 || len(w.invites.invites) != 3 {
t.Error("users and invites must be untouched")
}
})
t.Run("A user deletion failure is reported with the work done so far", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
w.users.fail.failNext("Delete")
result, err := w.useCase.Tick(retentionNow)
if err == nil {
t.Fatal("expected an error")
}
if result.RevokedSessions != 1 || result.UnverifiedUsers != 0 || result.Invites != 0 {
t.Errorf("unexpected partial result %+v", result)
}
if len(w.invites.invites) != 3 {
t.Error("invites must be untouched after the failure")
}
})
t.Run("An invite deletion failure is reported with the work done so far", func(t *testing.T) {
w := newRetentionWorld()
w.seedEverything()
w.invites.fail.failNext("Delete")
result, err := w.useCase.Tick(retentionNow)
if err == nil {
t.Fatal("expected an error")
}
if result.RevokedSessions != 1 || result.UnverifiedUsers != 1 || result.Invites != 0 {
t.Errorf("unexpected partial result %+v", result)
}
})
}
type retentionJobFake struct {
calls chan time.Time
result retention.Result
err error
}
func (f *retentionJobFake) Tick(now time.Time) (retention.Result, error) {
f.calls <- now
return f.result, f.err
}
func TestRetentionScheduler(t *testing.T) {
run := func(t *testing.T, job *retentionJobFake) string {
t.Helper()
ticks := make(chan time.Time, 1)
logs := &strings.Builder{}
runner := scheduler.NewRetentionRunner(job, func() time.Time { return retentionNow }, ticks, nil, log.New(logs, "", 0))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
runner.Run(ctx)
}()
ticks <- time.Time{}
select {
case got := <-job.calls:
if !got.Equal(retentionNow) {
t.Errorf("expected the injected clock, got %v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("the job was never ticked")
}
cancel()
<-done
return logs.String()
}
t.Run("A run that removed something is logged with its counts", func(t *testing.T) {
logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), result: retention.Result{RevokedSessions: 2, UnverifiedUsers: 1, Invites: 3}})
if !strings.Contains(logged, "personal data retention completed: revoked_sessions=2 unverified_users=1 invites=3") {
t.Errorf("unexpected log %q", logged)
}
})
t.Run("A run that removed nothing stays quiet", func(t *testing.T) {
if logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1)}); logged != "" {
t.Errorf("expected no log, got %q", logged)
}
})
t.Run("A failed run is logged", func(t *testing.T) {
logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), err: errors.New("database unavailable")})
if !strings.Contains(logged, "personal data retention failed: database unavailable") {
t.Errorf("unexpected log %q", logged)
}
})
}
+1 -1
View File
@@ -67,7 +67,7 @@ func newPublicArticleReadUserRepo(isVerified bool) *mockUserRepo {
func mintPublicArticleReadCookie(t *testing.T, token *authtoken.Auth, userId uint32) *http.Cookie {
t.Helper()
tokenString, err := token.Encode(map[string]any{"user_id": userId})
tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": 0})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
+105
View File
@@ -0,0 +1,105 @@
package test
import (
"RenewCMS/api/middleware"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newConnectionKeyLimiter(trustedProxies []string, maxAttempts int, window time.Duration) http.Handler {
limited := middleware.NewRateLimiter(maxAttempts, window)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
return middleware.TrustedProxyRealIP(trustedProxies)(limited)
}
func sendFromConnection(handler http.Handler, remoteAddr string, realIP string) int {
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
req.RemoteAddr = remoteAddr
if realIP != "" {
req.Header.Set("X-Real-IP", realIP)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec.Code
}
func TestRateLimitKeysOnTheConnectionHost(t *testing.T) {
t.Run("New connections from the same address share one counter whatever their source port", func(t *testing.T) {
handler := newConnectionKeyLimiter(nil, 5, time.Hour)
blocked := 0
for port := 40000; port < 40100; port++ {
if sendFromConnection(handler, fmt.Sprintf("203.0.113.7:%d", port), "") == http.StatusTooManyRequests {
blocked++
}
}
if blocked != 95 {
t.Errorf("expected 95 of 100 attempts blocked, got %d", blocked)
}
})
t.Run("IPv6 connections from the same address share one counter", func(t *testing.T) {
handler := newConnectionKeyLimiter(nil, 1, time.Hour)
first := sendFromConnection(handler, "[2001:db8::1]:50000", "")
second := sendFromConnection(handler, "[2001:db8::1]:50001", "")
if first != http.StatusOK || second != http.StatusTooManyRequests {
t.Errorf("expected 200 then 429, got %d then %d", first, second)
}
})
t.Run("Different addresses keep independent counters", func(t *testing.T) {
handler := newConnectionKeyLimiter(nil, 1, time.Hour)
first := sendFromConnection(handler, "203.0.113.7:1", "")
second := sendFromConnection(handler, "203.0.113.8:1", "")
if first != http.StatusOK || second != http.StatusOK {
t.Errorf("expected both 200, got %d and %d", first, second)
}
})
t.Run("A forwarded client keeps one counter across the proxy's connections", func(t *testing.T) {
handler := newConnectionKeyLimiter([]string{"10.0.0.1"}, 1, time.Hour)
first := sendFromConnection(handler, "10.0.0.1:51000", "198.51.100.9")
second := sendFromConnection(handler, "10.0.0.1:51001", "198.51.100.9")
if first != http.StatusOK || second != http.StatusTooManyRequests {
t.Errorf("expected 200 then 429, got %d then %d", first, second)
}
})
t.Run("A counter starts over once its window has passed", func(t *testing.T) {
handler := newConnectionKeyLimiter(nil, 1, 20*time.Millisecond)
first := sendFromConnection(handler, "203.0.113.7:1", "")
second := sendFromConnection(handler, "203.0.113.7:2", "")
time.Sleep(30 * time.Millisecond)
third := sendFromConnection(handler, "203.0.113.7:3", "")
if first != http.StatusOK || second != http.StatusTooManyRequests || third != http.StatusOK {
t.Errorf("expected 200, 429, 200, got %d, %d, %d", first, second, third)
}
})
t.Run("Sweeping expired counters never resets a counter whose window is still open", func(t *testing.T) {
handler := newConnectionKeyLimiter(nil, 1, 60*time.Millisecond)
_ = sendFromConnection(handler, "198.51.100.1:1", "")
time.Sleep(40 * time.Millisecond)
first := sendFromConnection(handler, "203.0.113.7:1", "")
time.Sleep(30 * time.Millisecond)
second := sendFromConnection(handler, "203.0.113.7:2", "")
if first != http.StatusOK || second != http.StatusTooManyRequests {
t.Errorf("expected 200 then 429 across a sweep, got %d then %d", first, second)
}
})
}
+70 -3
View File
@@ -2,16 +2,20 @@ package test
import (
"RenewCMS/internal/infrastructure/config"
"errors"
"strings"
"testing"
"time"
)
const testJWTSecret = "test-secret-0123456789abcdef0123456789"
func setBaseSettingsEnv(t *testing.T) {
t.Helper()
t.Setenv("ENVIRONMENT", "production")
t.Setenv("PUBLIC_URL", "https://cms.example.com")
t.Setenv("UPLOAD_DIR", "./data/uploads")
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("JWT_SECRET", testJWTSecret)
t.Setenv("DB_FILE", "./data/renewcms.db")
t.Setenv("PORT", "8080")
t.Setenv("SMTP_LOGIN", "smtp-login@example.com")
@@ -42,8 +46,8 @@ func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
if settings.UploadDir != "./data/uploads" {
t.Errorf("expected UploadDir %q, got %q", "./data/uploads", settings.UploadDir)
}
if settings.JWTSecret != "test-secret" {
t.Errorf("expected JWTSecret %q, got %q", "test-secret", settings.JWTSecret)
if settings.JWTSecret != testJWTSecret {
t.Errorf("expected JWTSecret %q, got %q", testJWTSecret, settings.JWTSecret)
}
if settings.DBFile != "./data/renewcms.db" {
t.Errorf("expected DBFile %q, got %q", "./data/renewcms.db", settings.DBFile)
@@ -175,3 +179,66 @@ func TestSettingsIsProductionFalseForNonProductionEnvironment(t *testing.T) {
t.Error("expected IsProduction to be false for environment \"development\"")
}
}
func TestNewSettingsFromEnvRefusesAWeakJWTSecret(t *testing.T) {
cases := map[string]string{
"empty": "",
"short": "test-secret",
"one byte short": "0123456789abcdef0123456789abcde",
"the .env.example placeholder": "change-me-to-a-long-random-string",
"the docker.env placeholder": "replace-with-output-of-openssl-rand-hex-32",
}
for name, secret := range cases {
t.Run(name, func(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("JWT_SECRET", secret)
_, err := config.NewSettingsFromEnv()
if !errors.Is(err, config.ErrWeakJWTSecret) {
t.Errorf("expected ErrWeakJWTSecret, got %v", err)
}
if err != nil && !strings.Contains(err.Error(), "openssl rand -hex 32") {
t.Errorf("expected the error to say how to generate a secret, got %q", err)
}
})
}
t.Run("A secret of exactly the minimum length is accepted", func(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("JWT_SECRET", strings.Repeat("k", config.MinJWTSecretBytes))
if _, err := config.NewSettingsFromEnv(); err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
func TestNewSettingsFromEnvReadsSmtpAllowInsecure(t *testing.T) {
for raw, want := range map[string]bool{"": false, "false": false, "true": true, "TRUE": true} {
t.Run("value "+raw, func(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("SMTP_ALLOW_INSECURE", raw)
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatal(err)
}
if settings.Smtp.AllowInsecure != want {
t.Errorf("expected AllowInsecure %v, got %v", want, settings.Smtp.AllowInsecure)
}
})
}
t.Run("An unparsable value names the variable", func(t *testing.T) {
setBaseSettingsEnv(t)
t.Setenv("SMTP_ALLOW_INSECURE", "maybe")
_, err := config.NewSettingsFromEnv()
if err == nil || !strings.Contains(err.Error(), "SMTP_ALLOW_INSECURE") {
t.Errorf("expected an error naming SMTP_ALLOW_INSECURE, got %v", err)
}
})
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
@@ -141,7 +142,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
+3
View File
@@ -51,6 +51,9 @@ func TestSecurityHeadersContentSecurityPolicy(t *testing.T) {
if strings.Contains(csp, "'unsafe-eval'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") {
t.Errorf("script-src must not allow unsafe sources, got %q", csp)
}
if !strings.Contains(csp, "font-src 'self';") || strings.Contains(csp, "googleapis") || strings.Contains(csp, "gstatic") {
t.Errorf("fonts must come from the server itself, never a font CDN, got %q", csp)
}
}
func TestAllowInlineScriptsHashesOnlyInlineScripts(t *testing.T) {
+308
View File
@@ -0,0 +1,308 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/session"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type memorySessionRepo struct {
revoked map[string]time.Time
failure error
revokeFailure error
}
func newMemorySessionRepo() *memorySessionRepo {
return &memorySessionRepo{revoked: map[string]time.Time{}}
}
func (m *memorySessionRepo) Revoke(revocation session.Revocation) error {
if m.failure != nil {
return m.failure
}
if m.revokeFailure != nil {
return m.revokeFailure
}
m.revoked[revocation.ID] = revocation.ExpiresAt
return nil
}
func (m *memorySessionRepo) IsRevoked(id string) (bool, error) {
if m.failure != nil {
return false, m.failure
}
_, ok := m.revoked[id]
return ok, nil
}
func (m *memorySessionRepo) DeleteExpired(now time.Time) (int, error) {
if m.failure != nil {
return 0, m.failure
}
removed := 0
for id, expiresAt := range m.revoked {
if !expiresAt.After(now) {
delete(m.revoked, id)
removed++
}
}
return removed, nil
}
var _ session.Repository = &memorySessionRepo{}
func TestSessionRevocationUseCase(t *testing.T) {
now := time.Date(2026, time.October, 22, 9, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
t.Run("A revoked session is reported as revoked until it is purged", func(t *testing.T) {
repo := newMemorySessionRepo()
uc := useCases.NewSessionRevocationUseCase(repo, clock)
if err := uc.Revoke("session-1", now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
revoked, err := uc.IsRevoked("session-1")
if err != nil || !revoked {
t.Errorf("expected revoked, got %v, %v", revoked, err)
}
if other, _ := uc.IsRevoked("session-2"); other {
t.Error("an unrelated session must not be revoked")
}
})
t.Run("A session that has already expired is not stored", func(t *testing.T) {
repo := newMemorySessionRepo()
if err := useCases.NewSessionRevocationUseCase(repo, clock).Revoke("session-1", now); err != nil {
t.Fatal(err)
}
if len(repo.revoked) != 0 {
t.Errorf("expected nothing stored, got %v", repo.revoked)
}
})
for name, id := range map[string]string{"empty": "", "too long": strings.Repeat("a", session.MaxIDLength+1)} {
t.Run("Rejects an "+name+" session id", func(t *testing.T) {
uc := useCases.NewSessionRevocationUseCase(newMemorySessionRepo(), clock)
if err := uc.Revoke(id, now.Add(time.Hour)); !errors.Is(err, session.ErrInvalidSessionID) {
t.Errorf("Revoke: expected ErrInvalidSessionID, got %v", err)
}
if _, err := uc.IsRevoked(id); !errors.Is(err, session.ErrInvalidSessionID) {
t.Errorf("IsRevoked: expected ErrInvalidSessionID, got %v", err)
}
})
}
t.Run("Accepts an id of exactly the maximum length", func(t *testing.T) {
repo := newMemorySessionRepo()
id := strings.Repeat("a", session.MaxIDLength)
if err := useCases.NewSessionRevocationUseCase(repo, clock).Revoke(id, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if _, ok := repo.revoked[id]; !ok {
t.Error("expected the revocation to be stored")
}
})
}
func TestRevokedSessionRepository(t *testing.T) {
now := time.Date(2026, time.October, 22, 9, 0, 0, 0, time.UTC)
t.Run("Revocations survive, revoking twice is harmless, and expired ones are purged", func(t *testing.T) {
db := openRealSqlite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
repo := persistence.NewRevokedSessionRepository(db)
for _, revocation := range []session.Revocation{
{ID: "expired", ExpiresAt: now.Add(-time.Minute)},
{ID: "exactly-now", ExpiresAt: now},
{ID: "live", ExpiresAt: now.Add(time.Hour)},
{ID: "live", ExpiresAt: now.Add(time.Hour)},
} {
if err := repo.Revoke(revocation); err != nil {
t.Fatalf("revoke %s: %v", revocation.ID, err)
}
}
removed, err := repo.DeleteExpired(now)
if err != nil || removed != 2 {
t.Fatalf("expected 2 purged, got %d, %v", removed, err)
}
for id, want := range map[string]bool{"expired": false, "exactly-now": false, "live": true, "unknown": false} {
if got, err := repo.IsRevoked(id); err != nil || got != want {
t.Errorf("%s: expected %v, got %v, %v", id, want, got, err)
}
}
})
}
type sessionRevocationEnv struct {
router http.Handler
sessions *memorySessionRepo
}
func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
t.Helper()
validator.InitValidator()
hasher := hashing.NewBcryptHasher()
hashed, err := hasher.Hash("hunter2example")
if err != nil {
t.Fatal(err)
}
userRepo := newMockUserRepo()
userRepo.users[1] = domainUser.User{ID: 1, Username: "alice", Email: "alice@example.com", Password: hashed, IsVerified: true, Role: domainUser.RoleOwner}
sessions := newMemorySessionRepo()
container := &api.UseCases{
Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
}
settings := config.Settings{
Environment: "production",
PublicURL: "https://cms.example.com",
AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 100, Window: time.Minute},
}
return &sessionRevocationEnv{router: api.InitRoutes(container, settings), sessions: sessions}
}
func (e *sessionRevocationEnv) send(method string, target string, body string, cookie *http.Cookie, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if cookie != nil {
req.AddCookie(cookie)
}
for name, value := range headers {
req.Header.Set(name, value)
}
rec := httptest.NewRecorder()
e.router.ServeHTTP(rec, req)
return rec
}
func (e *sessionRevocationEnv) login(t *testing.T) *http.Cookie {
t.Helper()
rec := e.send(http.MethodPost, "/v1/auth/login", `{"username":"alice","password":"hunter2example"}`, nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("login: expected 200, got %d: %s", rec.Code, rec.Body.String())
}
cookie, ok := jwtSetCookie(t, rec)
if !ok {
t.Fatal("login: expected a jwt cookie")
}
return cookie
}
func TestLogoutRevokesTheSession(t *testing.T) {
sameOrigin := map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"}
t.Run("The session cookie is HttpOnly, Secure and SameSite=Lax", func(t *testing.T) {
cookie := newSessionRevocationEnv(t).login(t)
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode {
t.Errorf("expected HttpOnly, Secure and SameSite=Lax, got %+v", cookie)
}
})
t.Run("After logout the same token is refused while other sessions keep working", func(t *testing.T) {
env := newSessionRevocationEnv(t)
loggedOut := env.login(t)
otherDevice := env.login(t)
if rec := env.send(http.MethodGet, "/v1/auth/me", "", loggedOut, nil); rec.Code != http.StatusOK {
t.Fatalf("before logout: expected 200, got %d", rec.Code)
}
rec := env.send(http.MethodPost, "/v1/auth/logout", "", loggedOut, sameOrigin)
if rec.Code != http.StatusOK {
t.Fatalf("logout: expected 200, got %d: %s", rec.Code, rec.Body.String())
}
cleared, ok := jwtSetCookie(t, rec)
if !ok || cleared.Value != "" || cleared.MaxAge >= 0 || cleared.SameSite != http.SameSiteLaxMode {
t.Errorf("expected a cleared SameSite=Lax cookie, got %+v", cleared)
}
if rec := env.send(http.MethodGet, "/v1/auth/me", "", loggedOut, nil); rec.Code != http.StatusUnauthorized {
t.Errorf("replayed token: expected 401, got %d", rec.Code)
}
if rec := env.send(http.MethodGet, "/v1/auth/me", "", otherDevice, nil); rec.Code != http.StatusOK {
t.Errorf("other session: expected 200, got %d", rec.Code)
}
if len(env.sessions.revoked) != 1 {
t.Errorf("expected one revocation stored, got %v", env.sessions.revoked)
}
})
t.Run("Logging out without a session still clears the cookie and stores nothing", func(t *testing.T) {
env := newSessionRevocationEnv(t)
rec := env.send(http.MethodPost, "/v1/auth/logout", "", nil, sameOrigin)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if len(env.sessions.revoked) != 0 {
t.Errorf("expected nothing stored, got %v", env.sessions.revoked)
}
})
t.Run("A failed revocation answers 500 but still clears the cookie", func(t *testing.T) {
env := newSessionRevocationEnv(t)
cookie := env.login(t)
env.sessions.revokeFailure = errors.New("database unavailable")
rec := env.send(http.MethodPost, "/v1/auth/logout", "", cookie, sameOrigin)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", rec.Code)
}
if cleared, ok := jwtSetCookie(t, rec); !ok || cleared.Value != "" {
t.Errorf("expected the cookie to be cleared, got %+v", cleared)
}
})
t.Run("A token whose revocation cannot be checked is refused", func(t *testing.T) {
env := newSessionRevocationEnv(t)
cookie := env.login(t)
env.sessions.failure = errors.New("database unavailable")
if rec := env.send(http.MethodGet, "/v1/auth/me", "", cookie, nil); rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
})
t.Run("A cross-site logout POST is refused before it reaches the handler", func(t *testing.T) {
env := newSessionRevocationEnv(t)
cookie := env.login(t)
rec := env.send(http.MethodPost, "/v1/auth/logout", "", cookie, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"})
if rec.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", rec.Code)
}
if len(env.sessions.revoked) != 0 {
t.Error("the session must not be touched")
}
})
}
+40
View File
@@ -180,6 +180,46 @@ func TestSMTPMailerDelivery(t *testing.T) {
})
}
t.Run("Refuses to deliver in clear text to a non-loopback server that offers no STARTTLS", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{listenAddress: "127.0.0.2:0"})
err := smtpMailerFor(server, config.SmtpSettings{Host: "127.0.0.2"}).Send("reader@example.org", "verification", verificationData())
if err == nil || !strings.Contains(err.Error(), "SMTP_ALLOW_INSECURE") {
t.Fatalf("expected an error naming SMTP_ALLOW_INSECURE, got %v", err)
}
if session := server.received()[0]; session.data != "" || session.from != "" {
t.Errorf("nothing may be sent in clear text: %+v", session)
}
})
t.Run("Delivers in clear text to a non-loopback server when SMTP_ALLOW_INSECURE is set", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{listenAddress: "127.0.0.2:0"})
err := smtpMailerFor(server, config.SmtpSettings{Host: "127.0.0.2", AllowInsecure: true}).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatal(err)
}
if session := server.received()[0]; session.data == "" || session.usedTLS {
t.Errorf("expected a clear-text delivery, got %+v", session)
}
})
t.Run("Still refuses credentials in clear text when SMTP_ALLOW_INSECURE is set", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN", listenAddress: "127.0.0.2:0"})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2", AllowInsecure: true}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
if session := server.received()[0]; session.password != "" || session.data != "" {
t.Errorf("credentials or data leaked: %+v", session)
}
})
t.Run("Fails when credentials are configured but the server offers no AUTH", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
+1 -1
View File
@@ -216,7 +216,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
}
func isNewTableObject(name string) bool {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_"} {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_"} {
if strings.HasPrefix(name, prefix) {
return true
}
+1 -1
View File
@@ -142,7 +142,7 @@ func TestServerWritesRunningInfoBeforeServing(t *testing.T) {
command := exec.Command(binary)
command.Dir = workDir
command.Env = append(emptyEnvironment(),
"ENVIRONMENT=development", "PORT="+port, "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-dispatch",
"ENVIRONMENT=development", "PORT="+port, "PUBLIC_URL=http://localhost", "JWT_SECRET="+testJWTSecret,
"SMTP_LOGIN=a@example.com", "SMTP_SENDER=a@example.com", "SMTP_PASSWORD=x", "SMTP_HOST=localhost", "SMTP_PORT=2525",
"DB_FILE="+filepath.Join(dataDir, "renewcms.db"), "UPLOAD_DIR="+filepath.Join(dataDir, "uploads"),
"UPDATE_FEED_URL=https://git.example")
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
@@ -335,7 +336,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
for _, tc := range cases {
t.Run(strings.ReplaceAll(tc.contentType, "/", "_"), func(t *testing.T) {
repo := newMockImageRepo()
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
content := []byte("fake-image-data")
if tc.valid {
@@ -369,7 +370,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
func TestCreateImagePropagatesRepositoryFailure(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := uc.CreateImage(testCtx, imageDomain.ImageInput{
Content: bytes.NewReader(pngBytes(t, 3, 2)),
+17 -1
View File
@@ -269,13 +269,29 @@ func TestVerifiedAccountRequiredHttp(t *testing.T) {
assertOnlyArticleOneListed(t, rec)
})
t.Run("Listing hides offline articles from a verified caller whose token is stale", func(t *testing.T) {
articleRepo := seedListingArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(true)
stale := userRepo.users[1]
stale.TokenVersion = 1
userRepo.users[1] = stale
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, token)
req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "")
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1), "token_version": 0}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assertOnlyArticleOneListed(t, rec)
})
t.Run("Listing still returns every article for an authenticated and verified caller", func(t *testing.T) {
articleRepo := seedListingArticleRepo()
userRepo := seedVerifiedAccountRequiredUserRepo(true)
router := newVerifiedAccountRequiredRouter(articleRepo, userRepo, token)
req := newVerifiedAccountRequiredRequest(http.MethodGet, "/v1/article/", "")
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1)}))
req.AddCookie(mintVerifiedAccountRequiredCookie(t, token, map[string]any{"user_id": uint32(1), "token_version": 0}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)