- rename Go module, binary, systemd unit, PG_DATABASE default, frontend package and docs from shared-album to galo - add theme-aware halo emblem and favicon (new ui-branding capability) - add make build-release (versioned, stripped, static binary) and run the binary from the repo root
170 lines
4.9 KiB
Go
170 lines
4.9 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"galo/internal/config"
|
|
)
|
|
|
|
func newTestS3(t *testing.T) (*S3, *Signer) {
|
|
t.Helper()
|
|
endpoint := os.Getenv("S3_ENDPOINT")
|
|
if endpoint == "" {
|
|
t.Skip("S3_ENDPOINT not set")
|
|
}
|
|
signer, err := NewSigner("http://app.test", "secret", time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("signer: %v", err)
|
|
}
|
|
cl, err := NewS3(context.Background(), config.S3{
|
|
Endpoint: endpoint,
|
|
Region: os.Getenv("S3_REGION"),
|
|
AccessKey: os.Getenv("S3_ACCESS_KEY"),
|
|
SecretKey: os.Getenv("S3_SECRET_KEY"),
|
|
Bucket: os.Getenv("S3_BUCKET"),
|
|
UsePathStyle: true,
|
|
}, signer)
|
|
if err != nil {
|
|
t.Skipf("storage init: %v", err)
|
|
}
|
|
return cl, signer
|
|
}
|
|
|
|
func signedQuery(signer *Signer, key string, exp int64) string {
|
|
return "?e=" + strconv.FormatInt(exp, 10) + "&s=" + signer.sign(key, exp)
|
|
}
|
|
|
|
func TestS3ServeObject(t *testing.T) {
|
|
ctx := context.Background()
|
|
cl, signer := newTestS3(t)
|
|
key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
|
|
data := []byte("0123456789")
|
|
if err := cl.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil {
|
|
t.Fatalf("put: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = cl.Delete(context.Background(), key) })
|
|
|
|
exp := time.Now().Add(time.Hour).Unix()
|
|
base := "/api/objects/" + key + signedQuery(signer, key, exp)
|
|
|
|
t.Run("full", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, base, nil)
|
|
if err := cl.ServeObject(rec, req, key); err != nil {
|
|
t.Fatalf("serve: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if !bytes.Equal(rec.Body.Bytes(), data) {
|
|
t.Fatalf("body = %q", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("range", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, base, nil)
|
|
req.Header.Set("Range", "bytes=2-5")
|
|
if err := cl.ServeObject(rec, req, key); err != nil {
|
|
t.Fatalf("serve range: %v", err)
|
|
}
|
|
if rec.Code != http.StatusPartialContent {
|
|
t.Fatalf("status = %d, want 206", rec.Code)
|
|
}
|
|
if body, _ := io.ReadAll(rec.Body); string(body) != "2345" {
|
|
t.Fatalf("range body = %q, want 2345", body)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid range", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, base, nil)
|
|
req.Header.Set("Range", "bytes=1000-2000")
|
|
err := cl.ServeObject(rec, req, key)
|
|
if !errors.Is(err, ErrRangeNotSatisfiable) {
|
|
t.Fatalf("err = %v, want ErrRangeNotSatisfiable", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing", func(t *testing.T) {
|
|
missingKey := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
|
|
target := "/api/objects/" + missingKey + signedQuery(signer, missingKey, exp)
|
|
rec := httptest.NewRecorder()
|
|
err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), missingKey)
|
|
if !errors.Is(err, ErrObjectNotFound) {
|
|
t.Fatalf("err = %v, want ErrObjectNotFound", err)
|
|
}
|
|
})
|
|
|
|
t.Run("expired signature", func(t *testing.T) {
|
|
past := time.Now().Add(-time.Minute).Unix()
|
|
target := "/api/objects/" + key + signedQuery(signer, key, past)
|
|
rec := httptest.NewRecorder()
|
|
err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), key)
|
|
if !errors.Is(err, ErrSignedURLInvalid) {
|
|
t.Fatalf("err = %v, want ErrSignedURLInvalid", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestS3List(t *testing.T) {
|
|
ctx := context.Background()
|
|
cl, _ := newTestS3(t)
|
|
prefix := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/"
|
|
keys := []string{prefix + "original.jpg", prefix + "thumb.webp"}
|
|
for _, k := range keys {
|
|
if err := cl.Put(ctx, k, strings.NewReader("x"), 1, "image/jpeg"); err != nil {
|
|
t.Fatalf("put %s: %v", k, err)
|
|
}
|
|
}
|
|
t.Cleanup(func() { _ = cl.Delete(context.Background(), keys...) })
|
|
|
|
got, err := cl.List(ctx, prefix)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
found := map[string]bool{}
|
|
for _, o := range got {
|
|
found[o.Key] = true
|
|
}
|
|
for _, k := range keys {
|
|
if !found[k] {
|
|
t.Fatalf("key %s not listed: %+v", k, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestS3ServeObjectHead(t *testing.T) {
|
|
ctx := context.Background()
|
|
cl, signer := newTestS3(t)
|
|
key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
|
|
data := []byte("head-check")
|
|
if err := cl.Put(ctx, key, strings.NewReader(string(data)), int64(len(data)), "image/jpeg"); err != nil {
|
|
t.Fatalf("put: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = cl.Delete(context.Background(), key) })
|
|
|
|
exp := time.Now().Add(time.Hour).Unix()
|
|
target := "/api/objects/" + key + signedQuery(signer, key, exp)
|
|
rec := httptest.NewRecorder()
|
|
if err := cl.ServeObject(rec, httptest.NewRequest(http.MethodHead, target, nil), key); err != nil {
|
|
t.Fatalf("head: %v", err)
|
|
}
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
if rec.Body.Len() != 0 {
|
|
t.Fatalf("HEAD body should be empty, got %d bytes", rec.Body.Len())
|
|
}
|
|
}
|