Files
iu eef7c6c5d0 fix(storage): emit origin-relative object URLs
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.
2026-10-12 12:14:46 +03:00

214 lines
6.0 KiB
Go

package app
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"galo/internal/albums"
"galo/internal/auth"
"galo/internal/config"
"galo/internal/members"
"galo/internal/models"
"galo/internal/photos"
"galo/internal/sharelinks"
"galo/internal/sse"
"galo/internal/storage"
"galo/internal/store"
)
func integrationPool(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 makeTestPNG(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()
}
// runShareFlow uploads a photo and validates the read-only share flow returns a
// usable photo URL. verify inspects/serves the URL according to the backend.
func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.T, router *gin.Engine, raw string)) {
t.Helper()
ctx := context.Background()
pool := integrationPool(t)
gin.SetMode(gin.TestMode)
hub := sse.NewHub(1)
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, tm, backend, hub)
router := srv.Router()
authStore := auth.NewStore(pool)
owner := &models.User{ID: auth.NewID(), Email: auth.NewID()[:8] + "@test.local", PasswordHash: "x", Name: "Owner"}
if err := authStore.CreateUser(ctx, owner); err != nil {
t.Fatalf("create user: %v", err)
}
albumsStore := albums.NewStore(pool)
albumsSvc := albums.NewService(albumsStore, members.NewStore(pool), hub, backend)
album, err := albumsSvc.Create(ctx, owner.ID, albums.CreateInput{Title: "Share Album"})
if err != nil {
t.Fatalf("create album: %v", err)
}
photosSvc := photos.NewService(photos.NewStore(pool), albumsSvc, backend, hub)
pngData := makeTestPNG(t)
if _, err := photosSvc.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: owner.ID, Filename: "pic.png", Data: pngData,
}); err != nil {
t.Fatalf("upload: %v", err)
}
var keys []string
rows, err := pool.Query(ctx,
`SELECT o.storage_key FROM photo_objects po
JOIN objects o ON o.id = po.object_id
JOIN photos p ON p.id = po.photo_id
WHERE p.album_id = $1`, album.ID)
if err == nil {
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keys = append(keys, k)
}
}
rows.Close()
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM photos WHERE album_id = $1`, album.ID)
for _, k := range keys {
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE storage_key = $1`, k)
}
_ = backend.Delete(context.Background(), keys...)
_, _ = pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, album.ID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID)
})
shareSvc := sharelinks.NewService(sharelinks.NewStore(pool), albumsSvc)
link, err := shareSvc.Create(ctx, album.ID, owner.ID, time.Hour)
if err != nil {
t.Fatalf("share create: %v", err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/share/"+link.Token+"/photos", nil))
if rec.Code != http.StatusOK {
t.Fatalf("share photos status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Photos []models.Photo `json:"photos"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Photos) != 1 {
t.Fatalf("photos len = %d, want 1", len(body.Photos))
}
if body.Photos[0].OriginalURL == "" {
t.Fatalf("empty original url")
}
verify(t, router, body.Photos[0].OriginalURL)
}
func TestShareFlowWithFS(t *testing.T) {
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("signer: %v", err)
}
fs, err := storage.NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("new fs: %v", err)
}
runShareFlow(t, fs, func(t *testing.T, router *gin.Engine, raw string) {
if !strings.Contains(raw, "/api/objects/") {
t.Fatalf("expected fs signed url, got %q", raw)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.Path+"?"+u.RawQuery, nil))
if rec.Code != http.StatusOK {
t.Fatalf("fs object fetch status = %d", rec.Code)
}
})
}
func TestShareFlowWithS3(t *testing.T) {
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
t.Skip("S3_ENDPOINT not set")
}
signer, err := storage.NewSigner("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: 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)
}
runShareFlow(t, cl, func(t *testing.T, router *gin.Engine, raw string) {
if !strings.Contains(raw, "/api/objects/") {
t.Fatalf("expected backend url, got %q", raw)
}
if strings.Contains(raw, "X-Amz-Signature") {
t.Fatalf("presigned url leaked to client: %q", raw)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.Path+"?"+u.RawQuery, nil))
if rec.Code != http.StatusOK {
t.Fatalf("s3 object fetch status = %d", rec.Code)
}
})
}