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/internal/subsonic/dto" "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= 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= 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"] != dto.ProtocolVersion || jroot["type"] != dto.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 != dto.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="+dto.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="+dto.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="+dto.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="+dto.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="+dto.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="+dto.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="+dto.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="+dto.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="+dto.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="+dto.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) } }