Phase 2: Subsonic REST API framework (auth, response formatting, error handling, routing) plus user permission model
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user