Files
summercms/modules/lagoon/attach/store_test.go
Jakub Zych ba8432cfb8 fix(lagoon/attach): store non-inline uploads with Content-Disposition attachment (quick-261028-rqe)
- attach.IsInlineContentType: the shared inline raster list (JPEG, PNG,
  GIF, WebP, AVIF), parameters and case ignored, false for empty or
  unparsable values
- writeBlob stores Content-Disposition: attachment for every other type,
  so a public bucket or CDN makes the browser download HTML, SVG, PDF or
  plain text; content_type and thumbnails are unchanged
- driver tests on memblob and fileblob, Postgres-backed Store assertion,
  lagoon README and the storage and attachments pages
2026-10-28 21:10:48 +01:00

537 lines
21 KiB
Go

package attach_test
import (
"archive/zip"
"bytes"
"context"
"encoding/binary"
"errors"
"hash/crc32"
"image"
"image/jpeg"
"image/png"
"io"
"net/http"
"regexp"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
func smokePNG(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 3))); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// scriptText is the payload of the stored-XSS todo: a page that reads the
// application's cookies if a browser renders the upload as HTML.
const scriptText = "<script>alert(document.cookie)</script>"
// pngWithScriptText returns a real, decodable PNG that carries scriptText in
// a tEXt chunk right after IHDR. It passes attach.IsAllowedImage, which is
// why the disk name, not the image guard, has to keep it from being served
// as HTML.
func pngWithScriptText(t *testing.T) []byte {
t.Helper()
base := smokePNG(t)
// 8-byte signature, then IHDR: 4 length, 4 type, 13 data, 4 CRC.
const split = 8 + 4 + 4 + 13 + 4
if len(base) < split || string(base[12:16]) != "IHDR" {
t.Fatalf("unexpected PNG layout: % x", base[:min(len(base), 16)])
}
data := append([]byte("Comment\x00"), scriptText...)
chunk := binary.BigEndian.AppendUint32(nil, uint32(len(data)))
typed := append([]byte("tEXt"), data...)
chunk = append(chunk, typed...)
chunk = binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(typed))
out := append([]byte{}, base[:split]...)
out = append(out, chunk...)
return append(out, base[split:]...)
}
func bucketKeys(t *testing.T, bucket *blob.Bucket) []string {
t.Helper()
var keys []string
iter := bucket.List(nil)
for {
obj, err := iter.Next(context.Background())
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
keys = append(keys, obj.Key)
}
return keys
}
// TestStoreSmoke stores a guarded PNG and a body of exactly MaxBytes.
func TestStoreSmoke(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
data := smokePNG(t)
f, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: `C:\photos\Cover.PNG`, Body: bytes.NewReader(data), Public: false}, attach.Limits{Image: true})
if err != nil {
t.Fatal(err)
}
if f.ID == 0 || f.SortOrder != int(f.ID) {
t.Fatalf("sort_order %d, id %d", f.SortOrder, f.ID)
}
if !strings.HasSuffix(f.DiskName, ".png") || len(f.DiskName) != 26 {
t.Fatalf("disk name %q", f.DiskName)
}
if f.FileName != "Cover.PNG" || f.ContentType != "image/png" || f.FileSize != int64(len(data)) || f.Public() {
t.Fatalf("row %+v", f)
}
var stored attach.File
if err := gdb.First(&stored, f.ID).Error; err != nil {
t.Fatal(err)
}
if stored.SortOrder != int(f.ID) || stored.Public() || stored.AttachmentID != "" {
t.Fatalf("stored row %+v", stored)
}
got, err := bucket.ReadAll(ctx, attach.BlobKey(f.DiskName))
if err != nil || !bytes.Equal(got, data) {
t.Fatalf("blob %v", err)
}
exact := bytes.Repeat([]byte("a"), 64)
g, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "notes.txt", Body: bytes.NewReader(exact)}, attach.Limits{MaxBytes: 64})
if err != nil {
t.Fatalf("exactly MaxBytes: %v", err)
}
if g.FileSize != 64 || g.Public() || g.ContentType != "text/plain" {
t.Fatalf("row %+v", g)
}
}
// TestStoreSmokeRefusals covers the refusals that happen before any row is
// written: an SVG in image mode and bodies of MaxBytes+1 bytes.
func TestStoreSmokeRefusals(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
db := &gorm.DB{}
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
_, err := attach.Store(ctx, db, bucket, attach.Upload{FileName: "x.png", Body: bytes.NewReader(svg)}, attach.Limits{Image: true})
if !errors.Is(err, attach.ErrNotImage) {
t.Fatalf("svg bytes: %v", err)
}
_, err = attach.Store(ctx, db, bucket, attach.Upload{FileName: "x.svg", Body: bytes.NewReader(svg)}, attach.Limits{Image: true})
if !errors.Is(err, attach.ErrFileType) {
t.Fatalf("svg extension: %v", err)
}
_, err = attach.Store(ctx, db, bucket, attach.Upload{FileName: "notes.txt", Body: bytes.NewReader(bytes.Repeat([]byte("a"), 65))}, attach.Limits{MaxBytes: 64})
if !errors.Is(err, attach.ErrTooLarge) {
t.Fatalf("small body: %v", err)
}
// Past the 1 MiB read-ahead the limit is enforced while streaming.
const limit = 2 << 20
_, err = attach.Store(ctx, db, bucket, attach.Upload{FileName: "notes.txt", Body: bytes.NewReader(bytes.Repeat([]byte("a"), limit+1))}, attach.Limits{MaxBytes: limit})
if !errors.Is(err, attach.ErrTooLarge) {
t.Fatalf("streamed body: %v", err)
}
if keys := bucketKeys(t, bucket); len(keys) != 0 {
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")
}
})
}
// TestStoreDiskExtensionSmoke is the stored-XSS regression (D-15): the disk
// name's extension follows the sniffed content, file_name keeps the client's
// name, and documents whose sniff is generic keep their extension.
func TestStoreDiskExtensionSmoke(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
lim := attach.Limits{Extensions: []string{"html", "png", "docx", "txt"}}
// An 8-byte PNG signature plus an IHDR chunk sniffs as image/png.
pngBody := append([]byte("\x89PNG\r\n\x1a\n"), []byte("\x00\x00\x00\x0dIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde")...)
f, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "x.html", Body: bytes.NewReader(pngBody), Public: true}, lim)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(f.DiskName, ".png") || !strings.HasSuffix(f.URL(), ".png") {
t.Fatalf("PNG named x.html: disk name %q, URL %q, want .png", f.DiskName, f.URL())
}
if f.FileName != "x.html" || f.ContentType != "image/png" {
t.Fatalf("PNG named x.html: file_name %q, content_type %q", f.FileName, f.ContentType)
}
var zipBody bytes.Buffer
zw := zip.NewWriter(&zipBody)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("<w:document/>")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
doc, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "report.docx", Body: bytes.NewReader(zipBody.Bytes())}, lim)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(doc.DiskName, ".docx") || doc.FileName != "report.docx" {
t.Fatalf("docx: disk name %q, file_name %q", doc.DiskName, doc.FileName)
}
note, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "note.txt", Body: strings.NewReader("<html><body><script>alert(1)</script></body></html>")}, lim)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(note.DiskName, ".txt") || note.FileName != "note.txt" {
t.Fatalf("HTML body named note.txt: disk name %q, file_name %q", note.DiskName, note.FileName)
}
}
// TestPNGScriptChunkNaming pins the naming half of the stored-XSS todo
// without Docker: a write-scoped client uploads a valid image under an .html
// name. The PNG with a script tEXt chunk decodes, passes attach.IsAllowedImage
// and sniffs as image/png, so attach.DiskExtension names it .png, not .html.
func TestPNGScriptChunkNaming(t *testing.T) {
fixture := pngWithScriptText(t)
if _, err := png.Decode(bytes.NewReader(fixture)); err != nil {
t.Fatalf("fixture does not decode: %v", err)
}
if !attach.IsAllowedImage(fixture) {
t.Fatal("fixture is refused by IsAllowedImage")
}
if !bytes.Contains(fixture, []byte(scriptText)) {
t.Fatal("fixture lost the script payload")
}
ct := http.DetectContentType(fixture)
if ct != "image/png" {
t.Fatalf("sniffed %q, want image/png", ct)
}
if got := attach.DiskExtension(ct, "html"); got != "png" {
t.Fatalf("DiskExtension(%q, html) = %q, want png", ct, got)
}
}
// TestStoreScriptChunkPNG runs the stored-XSS todo scenario through Store on
// Postgres: a write-scoped client uploads a valid image under an .html name
// in image mode, with html allowed so the extension check is not what stops
// it. The file is stored and served as .png with content_type image/png,
// file_name keeps x.html, and the bytes, script chunk included, are stored
// as uploaded: only the name makes them harmless.
func TestStoreScriptChunkPNG(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
fixture := pngWithScriptText(t)
f, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: "x.html", Body: bytes.NewReader(fixture), Public: true},
attach.Limits{Image: true, Extensions: []string{"png", "html"}})
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(f.DiskName, ".png") || !strings.HasSuffix(f.URL(), ".png") {
t.Fatalf("disk name %q, URL %q, want .png", f.DiskName, f.URL())
}
if f.FileName != "x.html" || f.ContentType != "image/png" {
t.Fatalf("file_name %q, content_type %q, want x.html image/png", f.FileName, f.ContentType)
}
got, err := bucket.ReadAll(ctx, attach.BlobKey(f.DiskName))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, fixture) {
t.Fatalf("stored %d bytes differ from the %d uploaded", len(got), len(fixture))
}
}
// TestStoreDiskExtension (plan 05 of phase 19.3, D-15) runs the stored-XSS
// regression table through Store on Postgres: the disk name and the URL end
// in the extension DiskExtension picks for the sniffed bytes, file_name keeps
// the client's name and content_type the sniffed type.
func TestStoreDiskExtension(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
lim := attach.Limits{Extensions: []string{"html", "png", "jpg", "jpeg", "docx", "txt", "ics", "svg", "js", "pdf", "mkv"}}
var jpegBody bytes.Buffer
if err := jpeg.Encode(&jpegBody, image.NewRGBA(image.Rect(0, 0, 4, 4)), nil); err != nil {
t.Fatal(err)
}
var docxBody bytes.Buffer
zw := zip.NewWriter(&docxBody)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("<w:document/>")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
webm := []byte("\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\xf7\x81\x01\x42\xf2\x81\x04\x42\xf3\x81\x08\x42\x82\x84webm\x42\x87\x81\x04\x42\x85\x81\x02")
for _, tc := range []struct {
name, fileName string
body []byte
ext, ct string
}{
{"png named html", "x.html", smokePNG(t), "png", "image/png"},
{"jpeg named png", "photo.png", jpegBody.Bytes(), "jpg", "image/jpeg"},
{"jpeg named jpeg", "photo.jpeg", jpegBody.Bytes(), "jpeg", "image/jpeg"},
{"docx under zip", "report.docx", docxBody.Bytes(), "docx", "application/zip"},
{"ics under text", "event.ics", []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"), "ics", "text/plain"},
{"html named txt", "note.txt", []byte("<html><body><script>alert(1)</script></body></html>"), "txt", "text/html"},
{"html named html", "page.html", []byte("<!DOCTYPE html><html><script>alert(1)</script></html>"), "txt", "text/html"},
{"svg under xml", "logo.svg", []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`), "txt", "text/xml"},
{"js under text", "app.js", []byte("console.log(document.cookie)\n"), "txt", "text/plain"},
// A generic sniff keeps the content_type rule (from the extension).
{"js under octet-stream", "app.js", []byte{0x00, 0x01, 0x02, 'a', 'l', 'e', 'r', 't'}, "bin", "text/javascript"},
{"pdf named txt", "doc.txt", []byte("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<>>\nendobj\n"), "pdf", "application/pdf"},
{"mkv under webm", "clip.mkv", webm, "mkv", "video/webm"},
} {
t.Run(tc.name, func(t *testing.T) {
f, err := attach.Store(ctx, gdb, bucket, attach.Upload{FileName: tc.fileName, Body: bytes.NewReader(tc.body), Public: true}, lim)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(f.DiskName, "."+tc.ext) || !strings.HasSuffix(f.URL(), "/"+f.DiskName) || !strings.HasSuffix(f.URL(), "."+tc.ext) {
t.Fatalf("disk name %q URL %q, want .%s", f.DiskName, f.URL(), tc.ext)
}
if f.FileName != tc.fileName || !strings.HasPrefix(f.ContentType, tc.ct) {
t.Fatalf("file_name %q content_type %q, want %q %q", f.FileName, f.ContentType, tc.fileName, tc.ct)
}
var stored attach.File
if err := gdb.First(&stored, f.ID).Error; err != nil {
t.Fatal(err)
}
if stored.DiskName != f.DiskName || stored.FileName != tc.fileName || stored.ContentType != f.ContentType {
t.Fatalf("stored row %+v", stored)
}
got, err := bucket.ReadAll(ctx, attach.BlobKey(f.DiskName))
if err != nil || !bytes.Equal(got, tc.body) {
t.Fatalf("blob under %s: %v", attach.BlobKey(f.DiskName), err)
}
attrs, err := bucket.Attributes(ctx, attach.BlobKey(f.DiskName))
if err != nil {
t.Fatalf("blob attributes: %v", err)
}
if attrs.ContentType != f.ContentType {
t.Fatalf("blob Content-Type %q, want the stored %q", attrs.ContentType, f.ContentType)
}
// An HTML body uploaded as note.txt keeps text/html as its type
// but is stored with an attachment disposition, so a public
// bucket makes the browser download it. Only raster images stay
// inline.
wantDisposition := "attachment"
if tc.ct == "image/png" || tc.ct == "image/jpeg" {
wantDisposition = ""
}
if attrs.ContentDisposition != wantDisposition {
t.Fatalf("blob Content-Disposition %q, want %q", attrs.ContentDisposition, wantDisposition)
}
})
}
}