275 lines
8.6 KiB
Go
275 lines
8.6 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
|
)
|
|
|
|
func TestResponseEnvelopeHasOpenSubsonicFields(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["openSubsonic"] != true {
|
|
t.Errorf("openSubsonic = %v, want true", j["openSubsonic"])
|
|
}
|
|
if j["type"] != ServerName {
|
|
t.Errorf("type = %v, want %q", j["type"], ServerName)
|
|
}
|
|
if j["serverVersion"] == nil || j["serverVersion"] == "" {
|
|
t.Errorf("serverVersion missing: %v", j)
|
|
}
|
|
}
|
|
|
|
func TestGetOpenSubsonicExtensions(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/getOpenSubsonicExtensions.view", q)
|
|
j := decodeJSON(t, resp)
|
|
exts, ok := j["openSubsonicExtensions"].([]any)
|
|
if !ok || len(exts) == 0 {
|
|
t.Fatalf("openSubsonicExtensions missing/empty: %v", j)
|
|
}
|
|
names := map[string]bool{}
|
|
for _, e := range exts {
|
|
m := e.(map[string]any)
|
|
names[m["name"].(string)] = true
|
|
if versions, ok := m["versions"].([]any); !ok || len(versions) == 0 {
|
|
t.Errorf("extension %v has no versions", m["name"])
|
|
}
|
|
}
|
|
// This list grows as each extension is implemented through this
|
|
// phase; see docs/subsonic-compatibility.md for the final complete
|
|
// set once Phase 3 is done.
|
|
for _, want := range []string{
|
|
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
|
"indexBasedQueue", "playbackReport",
|
|
} {
|
|
if !names[want] {
|
|
t.Errorf("extension %q not advertised: %v", want, names)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetOpenSubsonicExtensionsNeverStubbed(t *testing.T) {
|
|
// Regression guard for "no fake support": every advertised name must
|
|
// correspond to a real registered handler or a documented framework
|
|
// behavior (formPost/apiKeyAuthentication/transcodeOffset don't have
|
|
// their own endpoint, everything else does).
|
|
needsHandler := map[string]string{
|
|
"getPodcastEpisode": "getPodcastEpisode",
|
|
"indexBasedQueue": "getPlayQueueByIndex",
|
|
"playbackReport": "reportPlayback",
|
|
"songLyrics": "getLyricsBySongId",
|
|
"sonicSimilarity": "getSonicSimilarTracks",
|
|
"topSongsByArtistId": "getTopSongs",
|
|
"transcoding": "getTranscodeDecision",
|
|
}
|
|
for ext, endpoint := range needsHandler {
|
|
if !hasExtension(ext) {
|
|
continue
|
|
}
|
|
if _, ok := registry[endpoint]; !ok {
|
|
t.Errorf("extension %q advertised but handler %q not registered", ext, endpoint)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyAuthenticationSuccess(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
rawKey, err := vsauth.GenerateAPIKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "TestClient/1.0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}, "f": {"json"}}
|
|
resp := get(t, srv.URL, q)
|
|
j := decodeJSON(t, resp)
|
|
if j["status"] != "ok" {
|
|
t.Fatalf("apiKey auth failed: %v", j)
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyAuthenticationInvalidKey(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
|
|
q := url.Values{"apiKey": {"vs_bogus"}, "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 != ErrInvalidAPIKey {
|
|
t.Fatalf("expected error 44, got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyAuthenticationRevokedKey(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
rawKey, _ := vsauth.GenerateAPIKey()
|
|
key, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.RevokeAPIKey(db, key.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
q := url.Values{"apiKey": {rawKey}, "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 != ErrInvalidAPIKey {
|
|
t.Fatalf("expected error 44 for revoked key, got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyAuthenticationConflictsWithUsername(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
rawKey, _ := vsauth.GenerateAPIKey()
|
|
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
q := url.Values{"apiKey": {rawKey}, "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 != ErrConflictingAuth {
|
|
t.Fatalf("expected error 43, got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyDisabledUser(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
rawKey, _ := vsauth.GenerateAPIKey()
|
|
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.SetUserDisabled(db, u.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
q := url.Values{"apiKey": {rawKey}, "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 != ErrInvalidAPIKey {
|
|
t.Fatalf("expected error 44 for disabled user's key, got %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestTokenInfo(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
rawKey, _ := vsauth.GenerateAPIKey()
|
|
if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}, "f": {"json"}}
|
|
resp := getPath(t, srv.URL, "/rest/tokenInfo.view", q)
|
|
j := decodeJSON(t, resp)
|
|
info, ok := j["tokenInfo"].(map[string]any)
|
|
if !ok || info["username"] != "alice" {
|
|
t.Fatalf("tokenInfo = %v, want username alice", j)
|
|
}
|
|
}
|
|
|
|
func TestAPIKeyTouchesLastUsed(t *testing.T) {
|
|
s, db := newTestServer(t)
|
|
u := createTestUser(t, s, "alice", "password123", false)
|
|
rawKey, _ := vsauth.GenerateAPIKey()
|
|
key, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if key.LastUsedAt.Valid {
|
|
t.Fatal("new key should not have last_used_at set")
|
|
}
|
|
|
|
srv := httptest.NewServer(s.Router())
|
|
defer srv.Close()
|
|
q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}}
|
|
get(t, srv.URL, q)
|
|
|
|
updated, err := database.GetAPIKey(db, key.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !updated.LastUsedAt.Valid {
|
|
t.Error("expected last_used_at to be set after use")
|
|
}
|
|
}
|
|
|
|
func TestFormPostSuccess(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
|
|
q := baseParams("alice", "password123")
|
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/rest/ping.view", strings.NewReader(q.Encode()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
x := decodeXML(t, resp)
|
|
if x.Status != "ok" {
|
|
t.Fatalf("POST ping failed: %+v", x)
|
|
}
|
|
}
|
|
|
|
func TestFormPostBodyTakesPrecedenceOverQuery(t *testing.T) {
|
|
s, srv := newTestRecorderServer(t)
|
|
createTestUser(t, s, "alice", "password123", false)
|
|
createTestUser(t, s, "mallory", "password123", false)
|
|
|
|
// Body says mallory+correct password; query claims a different user.
|
|
// The body value must win — this is the documented net/http
|
|
// ParseForm precedence (POST-body values are placed before
|
|
// query-string values in r.Form), asserted here rather than
|
|
// silently relied upon.
|
|
q := url.Values{"u": {"someone-else"}, "p": {"wrong"}, "v": {"1.16.1"}, "c": {"test"}}
|
|
body := baseParams("mallory", "password123")
|
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/rest/getUser.view?"+q.Encode()+"&username=mallory", strings.NewReader(body.Encode()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
x := decodeXML(t, resp)
|
|
if x.Status != "ok" {
|
|
t.Fatalf("expected success (authenticated as mallory via body), got %+v", x)
|
|
}
|
|
}
|