Permanently deleting a photo whose album was deleted failed because authorization resolved through the album. Fall back to uploader ownership when the photo is albumless. Add a bulk DELETE /trash that removes every trashed photo the caller may delete (uncapped listing) and returns the deleted count, with an «Очистить корзину» action on the trash screen.
342 lines
10 KiB
Go
342 lines
10 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
assets "galo"
|
|
"galo/internal/albums"
|
|
"galo/internal/auth"
|
|
"galo/internal/db"
|
|
"galo/internal/photos"
|
|
"galo/internal/settings"
|
|
"galo/internal/sse"
|
|
"galo/internal/storage"
|
|
"galo/internal/store"
|
|
)
|
|
|
|
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()
|
|
}
|
|
|
|
type stack struct {
|
|
db *sql.DB
|
|
photos *photos.Service
|
|
albums *albums.Service
|
|
auth *auth.Service
|
|
hub *sse.Hub
|
|
tm *auth.TokenManager
|
|
repos store.Repos
|
|
storage storage.Backend
|
|
}
|
|
|
|
// openStack opens a migrated SQLite database at path and builds the services.
|
|
func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
|
|
t.Helper()
|
|
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
|
|
if err != nil {
|
|
t.Fatalf("migrations fs: %v", err)
|
|
}
|
|
if err := db.MigrateSQLite(path, migFS); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
database, err := db.OpenSQLite(path)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
repos := store.NewSQLite(database)
|
|
hub := sse.NewHub(1)
|
|
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour, 10*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("signer: %v", err)
|
|
}
|
|
blob, err := storage.NewFS(blobRoot, signer)
|
|
if err != nil {
|
|
t.Fatalf("fs: %v", err)
|
|
}
|
|
tm := auth.NewTokenManager("secret", 15*time.Minute, time.Hour)
|
|
authSvc := auth.NewService(repos.Auth, tm)
|
|
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
|
|
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
|
|
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc, hub: hub, tm: tm, repos: repos, storage: blob}, func() { database.Close() }
|
|
}
|
|
|
|
func TestUploadSetsDefaultCover(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "galo.db")
|
|
st, closeDB := openStack(t, path, t.TempDir())
|
|
defer closeDB()
|
|
|
|
user, _, err := st.auth.Register(ctx, "cover@test.local", "password123", "C")
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Cover"})
|
|
if err != nil {
|
|
t.Fatalf("create album: %v", err)
|
|
}
|
|
|
|
first, err := st.photos.Upload(ctx, photos.UploadInput{
|
|
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload first: %v", err)
|
|
}
|
|
got, _, err := st.albums.Get(ctx, album.ID, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("get album: %v", err)
|
|
}
|
|
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
|
|
t.Fatalf("cover = %v, want first photo %s", got.CoverPhotoID, first.ID)
|
|
}
|
|
|
|
second, err := st.photos.Upload(ctx, photos.UploadInput{
|
|
AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload second: %v", err)
|
|
}
|
|
got, _, err = st.albums.Get(ctx, album.ID, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("get album after second: %v", err)
|
|
}
|
|
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
|
|
t.Fatalf("cover changed to %v, want still first %s (second=%s)", got.CoverPhotoID, first.ID, second.ID)
|
|
}
|
|
}
|
|
|
|
func TestCoverClearedWhenCoverPhotoLeaves(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "galo.db")
|
|
st, closeDB := openStack(t, path, t.TempDir())
|
|
defer closeDB()
|
|
|
|
user, _, err := st.auth.Register(ctx, "clear@test.local", "password123", "C")
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
albumA, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "A"})
|
|
if err != nil {
|
|
t.Fatalf("create A: %v", err)
|
|
}
|
|
albumB, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "B"})
|
|
if err != nil {
|
|
t.Fatalf("create B: %v", err)
|
|
}
|
|
|
|
cover, err := st.photos.Upload(ctx, photos.UploadInput{
|
|
AlbumID: albumA.ID, UserID: user.ID, Filename: "cover.png", Data: makePNG(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload cover: %v", err)
|
|
}
|
|
|
|
// Trashing the cover clears it.
|
|
if err := st.photos.Delete(ctx, cover.ID, user.ID); err != nil {
|
|
t.Fatalf("delete cover: %v", err)
|
|
}
|
|
goal, _, _ := st.albums.Get(ctx, albumA.ID, user.ID)
|
|
if goal.CoverPhotoID != nil {
|
|
t.Fatalf("cover not cleared on delete: %v", goal.CoverPhotoID)
|
|
}
|
|
|
|
// A fresh upload becomes the cover, and moving it out clears it again.
|
|
next, err := st.photos.Upload(ctx, photos.UploadInput{
|
|
AlbumID: albumA.ID, UserID: user.ID, Filename: "next.png", Data: makePNG(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload next: %v", err)
|
|
}
|
|
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
|
|
if goal.CoverPhotoID == nil || *goal.CoverPhotoID != next.ID {
|
|
t.Fatalf("cover = %v, want %s", goal.CoverPhotoID, next.ID)
|
|
}
|
|
if _, err := st.photos.MoveBatch(ctx, []string{next.ID}, user.ID, albumB.ID); err != nil {
|
|
t.Fatalf("move: %v", err)
|
|
}
|
|
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
|
|
if goal.CoverPhotoID != nil {
|
|
t.Fatalf("cover not cleared on move: %v", goal.CoverPhotoID)
|
|
}
|
|
}
|
|
|
|
func TestWorkerPublishesPhotoUpdated(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "galo.db")
|
|
st, closeDB := openStack(t, path, t.TempDir())
|
|
defer closeDB()
|
|
|
|
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
|
|
wctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
go worker.Run(wctx)
|
|
|
|
user, _, err := st.auth.Register(ctx, "evt@test.local", "password123", "E")
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
|
|
if err != nil {
|
|
t.Fatalf("create album: %v", err)
|
|
}
|
|
|
|
sub := st.hub.SubscribeAlbum(album.ID)
|
|
defer sub.Close()
|
|
|
|
if _, err := st.photos.Upload(ctx, photos.UploadInput{
|
|
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
|
|
}); err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
|
|
select {
|
|
case ev := <-sub.Events():
|
|
if ev.Type != "photo.updated" {
|
|
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("no photo.updated event published for generated variants")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteEndToEndWithRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "galo.db")
|
|
blobs := t.TempDir()
|
|
|
|
st, closeDB := openStack(t, path, blobs)
|
|
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
|
|
wctx, cancel := context.WithCancel(ctx)
|
|
go worker.Run(wctx)
|
|
|
|
user, _, err := st.auth.Register(ctx, "e2e@test.local", "password123", "E2E")
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
|
|
if err != nil {
|
|
t.Fatalf("create album: %v", err)
|
|
}
|
|
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
|
|
if err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
waitFor(t, "variants of first photo", func() bool {
|
|
got, err := st.photos.Get(ctx, p.ID, user.ID)
|
|
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
|
|
})
|
|
|
|
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
|
|
t.Fatalf("permanent delete: %v", err)
|
|
}
|
|
waitFor(t, "garbage collection", func() bool {
|
|
var n int
|
|
return st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects`).Scan(&n) == nil && n == 0
|
|
})
|
|
|
|
// Stop the worker, then queue another upload whose variant job must survive
|
|
// the restart and be processed by a fresh worker over the same file.
|
|
cancel()
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
p2, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t)})
|
|
if err != nil {
|
|
t.Fatalf("upload p2: %v", err)
|
|
}
|
|
got2, _ := st.photos.Get(ctx, p2.ID, user.ID)
|
|
if got2.PreviewURL != "" {
|
|
t.Fatalf("variants generated before restart")
|
|
}
|
|
closeDB()
|
|
|
|
st2, close2 := openStack(t, path, blobs)
|
|
defer close2()
|
|
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute, 2)
|
|
wctx2, cancel2 := context.WithCancel(ctx)
|
|
defer cancel2()
|
|
go worker2.Run(wctx2)
|
|
|
|
waitFor(t, "variants of second photo after restart", func() bool {
|
|
got, err := st2.photos.Get(ctx, p2.ID, user.ID)
|
|
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
|
|
})
|
|
}
|
|
|
|
func TestJWTSecretPersistsAcrossRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "galo.db")
|
|
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
|
|
if err != nil {
|
|
t.Fatalf("migrations fs: %v", err)
|
|
}
|
|
|
|
open := func() (*sql.DB, store.Repos) {
|
|
t.Helper()
|
|
if err := db.MigrateSQLite(path, migFS); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
database, err := db.OpenSQLite(path)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
return database, store.NewSQLite(database)
|
|
}
|
|
|
|
db1, repos1 := open()
|
|
secret1, err := repos1.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
|
|
if err != nil || secret1 == "" {
|
|
t.Fatalf("ensure secret: %v %q", err, secret1)
|
|
}
|
|
tm1 := auth.NewTokenManager(secret1, 15*time.Minute, time.Hour)
|
|
token, err := tm1.GenerateAccess("user-1", "u@test.local")
|
|
if err != nil {
|
|
t.Fatalf("generate: %v", err)
|
|
}
|
|
_ = db1.Close()
|
|
|
|
db2, repos2 := open()
|
|
defer db2.Close()
|
|
secret2, err := repos2.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
|
|
if err != nil {
|
|
t.Fatalf("ensure after restart: %v", err)
|
|
}
|
|
if secret2 != secret1 {
|
|
t.Fatalf("secret changed across restart")
|
|
}
|
|
tm2 := auth.NewTokenManager(secret2, 15*time.Minute, time.Hour)
|
|
if _, err := tm2.ParseAccess(token); err != nil {
|
|
t.Fatalf("token issued before restart not valid after: %v", err)
|
|
}
|
|
}
|
|
|
|
func waitFor(t *testing.T, what string, cond func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for %s", what)
|
|
}
|