Files
vibesonic/internal/subsonic/router_test.go
T

326 lines
9.2 KiB
Go

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"])
}
}