326 lines
9.2 KiB
Go
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"])
|
|
}
|
|
}
|