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:
1 parent
e2812ee0b9
commit
ea98c5ec05
118 files changed
+3828
-130
No files matched your search
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user