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", } { 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) } }