192 lines
6.6 KiB
Go
192 lines
6.6 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type mediaUploadDB struct {
|
|
directory string
|
|
inserted, finalized, deleted, failFinalize bool
|
|
publicURL, mime string
|
|
size int64
|
|
}
|
|
type mediaUploadConnector struct{ db *mediaUploadDB }
|
|
|
|
func (c mediaUploadConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil }
|
|
func (c mediaUploadConnector) Driver() driver.Driver { return oauthTestDriver{} }
|
|
func (*mediaUploadDB) Prepare(string) (driver.Stmt, error) {
|
|
return nil, errors.New("unexpected prepare")
|
|
}
|
|
func (*mediaUploadDB) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") }
|
|
func (*mediaUploadDB) Close() error { return nil }
|
|
func (s *mediaUploadDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
|
if !strings.HasPrefix(query, "SELECT config_value,value_type FROM system_configs") {
|
|
return nil, errors.New("unexpected query")
|
|
}
|
|
values := map[string]string{"storage.provider": "local", "storage.object_prefix": "media", "storage.local.directory": s.directory, "storage.local.public_base_url": "https://media.example.com/uploads"}
|
|
value, ok := values[args[0].Value.(string)]
|
|
if !ok {
|
|
return &oauthTestRows{columns: []string{"config_value", "value_type"}}, nil
|
|
}
|
|
return oauthRow(value, "text"), nil
|
|
}
|
|
func (s *mediaUploadDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
|
switch {
|
|
case strings.HasPrefix(query, "INSERT INTO media_assets"):
|
|
s.inserted = true
|
|
s.mime = args[5].Value.(string)
|
|
s.size = args[6].Value.(int64)
|
|
return provisioningInsertResult{}, nil
|
|
case strings.HasPrefix(query, "UPDATE media_assets SET public_url="):
|
|
if s.failFinalize {
|
|
return nil, errors.New("finalization unavailable")
|
|
}
|
|
s.publicURL = args[0].Value.(string)
|
|
s.finalized = true
|
|
case strings.HasPrefix(query, "DELETE FROM media_assets"):
|
|
s.deleted = true
|
|
default:
|
|
return nil, errors.New("unexpected exec")
|
|
}
|
|
return driver.RowsAffected(1), nil
|
|
}
|
|
|
|
func uploadTestApp(t *testing.T) (*App, *mediaUploadDB) {
|
|
t.Helper()
|
|
store := &mediaUploadDB{directory: t.TempDir()}
|
|
db := sql.OpenDB(mediaUploadConnector{store})
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return &App{db: db, config: Config{MediaDir: store.directory, Environment: "development"}}, store
|
|
}
|
|
func uploadTestRequest(t *testing.T, a *App, source []byte, purpose string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", "camera.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = part.Write(source); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if purpose != "" {
|
|
if err = writer.WriteField("purpose", purpose); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err = writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "/api/v1/media/upload", &body)
|
|
r.Header.Set("Content-Type", writer.FormDataContentType())
|
|
r = r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{ID: 42, Role: "user"}))
|
|
w := httptest.NewRecorder()
|
|
a.uploadMedia(w, r)
|
|
return w
|
|
}
|
|
|
|
func TestAvatarUploadStoresAndServesAllVariants(t *testing.T) {
|
|
a, store := uploadTestApp(t)
|
|
source := avatarPNG(t, 1000, 800)
|
|
w := uploadTestRequest(t, a, source, "avatar")
|
|
if w.Code != 200 {
|
|
t.Fatalf("upload failed: %s", w.Body.String())
|
|
}
|
|
var payload struct {
|
|
Data struct {
|
|
URL string `json:"url"`
|
|
ThumbnailURL string `json:"thumbnailUrl"`
|
|
OriginalURL string `json:"originalUrl"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := payload.Data
|
|
if !store.inserted || !store.finalized || store.publicURL != data.URL || store.mime != "image/jpeg" {
|
|
t.Fatal("media record does not identify the display image")
|
|
}
|
|
for _, variant := range []struct {
|
|
url string
|
|
max int
|
|
}{{data.URL, 640}, {data.ThumbnailURL, 256}, {data.OriginalURL, 0}} {
|
|
u, err := url.Parse(variant.url)
|
|
if err != nil || !mediaNamePattern.MatchString(filepath.Base(u.Path)) {
|
|
t.Fatal("invalid variant URL", variant.url)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(store.directory, filepath.Base(u.Path)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if variant.max == 0 {
|
|
if !bytes.Equal(content, source) {
|
|
t.Fatal("original upload was not preserved")
|
|
}
|
|
} else {
|
|
cfg, format, err := image.DecodeConfig(bytes.NewReader(content))
|
|
if err != nil || format != "jpeg" || cfg.Width != variant.max || cfg.Height > variant.max {
|
|
t.Fatal("invalid variant dimensions", err)
|
|
}
|
|
if variant.url == data.URL && store.size != int64(len(content)) {
|
|
t.Fatal("DB file_size does not match uploaded JPEG")
|
|
}
|
|
}
|
|
response := httptest.NewRecorder()
|
|
a.serveMedia(response, httptest.NewRequest(http.MethodGet, u.Path, nil))
|
|
if response.Code != 200 || !bytes.Equal(response.Body.Bytes(), content) || !strings.Contains(response.Header().Get("Cache-Control"), "immutable") {
|
|
t.Fatal("variant is not publicly cacheable")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAvatarUploadFinalizationFailureCleansEveryObject(t *testing.T) {
|
|
a, store := uploadTestApp(t)
|
|
store.failFinalize = true
|
|
w := uploadTestRequest(t, a, avatarPNG(t, 300, 200), "avatar")
|
|
files, err := os.ReadDir(store.directory)
|
|
if w.Code != 500 || !store.deleted || store.finalized || err != nil || len(files) != 0 {
|
|
t.Fatal("failed upload retained objects or an active record", w.Code, err, len(files))
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryMediaUploadPreservesOriginal(t *testing.T) {
|
|
a, store := uploadTestApp(t)
|
|
source := avatarPNG(t, 800, 400)
|
|
w := uploadTestRequest(t, a, source, "")
|
|
if w.Code != 200 || store.mime != "image/png" || store.size != int64(len(source)) {
|
|
t.Fatal("ordinary image upload changed", w.Body.String())
|
|
}
|
|
files, err := os.ReadDir(store.directory)
|
|
if err != nil || len(files) != 1 {
|
|
t.Fatal("unexpected variants on ordinary media")
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(store.directory, files[0].Name()))
|
|
if err != nil || !bytes.Equal(data, source) {
|
|
t.Fatal("ordinary image was recompressed")
|
|
}
|
|
}
|
|
|
|
func TestInvalidAvatarUploadCreatesNoRecords(t *testing.T) {
|
|
for _, purpose := range []string{"avatar", "unknown"} {
|
|
a, store := uploadTestApp(t)
|
|
w := uploadTestRequest(t, a, []byte("GIF89a\x01\x00\x01\x00broken"), purpose)
|
|
if w.Code != 400 || store.inserted {
|
|
t.Fatal("invalid upload created media records", w.Code)
|
|
}
|
|
}
|
|
}
|