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:
2026-08-23 08:04:01 -05:00
parent 3b4883780b
commit e38d31d384
17 changed files with 763 additions and 37 deletions
+15
View File
@@ -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
}
+34 -3
View File
@@ -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
View File
@@ -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)
)
+48
View File
@@ -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
}
+22
View File
@@ -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)
}
+50 -1
View File
@@ -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 ---
+125
View File
@@ -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"`
}
+273
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}