The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName and Subsonic's authenticate both called auth.Authenticate with no limiting, so an attacker brute-forced through those instead: the same password, the same argon2, no ceiling. The limiter moves from auth.Handlers onto *Authenticator, which all three surfaces already hold, so they share one budget and splitting guesses across surfaces gains nothing. main.go builds one authenticator and hands it to both compat layers, so the sharing is real rather than intended. Subsonic needs care: it has no login step, authenticate runs on every request, so a naive check would charge playback against the login budget and lock a listener out mid-album. The limiter is consulted only when a credential was actually supplied, and a success or an uncredentialed request spends nothing. Verified red-green on all three: removing the Jellyfin gate fails TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the Subsonic credential guard fails TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or ResetLogin fails the auth tests. Known gap, unchanged: the limiter is in-process, so it resets on restart and does not span replicas. Fine for one binary against one SQLite file.
551 lines
19 KiB
Go
551 lines
19 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/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=<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"])
|
|
}
|
|
}
|
|
|
|
// ── login rate limiting ─────────────────────────────────────────────────
|
|
|
|
// Subsonic is a third way to guess a password, so it shares the login budget:
|
|
// otherwise the web login's limit is bypassed by pointing a client here. The
|
|
// refusal is indistinguishable from any other credential failure — code 40.
|
|
func TestFailedAuthIsRateLimited(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
bad := "u=alice&s=abc123&t=deadbeef&v=1.16.1&c=test&f=json"
|
|
|
|
for i := 1; i <= 10; i++ {
|
|
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+bad)))
|
|
if root["status"] != "failed" {
|
|
t.Fatalf("attempt %d: status = %v, want failed", i, root["status"])
|
|
}
|
|
}
|
|
// The correct key is refused too: that is the limiter, not the key.
|
|
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
|
|
if root["status"] != "failed" {
|
|
t.Errorf("the correct key still worked after 10 failures; the limiter is not engaged")
|
|
}
|
|
}
|
|
|
|
// The budget must move only on a failed check with a credential behind it.
|
|
// authenticate runs on EVERY request, so charging a working client would lock a
|
|
// listener out mid-album, and a request that supplies no credential must not
|
|
// spend anything either.
|
|
func TestSuccessAndUncredentialedDoNotConsumeTheBudget(t *testing.T) {
|
|
srv, f := newFixture(t)
|
|
|
|
for i := 1; i <= 25; i++ {
|
|
// A username but no credential: charged against nothing, so a client
|
|
// that sends one cannot lock the real user out.
|
|
readBody(t, get(t, srv.URL+"/rest/ping.view?u=alice&v=1.16.1&c=test&f=json"))
|
|
}
|
|
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
|
|
if root["status"] != "ok" {
|
|
t.Fatalf("the correct key was refused after 25 requests carrying no credential")
|
|
}
|
|
|
|
// A success does not spend budget either: authenticate runs on every
|
|
// request, so a listener browsing for hours must never be locked out.
|
|
for i := 1; i <= 25; i++ {
|
|
root := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
|
|
if root["status"] != "ok" {
|
|
t.Fatalf("request %d with the correct key: status = %v, want ok", i, root["status"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
}
|