package subsonic import ( "crypto/md5" "encoding/hex" "encoding/json" "encoding/xml" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "git.noroutes.net/NoRoutes/vibetunes/internal/database" ) func get(t *testing.T, srv string, q url.Values) *http.Response { t.Helper() resp, err := http.Get(srv + "/rest/ping.view?" + q.Encode()) if err != nil { t.Fatal(err) } return resp } func getPath(t *testing.T, srv, path string, q url.Values) *http.Response { t.Helper() resp, err := http.Get(srv + path + "?" + q.Encode()) if err != nil { t.Fatal(err) } return resp } func baseParams(username, password string) url.Values { return url.Values{ "u": {username}, "p": {password}, "v": {"1.16.1"}, "c": {"vibesonic-test"}, } } func tokenParams(username, salt, token string) url.Values { return url.Values{ "u": {username}, "t": {token}, "s": {salt}, "v": {"1.16.1"}, "c": {"vibesonic-test"}, } } type xmlResp struct { XMLName xml.Name `xml:"subsonic-response"` Status string `xml:"status,attr"` Version string `xml:"version,attr"` Error *struct { Code int `xml:"code,attr"` Message string `xml:"message,attr"` } `xml:"error"` } func decodeXML(t *testing.T, resp *http.Response) xmlResp { t.Helper() defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var x xmlResp if err := xml.Unmarshal(body, &x); err != nil { t.Fatalf("invalid XML response: %v\nbody: %s", err, body) } return x } func decodeJSON(t *testing.T, resp *http.Response) map[string]any { t.Helper() defer resp.Body.Close() var m map[string]any if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { t.Fatalf("invalid JSON response: %v", err) } inner, ok := m["subsonic-response"].(map[string]any) if !ok { t.Fatalf("response missing subsonic-response envelope: %v", m) } return inner } func TestPingSuccessXML(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) resp := get(t, srv.URL, baseParams("alice", "password123")) x := decodeXML(t, resp) if x.Status != "ok" { t.Fatalf("status = %q, want ok", x.Status) } if x.Version != ServerVersion { t.Fatalf("version = %q, want %q", x.Version, ServerVersion) } if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "xml") { t.Errorf("Content-Type = %q, want xml", ct) } } func TestPingSuccessJSON(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("f", "json") resp := get(t, srv.URL, q) j := decodeJSON(t, resp) if j["status"] != "ok" { t.Fatalf("status = %v, want ok", j["status"]) } if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "json") { t.Errorf("Content-Type = %q, want json", ct) } } func TestPingJSONP(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("f", "jsonp") q.Set("callback", "myCallback") resp := get(t, srv.URL, q) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.HasPrefix(string(body), "myCallback(") || !strings.HasSuffix(strings.TrimSpace(string(body)), ")") { t.Fatalf("jsonp body = %s, want wrapped in myCallback(...)", body) } } func TestJSONPRejectsMaliciousCallback(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("f", "jsonp") q.Set("callback", "alert(1);foo") resp := get(t, srv.URL, q) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), "alert(1)") { t.Fatalf("malicious callback was reflected into the response body: %s", body) } } func TestWrongPasswordFails(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) resp := get(t, srv.URL, baseParams("alice", "wrongpassword")) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials { t.Fatalf("expected error 40, got %+v", x) } } func TestUnknownUserFails(t *testing.T) { _, srv := newTestRecorderServer(t) resp := get(t, srv.URL, baseParams("nosuchuser", "password123")) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials { t.Fatalf("expected error 40, got %+v", x) } } func TestDisabledUserFails(t *testing.T) { s, srv := newTestRecorderServer(t) u := createTestUser(t, s, "alice", "password123", false) if err := database.SetUserDisabled(s.DB, u.ID, true); err != nil { t.Fatal(err) } resp := get(t, srv.URL, baseParams("alice", "password123")) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials { t.Fatalf("expected error 40 for disabled user, got %+v", x) } } func TestMissingUsernameFails(t *testing.T) { _, srv := newTestRecorderServer(t) q := url.Values{"p": {"x"}, "v": {"1.16.1"}, "c": {"test"}} resp := get(t, srv.URL, q) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter { t.Fatalf("expected error 10, got %+v", x) } } func TestMissingCredentialsFails(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := url.Values{"u": {"alice"}, "v": {"1.16.1"}, "c": {"test"}} resp := get(t, srv.URL, q) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter { t.Fatalf("expected error 10 with neither p nor t/s, got %+v", x) } } func TestTokenAuthSuccess(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) salt := "saltysalt" sum := md5.Sum([]byte("password123" + salt)) token := hex.EncodeToString(sum[:]) resp := get(t, srv.URL, tokenParams("alice", salt, token)) x := decodeXML(t, resp) if x.Status != "ok" { t.Fatalf("token auth failed: %+v", x) } } func TestTokenAuthWrongToken(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) resp := get(t, srv.URL, tokenParams("alice", "saltysalt", "0000000000000000000000000000000")) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials { t.Fatalf("expected error 40, got %+v", x) } } func TestEncPasswordAuth(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) encoded := "enc:" + hex.EncodeToString([]byte("password123")) q := url.Values{"u": {"alice"}, "p": {encoded}, "v": {"1.16.1"}, "c": {"test"}} resp := get(t, srv.URL, q) x := decodeXML(t, resp) if x.Status != "ok" { t.Fatalf("enc: password auth failed: %+v", x) } } func TestExternalOnlyUserCannotTokenAuth(t *testing.T) { s, db := newTestServer(t) if _, err := database.CreateExternalUser(db, "extuser"); err != nil { t.Fatal(err) } srv := httptest.NewServer(s.Router()) defer srv.Close() sum := md5.Sum([]byte("irrelevant" + "salt")) resp := get(t, srv.URL, tokenParams("extuser", "salt", hex.EncodeToString(sum[:]))) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrTokenAuthNotSupported { t.Fatalf("expected error 41 for external-auth-only user, got %+v", x) } } func TestClientVersionTooNewFails(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("v", "99.0.0") resp := get(t, srv.URL, q) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrClientMustUpgrade { t.Fatalf("expected error 20 for too-new client version, got %+v", x) } } func TestOlderClientVersionAccepted(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("v", "1.1.0") resp := get(t, srv.URL, q) x := decodeXML(t, resp) if x.Status != "ok" { t.Fatalf("older client version should be accepted, got %+v", x) } } func TestUnknownEndpoint(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) resp := getPath(t, srv.URL, "/rest/thisEndpointDoesNotExist.view", baseParams("alice", "password123")) x := decodeXML(t, resp) if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound { t.Fatalf("expected error 70 for unknown endpoint, got %+v", x) } } func TestViewSuffixOptional(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) resp := getPath(t, srv.URL, "/rest/ping", baseParams("alice", "password123")) x := decodeXML(t, resp) if x.Status != "ok" { t.Fatalf("expected /rest/ping (no .view) to work, got %+v", x) } } func TestGetLicenseAlwaysValid(t *testing.T) { s, srv := newTestRecorderServer(t) createTestUser(t, s, "alice", "password123", false) q := baseParams("alice", "password123") q.Set("f", "json") resp := getPath(t, srv.URL, "/rest/getLicense.view", q) j := decodeJSON(t, resp) license, ok := j["license"].(map[string]any) if !ok { t.Fatalf("missing license in response: %v", j) } if valid, _ := license["valid"].(bool); !valid { t.Fatalf("license.valid = %v, want true", license["valid"]) } }