Build signed object URLs as path-only (/api/objects/...) instead of prefixing APP_BASE_URL, so media loads from whatever host or reverse proxy served the app. Remove the now-unused base URL from the signer and drop the APP_BASE_URL / Server.BaseURL config.
168 lines
4.6 KiB
Go
168 lines
4.6 KiB
Go
package photos
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"galo/internal/auth"
|
|
"galo/internal/config"
|
|
"galo/internal/models"
|
|
"galo/internal/roles"
|
|
"galo/internal/sse"
|
|
"galo/internal/storage"
|
|
)
|
|
|
|
func testPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set")
|
|
}
|
|
pool, err := pgxpool.New(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Skipf("cannot connect: %v", err)
|
|
}
|
|
if err := pool.Ping(context.Background()); err != nil {
|
|
t.Skipf("cannot ping: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
return pool
|
|
}
|
|
|
|
func testEnvOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
type stubAlbums struct{}
|
|
|
|
func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
|
return &models.Album{ID: albumID}, roles.Owner, nil
|
|
}
|
|
|
|
func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string {
|
|
t.Helper()
|
|
id := auth.NewID()
|
|
if _, err := pool.Exec(ctx,
|
|
`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 't')`,
|
|
id, id+"@test.local"); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
|
|
return id
|
|
}
|
|
|
|
func createAlbum(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID string) string {
|
|
t.Helper()
|
|
id := auth.NewID()
|
|
if _, err := pool.Exec(ctx,
|
|
`INSERT INTO albums (id, owner_id, title) VALUES ($1, $2, 't')`, id, ownerID); err != nil {
|
|
t.Fatalf("create album: %v", err)
|
|
}
|
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, id) })
|
|
return id
|
|
}
|
|
|
|
func createPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, uploaderID string) string {
|
|
t.Helper()
|
|
id := auth.NewID()
|
|
if _, err := pool.Exec(ctx,
|
|
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, NULL, $2)`, id, uploaderID); err != nil {
|
|
t.Fatalf("create photo: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func makePNG(t *testing.T) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
|
for x := 0; x < 4; x++ {
|
|
for y := 0; y < 4; y++ {
|
|
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
t.Fatalf("encode png: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// newTestService builds a service backed by the real dev storage. It skips when
|
|
// S3 env is absent so unit runs stay hermetic.
|
|
func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
|
|
t.Helper()
|
|
endpoint := os.Getenv("S3_ENDPOINT")
|
|
if endpoint == "" {
|
|
t.Skip("S3_ENDPOINT not set")
|
|
}
|
|
signer, err := storage.NewSigner("test-secret", time.Hour, 10*time.Minute)
|
|
if err != nil {
|
|
t.Skipf("signer init: %v", err)
|
|
}
|
|
cl, err := storage.NewS3(context.Background(), config.S3{
|
|
Endpoint: endpoint,
|
|
Region: testEnvOr("S3_REGION", "garage"),
|
|
AccessKey: os.Getenv("S3_ACCESS_KEY"),
|
|
SecretKey: os.Getenv("S3_SECRET_KEY"),
|
|
Bucket: testEnvOr("S3_BUCKET", "galo-data"),
|
|
UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false",
|
|
}, signer)
|
|
if err != nil {
|
|
t.Skipf("storage init: %v", err)
|
|
}
|
|
return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl
|
|
}
|
|
|
|
// newTestServiceFS builds a service backed by a temporary local file-system
|
|
// storage backend so the full flow can run without external infrastructure.
|
|
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
|
|
t.Helper()
|
|
signer, err := storage.NewSigner("test-secret", time.Hour, 10*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("storage init: %v", err)
|
|
}
|
|
fs, err := storage.NewFS(t.TempDir(), signer)
|
|
if err != nil {
|
|
t.Fatalf("storage init: %v", err)
|
|
}
|
|
return NewService(NewStore(pool), stubAlbums{}, fs, sse.NewHub(1)), fs
|
|
}
|
|
|
|
// cleanupPhoto removes a photo, its objects (rows and blobs) and bindings.
|
|
func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl storage.Backend, photoID string) {
|
|
t.Helper()
|
|
rows, err := pool.Query(ctx,
|
|
`SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id
|
|
WHERE po.photo_id = $1`, photoID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var ids, keys []string
|
|
for rows.Next() {
|
|
var id, key string
|
|
if rows.Scan(&id, &key) == nil {
|
|
ids = append(ids, id)
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
rows.Close()
|
|
_, _ = pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photoID)
|
|
for _, id := range ids {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, id)
|
|
}
|
|
if cl != nil && len(keys) > 0 {
|
|
_ = cl.Delete(ctx, keys...)
|
|
}
|
|
}
|