Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.
Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
credential that is not the session cookie, and letting two agents each invent
a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
value so only its hash is kept, while a Subsonic key must be recoverable
because verifying `t` means computing md5(key + salt) server-side. token_plain
is therefore the one column in the schema holding a usable credential. It is a
generated random secret, never a user password, and it is revocable.
Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
/Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
/Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
/Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
with a DirectStreamUrl carrying the api_key. A client that disables both
direct modes is demanding a transcode and is refused with ErrorCode
"NoCompatibleStream" — rather than handed a TranscodingUrl that would never
resolve, which is how a client hangs forever.
Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.
Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.
Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
PlaybackInfo showing SupportsTranscoding=false, and a Range request to
/Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
/api/v1/version all still answer correctly.
Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
501 lines
17 KiB
Go
501 lines
17 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/apitoken"
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
// fixture is one seeded Subsonic server.
|
|
type fixture struct {
|
|
db *sql.DB
|
|
userID int64
|
|
key string
|
|
libID int64
|
|
album int64
|
|
file int64
|
|
root string
|
|
}
|
|
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "subsonic.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := core.Migrate(context.Background(), db, migrations.FS, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// newFixture seeds one non-admin user with a granted music library holding a
|
|
// single three-track album, an artist credit and a cover.
|
|
func newFixture(t *testing.T) (*httptest.Server, *fixture) {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
root := t.TempDir()
|
|
for _, name := range []string{
|
|
"Artist/Album/01 - One.mp3",
|
|
"Artist/Album/02 - Two.mp3",
|
|
"Artist/Album/03 - Three.mp3",
|
|
} {
|
|
p := filepath.Join(root, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 100 deterministic bytes so a Range read is verifiable.
|
|
if err := os.WriteFile(p, []byte(strings.Repeat("v", 100)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
coverPath := filepath.Join(root, "cover.png")
|
|
if err := os.WriteFile(coverPath, []byte("\x89PNG\r\n\x1a\ncoverbytes"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
user, err := auth.CreateUser(ctx, db, "alice", "Alice", "password123", false)
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
var libID int64
|
|
if err := db.QueryRowContext(ctx,
|
|
`INSERT INTO libraries (name, kind, root_path) VALUES ('Music','music',?) RETURNING id`,
|
|
root).Scan(&libID); err != nil {
|
|
t.Fatalf("insert library: %v", err)
|
|
}
|
|
if _, err := db.ExecContext(ctx,
|
|
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?,?,1,1)`,
|
|
user.ID, libID); err != nil {
|
|
t.Fatalf("grant: %v", err)
|
|
}
|
|
|
|
var albumID int64
|
|
if err := db.QueryRowContext(ctx, `
|
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
|
|
VALUES (?, 'music', 'Artist/Album', 'Album', 'album', 2013) RETURNING id`,
|
|
libID).Scan(&albumID); err != nil {
|
|
t.Fatalf("insert album: %v", err)
|
|
}
|
|
|
|
var firstFile int64
|
|
for i, name := range []string{
|
|
"Artist/Album/01 - One.mp3",
|
|
"Artist/Album/02 - Two.mp3",
|
|
"Artist/Album/03 - Three.mp3",
|
|
} {
|
|
var id int64
|
|
if err := db.QueryRowContext(ctx, `
|
|
INSERT INTO media_files (item_id, path, size, container, duration_ms)
|
|
VALUES (?,?,100,'mp3',60000) RETURNING id`,
|
|
albumID, name).Scan(&id); err != nil {
|
|
t.Fatalf("insert file: %v", err)
|
|
}
|
|
if i == 0 {
|
|
firstFile = id
|
|
}
|
|
}
|
|
|
|
var personID int64
|
|
if err := db.QueryRowContext(ctx,
|
|
`INSERT INTO people (name) VALUES ('The Artist') RETURNING id`).Scan(&personID); err != nil {
|
|
t.Fatalf("insert person: %v", err)
|
|
}
|
|
if _, err := db.ExecContext(ctx,
|
|
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?,?,'author',0)`,
|
|
albumID, personID); err != nil {
|
|
t.Fatalf("insert credit: %v", err)
|
|
}
|
|
if _, err := db.ExecContext(ctx,
|
|
`INSERT INTO assets (item_id, role, path, bytes) VALUES (?, 'cover', ?, 20)`,
|
|
albumID, coverPath); err != nil {
|
|
t.Fatalf("insert asset: %v", err)
|
|
}
|
|
|
|
key, err := apitoken.Mint(ctx, db, user.ID, apitoken.KindSubsonic, "test")
|
|
if err != nil {
|
|
t.Fatalf("Mint: %v", err)
|
|
}
|
|
|
|
authenticator := auth.NewAuthenticator(db, "vellum_session", 0, false, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
h := NewHandlers(db, slog.New(slog.NewTextHandler(io.Discard, nil)), authenticator)
|
|
mux := http.NewServeMux()
|
|
h.Register(mux)
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv, &fixture{db: db, userID: user.ID, key: key, libID: libID, album: albumID, file: firstFile, root: root}
|
|
}
|
|
|
|
// authQuery is the preferred t=md5(key+salt) credential form.
|
|
func (f *fixture) authQuery() string {
|
|
salt := "abc123"
|
|
sum := md5.Sum([]byte(f.key + salt))
|
|
return "u=alice&s=" + salt + "&t=" + hex.EncodeToString(sum[:]) + "&v=1.16.1&c=test"
|
|
}
|
|
|
|
// get performs a request and returns the recorder.
|
|
func get(t *testing.T, url string) *http.Response {
|
|
t.Helper()
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", url, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func readBody(t *testing.T, resp *http.Response) string {
|
|
t.Helper()
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// jsonEnvelope decodes a Subsonic JSON response into the root object.
|
|
func jsonEnvelope(t *testing.T, body string) map[string]any {
|
|
t.Helper()
|
|
var out map[string]any
|
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
|
t.Fatalf("decode json %q: %v", body, err)
|
|
}
|
|
root, ok := out["subsonic-response"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing subsonic-response in %q", body)
|
|
}
|
|
return root
|
|
}
|
|
|
|
// ── auth ───────────────────────────────────────────────────────────────
|
|
|
|
// TestAuthTokenForm proves the preferred md5(key+salt) path succeeds.
|
|
func TestAuthTokenForm(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
if root["status"] != "ok" {
|
|
t.Fatalf("status = %v, want ok; body=%s", root["status"], body)
|
|
}
|
|
}
|
|
|
|
// TestAuthWrongTokenIsEnvelope pins the single most common integration failure:
|
|
// a bad token is HTTP 200 with status failed and error code 40, never a 401.
|
|
func TestAuthWrongTokenIsEnvelope(t *testing.T) {
|
|
srv, _ := newFixture(t)
|
|
resp := get(t, srv.URL+"/rest/ping.view?u=alice&s=abc123&t=deadbeef&v=1.16.1&c=test&f=json")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("HTTP status = %d, want 200 (errors belong in the envelope)", resp.StatusCode)
|
|
}
|
|
body := readBody(t, resp)
|
|
root := jsonEnvelope(t, body)
|
|
if root["status"] != "failed" {
|
|
t.Fatalf("status = %v, want failed; body=%s", root["status"], body)
|
|
}
|
|
errObj, ok := root["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing error object; body=%s", body)
|
|
}
|
|
if code, _ := errObj["code"].(float64); int(code) != 40 {
|
|
t.Errorf("error code = %v, want 40; body=%s", errObj["code"], body)
|
|
}
|
|
}
|
|
|
|
// TestAuthPlainKey proves p=<key> is accepted (Navidrome compatibility).
|
|
func TestAuthPlainKey(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=alice&p="+f.key+"&v=1.16.1&c=test&f=json"))
|
|
if root := jsonEnvelope(t, body); root["status"] != "ok" {
|
|
t.Fatalf("p=<key> status = %v, want ok; body=%s", root["status"], body)
|
|
}
|
|
}
|
|
|
|
// TestAuthUnknownUserIsCode40 proves an unknown user is the same envelope error.
|
|
func TestAuthUnknownUserIsCode40(t *testing.T) {
|
|
srv, _ := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=nobody&p=x&v=1.16.1&c=test&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
if root["status"] != "failed" {
|
|
t.Fatalf("unknown user status = %v, want failed", root["status"])
|
|
}
|
|
if code, _ := root["error"].(map[string]any)["code"].(float64); int(code) != 40 {
|
|
t.Errorf("unknown user code = %v, want 40", root["error"])
|
|
}
|
|
}
|
|
|
|
// ── system / browse ────────────────────────────────────────────────────
|
|
|
|
func TestPingJSONAndXML(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
|
|
jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
|
|
if jroot["status"] != "ok" || jroot["version"] != protocolVersion || jroot["type"] != serverType {
|
|
t.Errorf("json envelope = %v", jroot)
|
|
}
|
|
|
|
xmlBody := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=xml"))
|
|
var parsed struct {
|
|
XMLName xml.Name `xml:"subsonic-response"`
|
|
Status string `xml:"status,attr"`
|
|
Version string `xml:"version,attr"`
|
|
}
|
|
if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil {
|
|
t.Fatalf("xml parse: %v; body=%s", err, xmlBody)
|
|
}
|
|
if parsed.Status != "ok" || parsed.Version != protocolVersion {
|
|
t.Errorf("xml envelope = %+v", parsed)
|
|
}
|
|
}
|
|
|
|
func TestGetMusicFolders(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/getMusicFolders.view?"+f.authQuery()+"&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
folders, ok := root["musicFolders"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing musicFolders; body=%s", body)
|
|
}
|
|
list, ok := folders["musicFolder"].([]any)
|
|
if !ok || len(list) != 1 {
|
|
t.Fatalf("musicFolder = %v, want one entry; body=%s", folders["musicFolder"], body)
|
|
}
|
|
if list[0].(map[string]any)["name"] != "Music" {
|
|
t.Errorf("folder = %v", list[0])
|
|
}
|
|
}
|
|
|
|
func TestGetIndexesAndArtists(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
|
|
body := readBody(t, get(t, srv.URL+"/rest/getArtists.view?"+f.authQuery()+"&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
artists := root["artists"].(map[string]any)
|
|
indexes := artists["index"].([]any)
|
|
if len(indexes) != 1 {
|
|
t.Fatalf("indexes = %v", indexes)
|
|
}
|
|
first := indexes[0].(map[string]any)
|
|
if first["name"] != "A" {
|
|
t.Errorf("index name = %v, want A (The Artist)", first["name"])
|
|
}
|
|
if first["artist"].([]any)[0].(map[string]any)["name"] != "The Artist" {
|
|
t.Errorf("artist = %v", first["artist"])
|
|
}
|
|
|
|
// getIndexes returns the same index but a leaner artist element.
|
|
body = readBody(t, get(t, srv.URL+"/rest/getIndexes.view?"+f.authQuery()+"&f=json"))
|
|
root = jsonEnvelope(t, body)
|
|
if _, ok := root["indexes"]; !ok {
|
|
t.Fatalf("missing indexes; body=%s", body)
|
|
}
|
|
}
|
|
|
|
func TestSearch3(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/search3.view?"+f.authQuery()+"&query=Artist&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
result := root["searchResult3"].(map[string]any)
|
|
|
|
albums := result["album"].([]any)
|
|
if len(albums) != 1 {
|
|
t.Fatalf("albums = %v, want one; body=%s", albums, body)
|
|
}
|
|
if albums[0].(map[string]any)["name"] != "Album" {
|
|
t.Errorf("album = %v", albums[0])
|
|
}
|
|
if len(result["song"].([]any)) != 3 {
|
|
t.Errorf("songs = %v, want three", result["song"])
|
|
}
|
|
if len(result["artist"].([]any)) != 1 {
|
|
t.Errorf("artists = %v, want one", result["artist"])
|
|
}
|
|
}
|
|
|
|
func TestGetAlbumList2Newest(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/getAlbumList2.view?"+f.authQuery()+"&type=newest&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
list := root["albumList2"].(map[string]any)
|
|
albums := list["album"].([]any)
|
|
if len(albums) != 1 {
|
|
t.Fatalf("albums = %v; body=%s", albums, body)
|
|
}
|
|
if albums[0].(map[string]any)["songCount"].(float64) != 3 {
|
|
t.Errorf("songCount = %v, want 3", albums[0].(map[string]any)["songCount"])
|
|
}
|
|
}
|
|
|
|
func TestGetAlbumAndSong(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
|
|
body := readBody(t, get(t, srv.URL+"/rest/getAlbum.view?"+f.authQuery()+"&id="+albumID(f.album)+"&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
album := root["album"].(map[string]any)
|
|
if len(album["song"].([]any)) != 3 {
|
|
t.Errorf("getAlbum songs = %v", album["song"])
|
|
}
|
|
|
|
body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+fileID(f.file)+"&f=json"))
|
|
root = jsonEnvelope(t, body)
|
|
song := root["song"].(map[string]any)
|
|
if song["album"] != "Album" || song["artist"] != "The Artist" {
|
|
t.Errorf("song = %v", song)
|
|
}
|
|
}
|
|
|
|
func TestGetMusicDirectory(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+folderID(f.libID)+"&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
dir := root["directory"].(map[string]any)
|
|
children := dir["child"].([]any)
|
|
if len(children) != 1 || children[0].(map[string]any)["isDir"] != true {
|
|
t.Fatalf("root children = %v; body=%s", children, body)
|
|
}
|
|
// Drill into the album directory and confirm its songs appear.
|
|
body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+albumID(f.album)+"&f=json"))
|
|
root = jsonEnvelope(t, body)
|
|
dir = root["directory"].(map[string]any)
|
|
if len(dir["child"].([]any)) != 3 {
|
|
t.Errorf("album directory children = %v", dir["child"])
|
|
}
|
|
}
|
|
|
|
func TestGetUser(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/getUser.view?"+f.authQuery()+"&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
user := root["user"].(map[string]any)
|
|
if user["username"] != "alice" {
|
|
t.Errorf("username = %v", user["username"])
|
|
}
|
|
if user["adminRole"] != false || user["streamRole"] != true {
|
|
t.Errorf("roles = %v", user)
|
|
}
|
|
}
|
|
|
|
func TestGetPlaylistsEmpty(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/getPlaylists.view?"+f.authQuery()+"&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
pl, ok := root["playlists"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing playlists; body=%s", body)
|
|
}
|
|
if list, ok := pl["playlist"].([]any); !ok || len(list) != 0 {
|
|
t.Errorf("playlist = %v, want empty array", pl["playlist"])
|
|
}
|
|
}
|
|
|
|
// ── media ──────────────────────────────────────────────────────────────
|
|
|
|
func TestStreamRawAndRange(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
|
|
resp := get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if got := readBody(t, resp); len(got) != 100 {
|
|
t.Fatalf("stream body length = %d, want 100", len(got))
|
|
}
|
|
|
|
req, _ := http.NewRequest(http.MethodGet,
|
|
srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw", nil)
|
|
req.Header.Set("Range", "bytes=0-9")
|
|
rangeResp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("range request: %v", err)
|
|
}
|
|
defer rangeResp.Body.Close()
|
|
if rangeResp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("range status = %d, want 206", rangeResp.StatusCode)
|
|
}
|
|
if cr := rangeResp.Header.Get("Content-Range"); cr != "bytes 0-9/100" {
|
|
t.Errorf("Content-Range = %q, want bytes 0-9/100", cr)
|
|
}
|
|
body, _ := io.ReadAll(rangeResp.Body)
|
|
if len(body) != 10 {
|
|
t.Errorf("range body length = %d, want 10", len(body))
|
|
}
|
|
}
|
|
|
|
// TestStreamRejectsTranscode proves a non-raw format is refused honestly rather
|
|
// than silently served untranscoded.
|
|
func TestStreamRejectsTranscode(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=mp3&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
if root["status"] != "failed" {
|
|
t.Fatalf("format=mp3 status = %v, want failed; body=%s", root["status"], body)
|
|
}
|
|
if !strings.Contains(root["error"].(map[string]any)["message"].(string), "transcod") {
|
|
t.Errorf("error message = %v, want a transcoding refusal", root["error"])
|
|
}
|
|
}
|
|
|
|
func TestGetCoverArt(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
resp := get(t, srv.URL+"/rest/getCoverArt.view?"+f.authQuery()+"&id="+albumID(f.album))
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("cover status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if got := readBody(t, resp); !strings.HasPrefix(got, "\x89PNG") {
|
|
t.Errorf("cover bytes = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestScrobbleWritesProgress proves scrobble lands in the progress store.
|
|
func TestScrobbleWritesProgress(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
body := readBody(t, get(t, srv.URL+"/rest/scrobble.view?"+f.authQuery()+
|
|
"&id="+fileID(f.file)+"&submission=true&position=30000&f=json"))
|
|
if root := jsonEnvelope(t, body); root["status"] != "ok" {
|
|
t.Fatalf("scrobble status = %v; body=%s", root["status"], body)
|
|
}
|
|
|
|
var position, duration int64
|
|
if err := f.db.QueryRow(
|
|
`SELECT position, duration FROM progress WHERE user_id = ? AND item_id = ? AND file_id = ?`,
|
|
f.userID, f.album, f.file).Scan(&position, &duration); err != nil {
|
|
t.Fatalf("progress row: %v", err)
|
|
}
|
|
if position != 30000 || duration != 60000 {
|
|
t.Errorf("progress = (%d,%d), want (30000,60000)", position, duration)
|
|
}
|
|
}
|
|
|
|
// TestAccessScopedToGrants proves a user without a grant cannot stream.
|
|
func TestAccessScopedToGrants(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
// Revoke the grant, then request the same file.
|
|
if _, err := f.db.Exec(`DELETE FROM library_grants WHERE user_id = ?`, f.userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+fileID(f.file)+"&format=raw&f=json"))
|
|
root := jsonEnvelope(t, body)
|
|
if root["status"] != "failed" {
|
|
t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)
|
|
}
|
|
}
|