test(12.2-05): cover every Go behaviour of phase 12.2
- lagoon: Date and TimeOfDay through JSON, text and real DATE/TIME columns; Fill text fallback without changing earlier conversions; required on zero dates; deferred_bindings shape, store isolation and envelope; PurgeDeferred cut-off, after-commit blobs, SKIP LOCKED, skipped types and the deferred:purge command - attach: Store limits, extensions, MIME patterns, default lists, key shape and blob cleanup; IsAllowedImage formats, polyglots, ceiling - conga and pact: framework purge schedule entry and forged jobs; the six relation child hook interfaces - cabana: fileupload and datepicker compile, upload, remove, caption, reorder and bounds; deferred commit order, rollback, applied-only and concurrent saves; relation contracts, forms, CRUD, deferral, schema
This commit is contained in:
109
modules/lagoon/attach/guard_test.go
Normal file
109
modules/lagoon/attach/guard_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package attach_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
)
|
||||
|
||||
// guardWebP is a 16x12 lossless WebP.
|
||||
var guardWebP = []byte{
|
||||
0x52, 0x49, 0x46, 0x46, 0x2a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c,
|
||||
0x1d, 0x00, 0x00, 0x00, 0x2f, 0x0f, 0xc0, 0x02, 0x00, 0x0f, 0x70, 0x14, 0xfb, 0x53, 0xd0, 0x5e,
|
||||
0x88, 0x7b, 0xfe, 0x83, 0x07, 0x62, 0xc1, 0x64, 0xfe, 0xd2, 0xbd, 0x21, 0x44, 0xf4, 0x3f, 0x74,
|
||||
0x01, 0x00,
|
||||
}
|
||||
|
||||
func guardGIF(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := gif.Encode(&buf, image.NewPaletted(image.Rect(0, 0, 3, 2), []color.Color{color.Black, color.White}), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func guardJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 3, 2)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// pngHeader is a PNG signature and a valid IHDR chunk for w x h, the part
|
||||
// image.DecodeConfig reads, without pixel data.
|
||||
func pngHeader(w, h uint32) []byte {
|
||||
var ihdr bytes.Buffer
|
||||
ihdr.WriteString("IHDR")
|
||||
_ = binary.Write(&ihdr, binary.BigEndian, w)
|
||||
_ = binary.Write(&ihdr, binary.BigEndian, h)
|
||||
ihdr.Write([]byte{8, 0, 0, 0, 0}) // 8-bit grayscale, no interlace
|
||||
var out bytes.Buffer
|
||||
out.Write([]byte("\x89PNG\r\n\x1a\n"))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(13))
|
||||
out.Write(ihdr.Bytes())
|
||||
_ = binary.Write(&out, binary.BigEndian, crc32.ChecksumIEEE(ihdr.Bytes()))
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// TestIsAllowedImageAccepts the four formats the thumbnailer decodes, and
|
||||
// an image exactly at the pixel ceiling.
|
||||
func TestIsAllowedImageAccepts(t *testing.T) {
|
||||
var p bytes.Buffer
|
||||
if err := png.Encode(&p, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, data := range map[string][]byte{
|
||||
"png": p.Bytes(),
|
||||
"gif": guardGIF(t),
|
||||
"jpeg": guardJPEG(t),
|
||||
"webp": guardWebP,
|
||||
"at the ceiling": pngHeader(4096, 4096),
|
||||
"one tall column": pngHeader(1, 4096*4096),
|
||||
} {
|
||||
if !attach.IsAllowedImage(data) {
|
||||
t.Errorf("%s refused", name)
|
||||
}
|
||||
}
|
||||
if attach.MaxImagePixels != 4096*4096 {
|
||||
t.Fatalf("MaxImagePixels = %d", attach.MaxImagePixels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsAllowedImageRefuses SVG, HTML, a GIF signature followed by script,
|
||||
// a truncated PNG, empty input, an image over 4096x4096 pixels, a zero-size
|
||||
// image and a BMP (a real image the thumbnailer does not decode).
|
||||
func TestIsAllowedImageRefuses(t *testing.T) {
|
||||
var p bytes.Buffer
|
||||
if err := png.Encode(&p, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bmp := append([]byte("BM"), make([]byte, 60)...)
|
||||
for name, data := range map[string][]byte{
|
||||
"svg": []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`),
|
||||
"html": []byte("<!DOCTYPE html><html><script>alert(1)</script></html>"),
|
||||
"gif polyglot": []byte("GIF89a<script>alert(document.domain)</script>"),
|
||||
"png polyglot": append([]byte("\x89PNG\r\n\x1a\n"), []byte("<html><script>alert(1)</script>")...),
|
||||
"truncated png": p.Bytes()[:20],
|
||||
"empty": nil,
|
||||
"over the ceiling": pngHeader(4097, 4096),
|
||||
"huge": pngHeader(100000, 100000),
|
||||
"zero width": pngHeader(0, 10),
|
||||
"bmp": bmp,
|
||||
"text": []byte("just text"),
|
||||
} {
|
||||
if attach.IsAllowedImage(data) {
|
||||
t.Errorf("%s accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -124,3 +125,162 @@ func TestStoreSmokeRefusals(t *testing.T) {
|
||||
t.Fatalf("blobs left behind: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore covers attach.Store against Postgres (D-07, D-08): a failed
|
||||
// row insert deletes its blob, size limits at the boundary and while
|
||||
// streaming, extension and MIME rules, the default lists per mode, the
|
||||
// content type from the sniff or the extension, the disk name and blob key
|
||||
// shape, sort_order and is_public. No refusal leaves a blob behind.
|
||||
func TestStore(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
ctx := t.Context()
|
||||
gdb := attachGorm(t)
|
||||
bucket := memblob.OpenBucket(nil)
|
||||
t.Cleanup(func() { _ = bucket.Close() })
|
||||
png := smokePNG(t)
|
||||
|
||||
t.Run("row failure deletes the blob", func(t *testing.T) {
|
||||
// system_files does not exist yet: the insert fails after the blob
|
||||
// was written, and Store deletes it again.
|
||||
_, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "a.png", Body: bytes.NewReader(png)}, attach.Limits{Image: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "store row") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if keys := bucketKeys(t, bucket); len(keys) != 0 {
|
||||
t.Fatalf("blobs left %v", keys)
|
||||
}
|
||||
})
|
||||
if err := lagoon.Migrate(gdb, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := func(name string, body []byte, lim attach.Limits, public bool) (*attach.File, error) {
|
||||
return attach.Store(ctx, gdb, bucket, attach.Upload{FileName: name, Body: bytes.NewReader(body), Public: public}, lim)
|
||||
}
|
||||
refuse := func(t *testing.T, what string, want error, name string, body []byte, lim attach.Limits) {
|
||||
t.Helper()
|
||||
before := len(bucketKeys(t, bucket))
|
||||
f, err := store(name, body, lim, false)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("%s: file=%v err=%v, want %v", what, f, err, want)
|
||||
}
|
||||
if after := len(bucketKeys(t, bucket)); after != before {
|
||||
t.Fatalf("%s left a blob (%d -> %d)", what, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("size", func(t *testing.T) {
|
||||
for _, n := range []int64{1, 64, 1 << 20, 1<<20 + 1, 3 << 20} {
|
||||
body := bytes.Repeat([]byte("a"), int(n))
|
||||
f, err := store("notes.txt", body, attach.Limits{MaxBytes: n}, false)
|
||||
if err != nil || f.FileSize != n {
|
||||
t.Fatalf("exactly %d bytes: %v %v", n, f, err)
|
||||
}
|
||||
refuse(t, "one byte over", attach.ErrTooLarge, "notes.txt", append(body, 'b'), attach.Limits{MaxBytes: n})
|
||||
}
|
||||
big := bytes.Repeat([]byte("z"), 3<<20)
|
||||
if f, err := store("big.txt", big, attach.Limits{}, false); err != nil || f.FileSize != int64(len(big)) {
|
||||
t.Fatalf("MaxBytes 0 means no limit: %v %v", f, err)
|
||||
}
|
||||
if _, err := store("neg.txt", []byte("x"), attach.Limits{MaxBytes: -1}, false); err == nil {
|
||||
t.Fatal("a negative limit was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extensions", func(t *testing.T) {
|
||||
f, err := store(`..\..\Uploads/PHOTO.PNG`, png, attach.Limits{Image: true}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.FileName != "PHOTO.PNG" || !regexp.MustCompile(`^[0-9a-f]{22}\.png$`).MatchString(f.DiskName) {
|
||||
t.Fatalf("name %q disk %q", f.FileName, f.DiskName)
|
||||
}
|
||||
key := attach.BlobKey(f.DiskName)
|
||||
if strings.Contains(key, "Uploads") || strings.Contains(key, "..") || strings.Contains(key, "PHOTO") {
|
||||
t.Fatalf("client path reached the key %q", key)
|
||||
}
|
||||
if ok, err := bucket.Exists(ctx, key); err != nil || !ok {
|
||||
t.Fatalf("blob at %s: %v %v", key, ok, err)
|
||||
}
|
||||
if f, err := store("a.png", png, attach.Limits{Extensions: []string{" .PNG "}}, false); err != nil || !strings.HasSuffix(f.DiskName, ".png") {
|
||||
t.Fatalf("normalised extension list: %v %v", f, err)
|
||||
}
|
||||
for _, name := range []string{"noext", "dot.", "a.toolongextension", "a.p-g", "a.p g", "a.ünï"} {
|
||||
refuse(t, "name "+name, attach.ErrFileType, name, png, attach.Limits{Image: true})
|
||||
}
|
||||
refuse(t, "extension outside the list", attach.ErrFileType, "a.gif", png, attach.Limits{Extensions: []string{"png"}})
|
||||
})
|
||||
|
||||
t.Run("default lists", func(t *testing.T) {
|
||||
for _, ext := range []string{"svg", "js", "html", "css", "xml", "swf", "map", "less", "scss", "php"} {
|
||||
refuse(t, "file mode ."+ext, attach.ErrFileType, "x."+ext, []byte("x"), attach.Limits{})
|
||||
}
|
||||
for _, ext := range []string{"pdf", "txt", "zip", "docx", "png"} {
|
||||
if _, err := store("x."+ext, []byte("plain bytes"), attach.Limits{}, false); err != nil {
|
||||
t.Fatalf("file mode .%s: %v", ext, err)
|
||||
}
|
||||
}
|
||||
for _, ext := range []string{"pdf", "svg", "bmp", "avif"} {
|
||||
refuse(t, "image mode ."+ext, attach.ErrFileType, "x."+ext, png, attach.Limits{Image: true})
|
||||
}
|
||||
refuse(t, "image mode non-image bytes", attach.ErrNotImage, "x.png", []byte("<html></html>"), attach.Limits{Image: true})
|
||||
refuse(t, "image mode truncated", attach.ErrNotImage, "x.png", png[:16], attach.Limits{Image: true})
|
||||
if f, err := store("x.webp", guardWebP, attach.Limits{Image: true}, false); err != nil || f.ContentType != "image/webp" {
|
||||
t.Fatalf("webp: %v %v", f, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content type and MIME patterns", func(t *testing.T) {
|
||||
// The sniff wins over the extension.
|
||||
if f, err := store("looks.txt", png, attach.Limits{Extensions: []string{"txt"}}, false); err != nil || f.ContentType != "image/png" {
|
||||
t.Fatalf("sniffed type: %v %v", f, err)
|
||||
}
|
||||
// Bytes the sniff cannot place take the extension's type.
|
||||
opaque := []byte{0x00, 0x01, 0x02, 0x03, 0xfe, 0xff}
|
||||
if f, err := store("doc.pdf", opaque, attach.Limits{}, false); err != nil || f.ContentType != "application/pdf" {
|
||||
t.Fatalf("extension fallback: %v %v", f, err)
|
||||
}
|
||||
if _, err := store("a.png", png, attach.Limits{MIMETypes: []string{"image/*"}}, false); err != nil {
|
||||
t.Fatalf("image/*: %v", err)
|
||||
}
|
||||
if _, err := store("a.png", png, attach.Limits{MIMETypes: []string{"IMAGE/PNG"}}, false); err != nil {
|
||||
t.Fatalf("case-insensitive pattern: %v", err)
|
||||
}
|
||||
if _, err := store("doc.pdf", opaque, attach.Limits{MIMETypes: []string{"pdf"}}, false); err != nil {
|
||||
t.Fatalf("bare extension pattern: %v", err)
|
||||
}
|
||||
refuse(t, "text under image/*", attach.ErrMIMEType, "a.txt", []byte("hello"), attach.Limits{MIMETypes: []string{"image/*"}})
|
||||
refuse(t, "png under text/plain", attach.ErrMIMEType, "a.png", png, attach.Limits{MIMETypes: []string{"text/plain", "", "jpg"}})
|
||||
refuse(t, "png under */jpeg", attach.ErrMIMEType, "a.png", png, attach.Limits{MIMETypes: []string{"*/jpeg"}})
|
||||
})
|
||||
|
||||
t.Run("row", func(t *testing.T) {
|
||||
for _, public := range []bool{true, false} {
|
||||
f, err := store("row.png", png, attach.Limits{Image: true}, public)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored attach.File
|
||||
if err := gdb.First(&stored, f.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.SortOrder != int(stored.ID) || stored.Public() != public || stored.AttachmentID != "" || stored.AttachmentType != "" || stored.Field != "" {
|
||||
t.Fatalf("row %+v public=%v", stored, public)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("arguments", func(t *testing.T) {
|
||||
if _, err := attach.Store(ctx, nil, bucket, attach.Upload{FileName: "a.png", Body: bytes.NewReader(png)}, attach.Limits{}); err == nil {
|
||||
t.Fatal("nil db accepted")
|
||||
}
|
||||
if _, err := attach.Store(ctx, gdb, nil, attach.Upload{FileName: "a.png", Body: bytes.NewReader(png)}, attach.Limits{}); err == nil {
|
||||
t.Fatal("nil bucket accepted")
|
||||
}
|
||||
if _, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "a.png"}, attach.Limits{}); err == nil {
|
||||
t.Fatal("nil body accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user