test(api): share API v1 test helpers and check scopes in Go

Move the end-to-end call/setup/mint helpers to a suite-level test client
and the apiauth login/mustMint helpers to package level. Replace the
hardcoded vacuum x-scope enum with a Go test that every known scope is
a valid spec Scope; the gate already rejects unknown x-scope values.
This commit is contained in:
Deluan 2026-09-26 02:41:23 -04:00
commit 45d8a7724d
8 changed files with 172 additions and 178 deletions

View file

@ -44,14 +44,6 @@ rules:
then:
field: security
function: defined
nd-operation-x-scope:
description: An operation's x-scope is a known scope. Cross-checks with x-module and security run in Go (server/apiv1 newGate).
severity: error
given: $.paths[*][get,put,post,delete,patch]['x-scope']
then:
function: enumeration
functionOptions:
values: [read, password]
nd-operation-stability-level-required:
description: Every operation declares its stability level, which the breaking-change gate relies on.
severity: error

View file

@ -32,3 +32,20 @@ func createUser(ctx context.Context, password string, admin bool) model.User {
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return *stored
}
// login runs the full client flow for a user whose password is "pw": grant, resolve, then mint.
func login(ctx context.Context, svc *Service, u model.User) (*Issued, *Principal, *AccessToken) {
issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
p, err := svc.ResolveGrant(ctx, issued.Secret, "")
ExpectWithOffset(1, err).ToNot(HaveOccurred())
tok, err := svc.Mint(ctx, p, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return issued, p, tok
}
func mustMint(ctx context.Context, svc *Service, p *Principal) string {
tok, err := svc.Mint(ctx, p, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return tok.Token
}

View file

@ -18,16 +18,6 @@ var _ = Describe("Service: sessions", func() {
var svc *Service
var now time.Time
login := func(u model.User) (*Issued, *Principal, *AccessToken) {
issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
p, err := svc.ResolveGrant(ctx, issued.Secret, "")
ExpectWithOffset(1, err).ToNot(HaveOccurred())
tok, err := svc.Mint(ctx, p, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return issued, p, tok
}
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
@ -39,7 +29,7 @@ var _ = Describe("Service: sessions", func() {
Describe("Authenticate", func() {
It("returns the token's scopes and marks the grant used", func() {
u := createUser(ctx, "pw", false)
issued, _, tok := login(u)
issued, _, tok := login(ctx, svc, u)
now = now.Add(10 * time.Minute)
p, err := svc.Authenticate(ctx, tok.Token, "10.1.1.1")
Expect(err).ToNot(HaveOccurred())
@ -51,7 +41,7 @@ var _ = Describe("Service: sessions", func() {
It("reports an expired token as ErrTokenExpired", func() {
u := createUser(ctx, "pw", false)
_, _, tok := login(u)
_, _, tok := login(ctx, svc, u)
now = now.Add(TokenTTL + clockSkew + time.Second)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).To(MatchError(ErrTokenExpired))
@ -59,7 +49,7 @@ var _ = Describe("Service: sessions", func() {
It("rejects a token at once on the node that revoked its grant", func() {
u := createUser(ctx, "pw", false)
_, p, tok := login(u)
_, p, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).ToNot(HaveOccurred())
Expect(svc.Logout(ctx, p)).To(Succeed())
@ -69,7 +59,7 @@ var _ = Describe("Service: sessions", func() {
It("stops a token revoked on another node within the cache time", func() {
u := createUser(ctx, "pw", false)
issued, _, tok := login(u)
issued, _, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).ToNot(HaveOccurred())
@ -83,7 +73,7 @@ var _ = Describe("Service: sessions", func() {
It("kills grants when the password changes anywhere else", func() {
u := createUser(ctx, "pw", false)
_, _, tok := login(u)
_, _, tok := login(ctx, svc, u)
u.NewPassword = "reset-by-admin"
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
_, err := svc.Authenticate(ctx, tok.Token, "")
@ -92,7 +82,7 @@ var _ = Describe("Service: sessions", func() {
It("does not kill a grant kept by a password change made through another node", func() {
u := createUser(ctx, "pw", false)
_, p, tok := login(u)
_, p, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "") // caches the old epoch
Expect(err).ToNot(HaveOccurred())
@ -109,7 +99,7 @@ var _ = Describe("Service: sessions", func() {
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin}
DeferCleanup(func() { KnownScopes = saved })
u := createUser(ctx, "pw", true)
_, p, tok := login(u)
_, p, tok := login(ctx, svc, u)
Expect(tok.Scopes).To(ContainElement(ScopeAdmin))
u.IsAdmin = false
@ -124,7 +114,7 @@ var _ = Describe("Service: sessions", func() {
It("rejects a live token after its user is deleted, and the grant row is gone", func() {
u := createUser(ctx, "pw", false)
issued, _, tok := login(u)
issued, _, tok := login(ctx, svc, u)
Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed())
_, err := realDS.Grant().Get(ctx, issued.Grant.ID)
Expect(err).To(MatchError(model.ErrNotFound))
@ -135,7 +125,7 @@ var _ = Describe("Service: sessions", func() {
It("grants no scopes to a signed token claiming all", func() {
u := createUser(ctx, "pw", true)
_, p, _ := login(u)
_, p, _ := login(ctx, svc, u)
sg, err := svc.signer()
Expect(err).ToNot(HaveOccurred())
tok, err := sg.sign(claims{UserID: u.ID, GrantID: p.GrantID, Scopes: []string{ScopeAll, "unknown"}, IssuedAt: now, ExpiresAt: now.Add(TokenTTL)})
@ -148,7 +138,7 @@ var _ = Describe("Service: sessions", func() {
It("rejects a token whose grant belongs to another user, even across an epoch change", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
bobGrant, _, _ := login(bob)
bobGrant, _, _ := login(ctx, svc, bob)
alice.NewPassword = "bumped"
Expect(realDS.User().Put(ctx, &alice)).To(Succeed())
@ -162,7 +152,7 @@ var _ = Describe("Service: sessions", func() {
It("does not delete a kept grant when the user was read before a password change", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(u)
_, p, _ := login(ctx, svc, u)
stale, err := realDS.User().Get(ctx, u.ID) // read before the change lands
Expect(err).ToNot(HaveOccurred())
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
@ -202,8 +192,8 @@ var _ = Describe("Service: sessions", func() {
Describe("grant management", func() {
It("lists the user's grants and marks the current one", func() {
u := createUser(ctx, "pw", false)
first, _, _ := login(u)
_, p, _ := login(u)
first, _, _ := login(ctx, svc, u)
_, p, _ := login(ctx, svc, u)
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
Expect(err).ToNot(HaveOccurred())
Expect(total).To(Equal(int64(2)))
@ -213,7 +203,7 @@ var _ = Describe("Service: sessions", func() {
It("lists only grants on the user's current epoch", func() {
u := createUser(ctx, "pw", false)
login(u)
login(ctx, svc, u)
u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
issued, err := svc.Login(ctx, u.UserName, "reset-by-admin", meta, nil)
@ -230,7 +220,7 @@ var _ = Describe("Service: sessions", func() {
It("logs out successfully when the grant is already gone", func() {
u := createUser(ctx, "pw", false)
_, p, tok := login(u)
_, p, tok := login(ctx, svc, u)
_, err := svc.Authenticate(ctx, tok.Token, "")
Expect(err).ToNot(HaveOccurred())
Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed()) // another node
@ -243,8 +233,8 @@ var _ = Describe("Service: sessions", func() {
It("refuses to revoke another user's grant", func() {
alice := createUser(ctx, "pw", false)
bob := createUser(ctx, "pw", false)
aliceGrant, _, _ := login(alice)
_, bobP, _ := login(bob)
aliceGrant, _, _ := login(ctx, svc, alice)
_, bobP, _ := login(ctx, svc, bob)
Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound))
})
})
@ -252,8 +242,8 @@ var _ = Describe("Service: sessions", func() {
Describe("ChangePassword", func() {
It("revokes other grants by default and keeps the caller's", func() {
u := createUser(ctx, "pw", false)
_, _, otherTok := login(u)
_, p, myTok := login(u)
_, _, otherTok := login(ctx, svc, u)
_, p, myTok := login(ctx, svc, u)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed())
_, err := svc.Authenticate(ctx, myTok.Token, "")
@ -268,8 +258,8 @@ var _ = Describe("Service: sessions", func() {
It("keeps every grant when revokeOthers is false", func() {
u := createUser(ctx, "pw", false)
_, _, otherTok := login(u)
_, p, _ := login(u)
_, _, otherTok := login(ctx, svc, u)
_, p, _ := login(ctx, svc, u)
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
now = now.Add(cacheTTL)
_, err := svc.Authenticate(ctx, otherTok.Token, "")
@ -278,7 +268,7 @@ var _ = Describe("Service: sessions", func() {
It("rejects a wrong current password without changing anything", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(u)
_, p, _ := login(ctx, svc, u)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true)
Expect(err).To(MatchError(ErrCurrentPasswordMismatch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
@ -288,14 +278,14 @@ var _ = Describe("Service: sessions", func() {
It("is forbidden for non-admins when user editing is off", func() {
conf.Server.EnableUserEditing = false
u := createUser(ctx, "pw", false)
_, p, _ := login(u)
_, p, _ := login(ctx, svc, u)
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("does not revive grants killed by an earlier reset when keeping grants", func() {
u := createUser(ctx, "pw", false)
killed, _, _ := login(u)
killed, _, _ := login(ctx, svc, u)
u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
@ -311,7 +301,7 @@ var _ = Describe("Service: sessions", func() {
It("rejects a caller whose grant was revoked before the change ran", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(u)
_, p, _ := login(ctx, svc, u)
Expect(realDS.Grant().Delete(ctx, p.GrantID)).To(Succeed())
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
Expect(err).To(MatchError(model.ErrInvalidAuth))
@ -321,7 +311,7 @@ var _ = Describe("Service: sessions", func() {
It("rolls back the password and epoch when a grant update fails", func() {
u := createUser(ctx, "pw", false)
_, p, _ := login(u)
_, p, _ := login(ctx, svc, u)
failing := New(failingEpochDS{realDS})
failing.SetClock(func() time.Time { return now })
@ -332,7 +322,7 @@ var _ = Describe("Service: sessions", func() {
Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch))
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
Expect(err).ToNot(HaveOccurred())
_, err = svc.Authenticate(ctx, mustMint(svc, ctx, p), "")
_, err = svc.Authenticate(ctx, mustMint(ctx, svc, p), "")
Expect(err).ToNot(HaveOccurred())
})
})
@ -368,9 +358,3 @@ type failingGrants struct{ model.GrantRepository }
func (failingGrants) SetEpoch(context.Context, string, int, int, string) error {
return errors.New("boom")
}
func mustMint(svc *Service, ctx context.Context, p *Principal) string {
tok, err := svc.Mint(ctx, p, nil)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
return tok.Token
}

View file

@ -111,15 +111,9 @@ var _ = Describe("Service: grants and tokens", func() {
Describe("ResolveGrant and Mint", func() {
It("mints a token with the grant's expanded scopes and a 1h lifetime", func() {
u := createUser(ctx, "pw", false)
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
p, err := svc.ResolveGrant(ctx, issued.Secret, "10.0.0.9")
Expect(err).ToNot(HaveOccurred())
issued, p, tok := login(ctx, svc, u)
Expect(p.GrantID).To(Equal(issued.Grant.ID))
Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
tok, err := svc.Mint(ctx, p, nil)
Expect(err).ToNot(HaveOccurred())
Expect(tok.ExpiresIn).To(Equal(time.Hour))
Expect(tok.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))

View file

@ -2,6 +2,8 @@ package apiv1
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
@ -57,6 +59,51 @@ func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
return w
}
// testClient drives a router end to end through serve, so every response is also checked against the spec.
type testClient struct {
ctx context.Context
router http.Handler
}
func (c testClient) call(method, path, bearer string, body any) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
b, _ := json.Marshal(body)
req = httptest.NewRequestWithContext(c.ctx, method, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequestWithContext(c.ctx, method, path, nil)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return serve(c.router, req)
}
func (c testClient) setup() GrantCreated {
w := c.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String())
var gc GrantCreated
decodeJSON(w, &gc)
return gc
}
func (c testClient) mint(secret string, body any) AccessToken {
w := c.call(http.MethodPost, "/api/v1/auth/token", secret, body)
ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String())
var at AccessToken
decodeJSON(w, &at)
return at
}
func creds(user, pw string) map[string]any {
return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"}
}
func decodeJSON(w *httptest.ResponseRecorder, v any) {
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String())
}
func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) {
route, pathParams, err := specRouter.FindRoute(req)
if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) {

View file

@ -1,9 +1,7 @@
package apiv1
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@ -19,53 +17,14 @@ import (
var _ = Describe("auth endpoints", func() {
var ctx context.Context
var router *Router
call := func(method, path, bearer string, body any) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
b, _ := json.Marshal(body)
req = httptest.NewRequestWithContext(ctx, method, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequestWithContext(ctx, method, path, nil)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
return serve(router, req)
}
creds := func(user, pw string) map[string]any {
return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"}
}
decode := func(w *httptest.ResponseRecorder, v any) {
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String())
}
setup := func() GrantCreated {
w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String())
var gc GrantCreated
decode(w, &gc)
return gc
}
mint := func(secret string, body any) AccessToken {
w := call(http.MethodPost, "/api/v1/auth/token", secret, body)
ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String())
var at AccessToken
decode(w, &at)
return at
}
var api testClient
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 0
resetDB()
router = New(realDS)
api = testClient{ctx: ctx, router: New(realDS)}
})
It("lets exactly one of a v1 setup and a v0 first-admin creation win", func() {
@ -76,7 +35,7 @@ var _ = Describe("auth endpoints", func() {
go func() {
defer GinkgoRecover()
defer wg.Done()
v1Code = call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code
v1Code = api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code
}()
go func() {
defer GinkgoRecover()
@ -90,149 +49,149 @@ var _ = Describe("auth endpoints", func() {
})
It("sets up the first admin once, then answers 409 setup_complete", func() {
gc := setup()
gc := api.setup()
Expect(gc.Secret).To(HavePrefix("ndg_"))
Expect(gc.User.IsAdmin).To(BeTrue())
Expect(gc.Grant.Provider).To(Equal("setup"))
Expect(gc.Grant.Current).To(BeTrue())
w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw"))
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw"))
Expect(w.Code).To(Equal(http.StatusConflict))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete))
})
It("logs in, mints a token, and uses it on a scoped endpoint", func() {
setup()
w := call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw"))
api.setup()
w := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw"))
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
var gc GrantCreated
decode(w, &gc)
decodeJSON(w, &gc)
Expect(gc.User.PasswordChangeable).To(BeTrue())
at := mint(gc.Secret, nil)
at := api.mint(gc.Secret, nil)
Expect(at.TokenType).To(Equal(AccessTokenTokenTypeBearer))
Expect(at.ExpiresIn).To(Equal(3600))
w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil)
w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
var list GrantList
decode(w, &list)
decodeJSON(w, &list)
Expect(list.Total).To(Equal(2))
Expect(list.Limit).To(Equal(100))
})
It("fails login the same way for an unknown user and a wrong password, with a Bearer challenge", func() {
setup()
a := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong"))
b := call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw"))
api.setup()
a := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong"))
b := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw"))
Expect(a.Code).To(Equal(http.StatusUnauthorized))
Expect(a.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
Expect(a.Body.String()).To(Equal(b.Body.String()))
})
It("treats no body and {} as all scopes, and [] as no scopes", func() {
gc := setup()
all := mint(gc.Secret, nil)
gc := api.setup()
all := api.mint(gc.Secret, nil)
Expect(all.Scopes).To(ConsistOf(ScopeRead, ScopePassword))
Expect(mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword))
Expect(api.mint(gc.Secret, map[string]any{}).Scopes).To(ConsistOf(ScopeRead, ScopePassword))
none := mint(gc.Secret, map[string]any{"scopes": []string{}})
none := api.mint(gc.Secret, map[string]any{"scopes": []string{}})
Expect(none.Scopes).To(BeEmpty())
w := call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil)
w := api.call(http.MethodGet, "/api/v1/auth/grants", none.AccessToken, nil)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
})
It("drops unknown requested scopes instead of rejecting them", func() {
gc := setup()
at := mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}})
gc := api.setup()
at := api.mint(gc.Secret, map[string]any{"scopes": []string{"read", "playlists:write"}})
Expect(at.Scopes).To(ConsistOf(ScopeRead))
})
It("does not let a token without read log out or revoke grants", func() {
gc := setup()
narrow := mint(gc.Secret, map[string]any{"scopes": []string{"password"}})
Expect(call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden))
Expect(call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden))
gc := api.setup()
narrow := api.mint(gc.Secret, map[string]any{"scopes": []string{"password"}})
Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden))
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.AccessToken, nil).Code).To(Equal(http.StatusForbidden))
})
It("logs out: the token stops at once and logoutUrl is null", func() {
gc := setup()
at := mint(gc.Secret, nil)
w := call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil)
gc := api.setup()
at := api.mint(gc.Secret, nil)
w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`))
w = call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil)
w = api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
Expect(call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
Expect(api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
})
It("logs out with 200 when another node already revoked the grant", func() {
gc := setup()
at := mint(gc.Secret, nil)
Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant
gc := api.setup()
at := api.mint(gc.Secret, nil)
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant
Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed())
w := call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil)
w := api.call(http.MethodPost, "/api/v1/auth/logout", at.AccessToken, nil)
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`))
})
It("challenges with invalid_token when the grant is revoked while a password change runs", func() {
gc := setup()
at := mint(gc.Secret, nil)
Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant
gc := api.setup()
at := api.mint(gc.Secret, nil)
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK)) // caches the grant
Expect(realDS.Grant().Delete(ctx, gc.Grant.Id)).To(Succeed())
w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String())
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
})
It("marks grant and token responses no-store", func() {
w := call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
Expect(w.Code).To(Equal(http.StatusCreated))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
var gc GrantCreated
decode(w, &gc)
decodeJSON(w, &gc)
w = call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw"))
w = api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw"))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
w = call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil)
w = api.call(http.MethodPost, "/api/v1/auth/token", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
})
It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() {
gc := setup()
tok := mint(gc.Secret, nil).AccessToken
Expect(call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound))
w := call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, nil)
gc := api.setup()
tok := api.mint(gc.Secret, nil).AccessToken
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", tok, nil).Code).To(Equal(http.StatusNotFound))
w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), tok, nil)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
})
It("changes the password, keeping the caller and revoking the rest", func() {
gc := setup()
otherLogin := call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw"))
gc := api.setup()
otherLogin := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw"))
var other GrantCreated
decode(otherLogin, &other)
at := mint(gc.Secret, nil)
decodeJSON(otherLogin, &other)
at := api.mint(gc.Secret, nil)
w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String())
Expect(call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK))
Expect(call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", at.AccessToken, nil).Code).To(Equal(http.StatusOK))
Expect(api.call(http.MethodPost, "/api/v1/auth/token", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
})
It("reports a wrong current password as a field error", func() {
gc := setup()
at := mint(gc.Secret, nil)
w := call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "nope", "newPassword": "pw2"})
gc := api.setup()
at := api.mint(gc.Secret, nil)
w := api.call(http.MethodPost, "/api/v1/auth/password", at.AccessToken, map[string]any{"currentPassword": "nope", "newPassword": "pw2"})
Expect(w.Code).To(Equal(http.StatusBadRequest))
p := decodeProblem(w)
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
@ -240,7 +199,7 @@ var _ = Describe("auth endpoints", func() {
DescribeTable("rejects bad credential bodies with a field error and no echo",
func(body map[string]any, field string) {
w := call(http.MethodPost, "/api/v1/auth/setup", "", body)
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", body)
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
p := decodeProblem(w)
Expect(p.Code).To(Equal(ProblemCodeValidation))
@ -258,7 +217,7 @@ var _ = Describe("auth endpoints", func() {
big := `{"username":"a","password":"` + strings.Repeat("a", maxBodyBytes) + `","client":"c"}`
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", body(big))
req.Header.Set("Content-Type", "application/json")
w := serve(router, req)
w := serve(api.router, req)
Expect(w.Code).To(Equal(http.StatusRequestEntityTooLarge))
Expect(decodeProblem(w).Code).To(Equal(ProblemCodePayloadTooLarge))
},

View file

@ -1,11 +1,8 @@
package apiv1
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
@ -14,39 +11,28 @@ import (
var _ = Describe("GET /capabilities", func() {
var ctx context.Context
var router *Router
var api testClient
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
resetDB()
router = New(realDS)
api = testClient{ctx: ctx, router: New(realDS)}
})
It("needs a token", func() {
w := serve(router, httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil))
w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("lists core and password for any valid token, even one with no scopes", func() {
body, _ := json.Marshal(map[string]any{"username": "admin", "password": "pw", "client": "c"})
setupReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
setupReq.Header.Set("Content-Type", "application/json")
var gc GrantCreated
Expect(json.Unmarshal(serve(router, setupReq).Body.Bytes(), &gc)).To(Succeed())
gc := api.setup()
at := api.mint(gc.Secret, map[string]any{"scopes": []string{}})
tokReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", bytes.NewReader([]byte(`{"scopes":[]}`)))
tokReq.Header.Set("Content-Type", "application/json")
tokReq.Header.Set("Authorization", "Bearer "+gc.Secret)
var at AccessToken
Expect(json.Unmarshal(serve(router, tokReq).Body.Bytes(), &at)).To(Succeed())
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil)
req.Header.Set("Authorization", "Bearer "+at.AccessToken)
w := serve(router, req)
w := api.call(http.MethodGet, "/api/v1/capabilities", at.AccessToken, nil)
Expect(w.Code).To(Equal(http.StatusOK))
var caps Capabilities
Expect(json.Unmarshal(w.Body.Bytes(), &caps)).To(Succeed())
decodeJSON(w, &caps)
Expect(caps.Core.Version).To(Equal(1))
Expect(caps.Password.Version).To(Equal(1))
})

15
server/apiv1/dto_test.go Normal file
View file

@ -0,0 +1,15 @@
package apiv1
import (
"github.com/navidrome/navidrome/core/apiauth"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("toScopes", func() {
It("only produces scopes the spec's Scope enum allows", func() {
for _, s := range toScopes(append([]string{apiauth.ScopeAll}, apiauth.KnownScopes...)) {
Expect(s.Valid()).To(BeTrue(), "scope %q is missing from the spec's Scope enum", s)
}
})
})