EXPAND ONLY, per the migration order (expand, migrate, verify, contract). No
Postgres implementation, no behaviour change.
internal/catalog now exposes:
type Store interface {
Item / ItemFor / Files / FilesFor / FileByID / FirstFile / FileCount
}
type SQL struct{ db *sql.DB }; func New(db *sql.DB) *SQL
The seven bodies are the old functions moved verbatim. ItemFor still returns the
same ErrNotFound for absent and forbidden, so the security property is
unchanged. A compile-time `var _ Store = (*SQL)(nil)` keeps the implementation
honest.
Deliberately NOT moved: browse lists, home shelves and subsonic album queries.
Those are each package's own presentation concern, and pulling all of them into
the seam is exactly the 186-statement rewrite the seam exists to avoid. The
package doc says so.
Proven substitutable, not just declared: store_test.go implements a fake Store
in memory with no database at all and drives a caller through the interface. A
seam nobody can substitute proves nothing.
The agent's own risk note was right and I acted on it: the seam only pays off
once something actually threads it, and initially nothing did. So internal/
playback — the smallest consumer — now takes a catalog.Store instead of *sql.DB
and is wired with catalog.New(db) at the composition point. It has no database
reference left outside a comment. The remaining consumers (media, jellyfin,
subsonic, metadata) still call the package-level wrappers; threading them is
mechanical, and I would rather do it deliberately than in one sweep.
Verified: gofmt clean, build exit 0, vet exit 0, 25 packages pass, arch rules
pass. End to end against a running server: /playback/resolve still returns
vcodec:hevc for html5 and engine mpv when mpv is offered, an unknown item 404s,
/home and Jellyfin /Items still answer.
Known gap, unchanged by this commit: internal/arch does not enforce "new code
must not take *sql.DB", so the seam can erode by drift. That rule would be the
natural next step if this is to hold.
231 lines
7.9 KiB
Go
231 lines
7.9 KiB
Go
package playback
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/catalog"
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/internal/engine"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "playback.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
|
t.Helper()
|
|
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
|
|
t.Fatalf("exec %q: %v", q, err)
|
|
}
|
|
}
|
|
|
|
func mustID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
|
|
t.Fatalf("query %q: %v", q, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func newHandlers(t *testing.T, db *sql.DB) *Handlers {
|
|
t.Helper()
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
a := auth.NewAuthenticator(db, "vellum_session", 0, false, log)
|
|
return NewHandlers(catalog.New(db), log, a)
|
|
}
|
|
|
|
// fixture builds two libraries with one item each and a user granted only the
|
|
// first. The item in the second library must be invisible.
|
|
type fixture struct {
|
|
db *sql.DB
|
|
libA int64
|
|
libB int64
|
|
userA int64
|
|
itemA int64 // mp4 h264 aac, readable
|
|
itemB int64 // mp4 hevc aac, NOT readable
|
|
}
|
|
|
|
func setup(t *testing.T) fixture {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
f := fixture{db: db}
|
|
f.libA = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('A','video','/a') RETURNING id`)
|
|
f.libB = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('B','video','/b') RETURNING id`)
|
|
f.userA = mustID(t, db, `INSERT INTO users (username, password_hash) VALUES ('alice','x') RETURNING id`)
|
|
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libA)
|
|
|
|
f.itemA = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'a.mp4', 'A') RETURNING id`, f.libA)
|
|
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'a.mp4', 1, 'mp4', 'h264', 'aac')`, f.itemA)
|
|
|
|
f.itemB = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'b.mp4', 'B') RETURNING id`, f.libB)
|
|
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'b.mp4', 1, 'mp4', 'hevc', 'aac')`, f.itemB)
|
|
return f
|
|
}
|
|
|
|
func resolve(t *testing.T, h *Handlers, u auth.User, body string) (int, engine.Resolution) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/resolve", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
h.handleResolve(rec, req, u)
|
|
var res engine.Resolution
|
|
if rec.Code == http.StatusOK {
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
|
|
t.Fatalf("decode resolution: %v (body %s)", err, rec.Body.String())
|
|
}
|
|
}
|
|
return rec.Code, res
|
|
}
|
|
|
|
func TestEnginesEndpoint(t *testing.T) {
|
|
f := setup(t)
|
|
h := newHandlers(t, f.db)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/engines", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.handleEngines(rec, req, auth.User{ID: f.userA})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Engines []engine.Engine `json:"engines"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode engines: %v", err)
|
|
}
|
|
got := map[string]bool{}
|
|
for _, e := range body.Engines {
|
|
got[e.ID] = true
|
|
}
|
|
for _, id := range []string{"html5", "mpv", "vlc", "mdk"} {
|
|
if !got[id] {
|
|
t.Errorf("engines response is missing %q: %s", id, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolvePlayableAndNot(t *testing.T) {
|
|
f := setup(t)
|
|
h := newHandlers(t, f.db)
|
|
u := auth.User{ID: f.userA}
|
|
|
|
code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
|
|
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "html5" {
|
|
t.Fatalf("h264 resolve = %d %+v, want playable html5", code, res)
|
|
}
|
|
|
|
// The readable item's facts are h264, so to test HEVC we point at itemB but
|
|
// grant it first — scoping is tested separately.
|
|
mustExec(t, f.db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libB)
|
|
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
|
|
if code != http.StatusOK || res.Playable {
|
|
t.Fatalf("hevc resolve = %d %+v, want not playable", code, res)
|
|
}
|
|
if res.Reason != "vcodec:hevc" {
|
|
t.Errorf("reason = %q, want vcodec:hevc", res.Reason)
|
|
}
|
|
if !strings.Contains(res.Message, "HEVC") {
|
|
t.Errorf("message = %q, want it to name HEVC", res.Message)
|
|
}
|
|
|
|
// A native engine changes the answer.
|
|
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5","mpv"]}`)
|
|
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "mpv" {
|
|
t.Fatalf("hevc+mpv resolve = %d %+v, want playable mpv", code, res)
|
|
}
|
|
}
|
|
|
|
// TestResolveGrantScoping pins the chokepoint: an item in a library the caller
|
|
// cannot read must be a 404, indistinguishable from a missing item.
|
|
func TestResolveGrantScoping(t *testing.T) {
|
|
f := setup(t)
|
|
h := newHandlers(t, f.db)
|
|
|
|
// userA may read libA only; itemB lives in libB.
|
|
code, _ := resolve(t, h, auth.User{ID: f.userA}, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("inaccessible item status = %d, want 404", code)
|
|
}
|
|
|
|
// A user with no grants gets the same 404 for a readable-by-someone item.
|
|
none := mustID(t, f.db, `INSERT INTO users (username, password_hash) VALUES ('carol','x') RETURNING id`)
|
|
code, _ = resolve(t, h, auth.User{ID: none}, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("no-grant item status = %d, want 404", code)
|
|
}
|
|
|
|
// A nonexistent item is also a 404, not a leak.
|
|
code, _ = resolve(t, h, auth.User{ID: f.userA}, `{"item_id":99999,"engines":["html5"]}`)
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("missing item status = %d, want 404", code)
|
|
}
|
|
}
|
|
|
|
func TestResolveValidation(t *testing.T) {
|
|
f := setup(t)
|
|
h := newHandlers(t, f.db)
|
|
u := auth.User{ID: f.userA}
|
|
|
|
for _, body := range []string{
|
|
`{"item_id":0,"engines":["html5"]}`,
|
|
`{"item_id":-3,"engines":["html5"]}`,
|
|
} {
|
|
if code, _ := resolve(t, h, u, body); code != http.StatusBadRequest {
|
|
t.Errorf("body %s status = %d, want 400", body, code)
|
|
}
|
|
}
|
|
|
|
// Missing engines is allowed: the handler falls back to the html5 baseline.
|
|
if code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`}`); code != http.StatusOK || !res.Playable {
|
|
t.Errorf("missing engines = %d %+v, want playable fallback", code, res)
|
|
}
|
|
}
|
|
|
|
// TestRegisterRequiresAuth proves the routes are mounted behind the auth
|
|
// middleware: an unauthenticated request is rejected with 401.
|
|
func TestRegisterRequiresAuth(t *testing.T) {
|
|
f := setup(t)
|
|
h := newHandlers(t, f.db)
|
|
mux := http.NewServeMux()
|
|
h.Register(mux)
|
|
|
|
for _, tc := range []struct{ method, path, body string }{
|
|
{http.MethodGet, "/api/v1/playback/engines", ""},
|
|
{http.MethodPost, "/api/v1/playback/resolve", `{"item_id":1,"engines":["html5"]}`},
|
|
} {
|
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
|
if tc.body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|