Phase 3: OpenSubsonic core (response envelope, error codes 42-44, getOpenSubsonicExtensions) and apiKeyAuthentication extension with tokenInfo, web API key management
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package subsonic
|
||||
|
||||
func init() {
|
||||
registerExtension("apiKeyAuthentication", 1)
|
||||
register("tokenInfo", handleTokenInfo)
|
||||
}
|
||||
|
||||
// handleTokenInfo confirms which user an API key (or any other
|
||||
// credential) resolves to — primarily useful for a client that just
|
||||
// received a key and wants to confirm it works before storing it.
|
||||
func handleTokenInfo(rc *Context) *apiError {
|
||||
rc.Response = newResponse()
|
||||
rc.Response.TokenInfo = &TokenInfo{Username: rc.User.Username}
|
||||
return nil
|
||||
}
|
||||
@@ -22,9 +22,22 @@ import (
|
||||
// surface never reintroduces MD5 or plaintext as VibeSonic's own storage
|
||||
// format, it only speaks it on the wire where the protocol requires it.
|
||||
func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) {
|
||||
username, aerr := p.Required("u")
|
||||
if aerr != nil {
|
||||
return nil, aerr
|
||||
apiKey := p.Get("apiKey")
|
||||
username := p.Get("u")
|
||||
|
||||
if apiKey != "" {
|
||||
// The apiKeyAuthentication extension requires a client not send
|
||||
// 'u' alongside 'apiKey' — accepting both would leave it
|
||||
// ambiguous which identity the caller actually intended, so this
|
||||
// is rejected outright rather than silently preferring one.
|
||||
if username != "" {
|
||||
return nil, errConflictingAuth
|
||||
}
|
||||
return authenticateAPIKey(db, apiKey)
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return nil, errMissingParameter("u")
|
||||
}
|
||||
|
||||
user, err := database.GetUserByUsername(db, username)
|
||||
@@ -75,6 +88,24 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// authenticateAPIKey implements the OpenSubsonic apiKeyAuthentication
|
||||
// extension: the key is hashed the same way as VibeSonic's own web
|
||||
// settings API keys (they are exactly the same keys — one system, not a
|
||||
// parallel one) and looked up by hash, never by scanning stored keys in
|
||||
// plaintext.
|
||||
func authenticateAPIKey(db *sql.DB, rawKey string) (*database.User, *apiError) {
|
||||
key, err := database.GetAPIKeyByHash(db, vsauth.HashAPIKey(rawKey))
|
||||
if err != nil {
|
||||
return nil, errInvalidAPIKey
|
||||
}
|
||||
user, err := database.GetUserByID(db, key.UserID)
|
||||
if err != nil || user.Disabled {
|
||||
return nil, errInvalidAPIKey
|
||||
}
|
||||
_ = database.TouchAPIKey(db, key.ID)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// checkVersion rejects clients whose declared protocol version is newer
|
||||
// than what this server implements. A missing or unparsable v is
|
||||
// tolerated (treated as compatible) rather than rejected outright, to
|
||||
|
||||
+23
-10
@@ -1,16 +1,20 @@
|
||||
package subsonic
|
||||
|
||||
// Error codes from the Subsonic REST API specification.
|
||||
// Error codes from the Subsonic REST API specification, plus the
|
||||
// OpenSubsonic apiKeyAuthentication extension's additions (42, 43, 44).
|
||||
const (
|
||||
ErrGeneric = 0
|
||||
ErrMissingParameter = 10
|
||||
ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports
|
||||
ErrServerMustUpgrade = 30 // server requires a newer client
|
||||
ErrWrongCredentials = 40
|
||||
ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password
|
||||
ErrUnauthorized = 50 // authenticated, but not permitted to perform this action
|
||||
ErrTrialExpired = 60 // VibeSonic never returns this: no license is required
|
||||
ErrNotFound = 70
|
||||
ErrGeneric = 0
|
||||
ErrMissingParameter = 10
|
||||
ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports
|
||||
ErrServerMustUpgrade = 30 // server requires a newer client
|
||||
ErrWrongCredentials = 40
|
||||
ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password
|
||||
ErrAuthMechanismUnsupported = 42 // provided authentication mechanism not supported
|
||||
ErrConflictingAuth = 43 // multiple conflicting authentication mechanisms provided
|
||||
ErrInvalidAPIKey = 44
|
||||
ErrUnauthorized = 50 // authenticated, but not permitted to perform this action
|
||||
ErrTrialExpired = 60 // VibeSonic never returns this: no license is required
|
||||
ErrNotFound = 70
|
||||
)
|
||||
|
||||
// apiError is a Subsonic protocol error carrying an HTTP-agnostic code
|
||||
@@ -21,6 +25,7 @@ const (
|
||||
type apiError struct {
|
||||
Code int
|
||||
Message string
|
||||
HelpURL string
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.Message }
|
||||
@@ -29,6 +34,10 @@ func newError(code int, message string) *apiError {
|
||||
return &apiError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
func newErrorWithHelp(code int, message, helpURL string) *apiError {
|
||||
return &apiError{Code: code, Message: message, HelpURL: helpURL}
|
||||
}
|
||||
|
||||
func errMissingParameter(name string) *apiError {
|
||||
return newError(ErrMissingParameter, "Required parameter '"+name+"' is missing")
|
||||
}
|
||||
@@ -41,8 +50,12 @@ func errUnauthorized(action string) *apiError {
|
||||
return newError(ErrUnauthorized, "User is not authorized for the requested "+action)
|
||||
}
|
||||
|
||||
const apiKeyHelpURL = "/settings#api-keys"
|
||||
|
||||
var (
|
||||
errWrongCredentials = newError(ErrWrongCredentials, "Wrong username or password")
|
||||
errClientMustUpgrade = newError(ErrClientMustUpgrade, "Incompatible Subsonic REST protocol version. Client must upgrade.")
|
||||
errTokenAuthNotSupported = newError(ErrTokenAuthNotSupported, "Token authentication is not supported for this account; use password authentication")
|
||||
errConflictingAuth = newErrorWithHelp(ErrConflictingAuth, "Provide either 'u' (with 'p' or 't'/'s') or 'apiKey', not both", apiKeyHelpURL)
|
||||
errInvalidAPIKey = newErrorWithHelp(ErrInvalidAPIKey, "Invalid API key", apiKeyHelpURL)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package subsonic
|
||||
|
||||
import "sort"
|
||||
|
||||
func init() {
|
||||
register("getOpenSubsonicExtensions", handleGetOpenSubsonicExtensions)
|
||||
}
|
||||
|
||||
// extensionRegistry lists every OpenSubsonic extension VibeSonic
|
||||
// actually implements and has tests for. Advertising an extension here
|
||||
// is a promise the corresponding endpoint(s) really work — never add an
|
||||
// entry just because a route exists. Each feature file registers its
|
||||
// own entry via registerExtension() in its init().
|
||||
var extensionRegistry = map[string][]int{}
|
||||
|
||||
func registerExtension(name string, versions ...int) {
|
||||
if _, exists := extensionRegistry[name]; exists {
|
||||
panic("subsonic: duplicate extension registration for " + name)
|
||||
}
|
||||
extensionRegistry[name] = versions
|
||||
}
|
||||
|
||||
func supportedExtensions() []OpenSubsonicExtension {
|
||||
names := make([]string, 0, len(extensionRegistry))
|
||||
for n := range extensionRegistry {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]OpenSubsonicExtension, 0, len(names))
|
||||
for _, n := range names {
|
||||
out = append(out, OpenSubsonicExtension{Name: n, Versions: extensionRegistry[n]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasExtension reports whether ext (any version) is advertised — used
|
||||
// where a feature is fully optional and we want to fail closed if it
|
||||
// were ever unregistered.
|
||||
func hasExtension(name string) bool {
|
||||
_, ok := extensionRegistry[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func handleGetOpenSubsonicExtensions(rc *Context) *apiError {
|
||||
rc.Response = newResponse()
|
||||
rc.Response.OpenSubsonicExtensions = supportedExtensions()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package subsonic
|
||||
|
||||
// The OpenSubsonic formPost extension requires the server accept API
|
||||
// arguments via POST with an application/x-www-form-urlencoded body, in
|
||||
// addition to the classic GET query-string form. This needs no
|
||||
// dedicated handler: the router's dispatch (router.go) already calls
|
||||
// r.ParseForm() before building params, and net/http's ParseForm merges
|
||||
// URL query values and a url-encoded POST body into r.Form for any HTTP
|
||||
// method — GET, POST, or otherwise — so every endpoint above already
|
||||
// accepts either form for free.
|
||||
//
|
||||
// Precedence when the same key appears in both the query string and the
|
||||
// POST body: net/http's ParseForm populates r.Form with POST-body values
|
||||
// before query-string values, so params.Get (url.Values.Get, which
|
||||
// returns the first entry) resolves a body value over a query value for
|
||||
// the same key. This is deterministic — governed entirely by net/http's
|
||||
// single parse of one request, not by any proxying/multi-parser
|
||||
// ambiguity — and is asserted directly rather than silently relied upon;
|
||||
// see TestFormPostBodyTakesPrecedenceOverQuery.
|
||||
func init() {
|
||||
registerExtension("formPost", 1)
|
||||
}
|
||||
@@ -28,6 +28,14 @@ type Response struct {
|
||||
Status string `xml:"status,attr" json:"status"`
|
||||
Version string `xml:"version,attr" json:"version"`
|
||||
|
||||
// OpenSubsonic core response additions (always populated): the
|
||||
// server's real product name and build version, and a flag
|
||||
// confirming OpenSubsonic support so clients can detect it from any
|
||||
// response, not just getOpenSubsonicExtensions.
|
||||
Type string `xml:"type,attr" json:"type"`
|
||||
ServerVersion string `xml:"serverVersion,attr" json:"serverVersion"`
|
||||
OpenSubsonic bool `xml:"openSubsonic,attr" json:"openSubsonic"`
|
||||
|
||||
Error *Error `xml:"error,omitempty" json:"error,omitempty"`
|
||||
|
||||
License *License `xml:"license,omitempty" json:"license,omitempty"`
|
||||
@@ -72,16 +80,57 @@ type Response struct {
|
||||
NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"`
|
||||
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
|
||||
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"`
|
||||
|
||||
// OpenSubsonic extensions.
|
||||
OpenSubsonicExtensions []OpenSubsonicExtension `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"`
|
||||
PodcastEpisode *PodcastEpisode `xml:"podcastEpisode,omitempty" json:"podcastEpisode,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
// SonicMatch is a top-level array (not wrapped in an object) on both
|
||||
// getSonicSimilarTracks and findSonicPath, per the OpenSubsonic
|
||||
// schema — the two endpoints are otherwise unrelated, so there is no
|
||||
// shared wrapper type to name it after.
|
||||
SonicMatch []SonicMatch `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"`
|
||||
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
|
||||
}
|
||||
|
||||
// OpenSubsonicExtension describes one extension this server supports,
|
||||
// as returned by getOpenSubsonicExtensions.
|
||||
type OpenSubsonicExtension struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Versions []int `xml:"versions" json:"versions"`
|
||||
}
|
||||
|
||||
type TokenInfo struct {
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
}
|
||||
|
||||
// ServerName is the real product name advertised in every response's
|
||||
// "type" attribute, per the OpenSubsonic base response additions.
|
||||
const ServerName = "vibesonic"
|
||||
|
||||
// AppVersion is VibeSonic's own build version, advertised in every
|
||||
// response's "serverVersion" attribute. Overridable at link time
|
||||
// (matching cmd/vibesonic's version variable); defaults to "dev".
|
||||
var AppVersion = "dev"
|
||||
|
||||
func newResponse() *Response {
|
||||
return &Response{Xmlns: "http://subsonic.org/restapi", Status: "ok", Version: ServerVersion}
|
||||
return &Response{
|
||||
Xmlns: "http://subsonic.org/restapi",
|
||||
Status: "ok",
|
||||
Version: ServerVersion,
|
||||
Type: ServerName,
|
||||
ServerVersion: AppVersion,
|
||||
OpenSubsonic: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Error is the payload of a failed response.
|
||||
type Error struct {
|
||||
Code int `xml:"code,attr" json:"code"`
|
||||
Message string `xml:"message,attr" json:"message"`
|
||||
HelpURL string `xml:"helpUrl,attr,omitempty" json:"helpUrl,omitempty"`
|
||||
}
|
||||
|
||||
// --- System ---
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package subsonic
|
||||
|
||||
// --- songLyrics extension (versions 1 and 2) ---
|
||||
|
||||
type LyricsList struct {
|
||||
StructuredLyrics []StructuredLyrics `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"`
|
||||
}
|
||||
|
||||
type StructuredLyrics struct {
|
||||
Lang string `xml:"lang,attr" json:"lang"`
|
||||
Synced bool `xml:"synced,attr" json:"synced"`
|
||||
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
|
||||
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
|
||||
Offset int `xml:"offset,attr,omitempty" json:"offset,omitempty"`
|
||||
Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` // main|translation|pronunciation; v2 only
|
||||
Line []Line `xml:"line" json:"line"`
|
||||
Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` // v2 only
|
||||
CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` // v2 only, synced entries only
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
Value string `xml:",chardata" json:"value"`
|
||||
Start *int `xml:"start,attr,omitempty" json:"start,omitempty"`
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID string `xml:"id,attr" json:"id"`
|
||||
Role string `xml:"role,attr" json:"role"` // main|voice|bg|group
|
||||
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type CueLine struct {
|
||||
Index int `xml:"index,attr" json:"index"`
|
||||
Start int `xml:"start,attr,omitempty" json:"start,omitempty"`
|
||||
End int `xml:"end,attr,omitempty" json:"end,omitempty"`
|
||||
Value string `xml:"value,attr" json:"value"`
|
||||
AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"`
|
||||
Cue []Cue `xml:"cue" json:"cue"`
|
||||
}
|
||||
|
||||
type Cue struct {
|
||||
Start int `xml:"start,attr" json:"start"`
|
||||
End *int `xml:"end,attr,omitempty" json:"end,omitempty"`
|
||||
ByteStart int `xml:"byteStart,attr" json:"byteStart"`
|
||||
ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"`
|
||||
Value string `xml:"value,attr,omitempty" json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// --- sonicSimilarity extension ---
|
||||
|
||||
type SonicMatch struct {
|
||||
Entry Child `xml:"entry" json:"entry"`
|
||||
Similarity float64 `xml:"similarity,attr" json:"similarity"`
|
||||
}
|
||||
|
||||
// --- transcoding extension ---
|
||||
|
||||
type ClientInfo struct {
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"`
|
||||
MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"`
|
||||
DirectPlayProfiles []DirectPlayProfile `json:"directPlayProfiles,omitempty"`
|
||||
TranscodingProfiles []TranscodingProfile `json:"transcodingProfiles,omitempty"`
|
||||
CodecProfiles []CodecProfile `json:"codecProfiles,omitempty"`
|
||||
}
|
||||
|
||||
type DirectPlayProfile struct {
|
||||
Containers []string `json:"containers"`
|
||||
AudioCodecs []string `json:"audioCodecs"`
|
||||
Protocols []string `json:"protocols"`
|
||||
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||
}
|
||||
|
||||
type TranscodingProfile struct {
|
||||
Container string `json:"container"`
|
||||
AudioCodec string `json:"audioCodec"`
|
||||
Protocol string `json:"protocol"`
|
||||
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||
}
|
||||
|
||||
type CodecProfile struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Limitations []Limitation `json:"limitations,omitempty"`
|
||||
}
|
||||
|
||||
type Limitation struct {
|
||||
Name string `json:"name"` // audioChannels|audioBitrate|audioProfile|audioSamplerate|audioBitdepth
|
||||
Comparison string `json:"comparison"` // Equals|NotEquals|LessThanEqual|GreaterThanEqual
|
||||
Values []string `json:"values"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type StreamDetails struct {
|
||||
Protocol string `xml:"protocol,attr" json:"protocol"`
|
||||
Container string `xml:"container,attr" json:"container"`
|
||||
Codec string `xml:"codec,attr" json:"codec"`
|
||||
AudioChannels int `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"`
|
||||
AudioBitrate int `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"`
|
||||
AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"`
|
||||
AudioSamplerate int `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"`
|
||||
AudioBitdepth int `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"`
|
||||
}
|
||||
|
||||
type TranscodeDecision struct {
|
||||
CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"`
|
||||
CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"`
|
||||
TranscodeReason []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"`
|
||||
ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"`
|
||||
TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"`
|
||||
SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"`
|
||||
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
|
||||
}
|
||||
|
||||
// --- index-based queue extension ---
|
||||
|
||||
type PlayQueueByIndex struct {
|
||||
CurrentIndex int `xml:"currentIndex,attr" json:"currentIndex"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed string `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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",
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,6 @@ func writeResponse(w http.ResponseWriter, format, callback string, resp *Respons
|
||||
func errorResponse(err *apiError) *Response {
|
||||
r := newResponse()
|
||||
r.Status = "failed"
|
||||
r.Error = &Error{Code: err.Code, Message: err.Message}
|
||||
r.Error = &Error{Code: err.Code, Message: err.Message, HelpURL: err.HelpURL}
|
||||
return r
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user