From 45d8a7724d07ab425646c9be7bbe217be96b7dc7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 26 Sep 2026 02:41:23 -0400 Subject: [PATCH] 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. --- api/.vacuum.yaml | 8 -- core/apiauth/db_test.go | 17 +++ core/apiauth/service_session_test.go | 70 +++++------- core/apiauth/service_test.go | 8 +- server/apiv1/apiv1_suite_test.go | 47 ++++++++ server/apiv1/auth_test.go | 157 ++++++++++----------------- server/apiv1/capabilities_test.go | 28 ++--- server/apiv1/dto_test.go | 15 +++ 8 files changed, 172 insertions(+), 178 deletions(-) create mode 100644 server/apiv1/dto_test.go diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml index f658b6c01..261502224 100644 --- a/api/.vacuum.yaml +++ b/api/.vacuum.yaml @@ -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 diff --git a/core/apiauth/db_test.go b/core/apiauth/db_test.go index 69de6cd62..6fdd20459 100644 --- a/core/apiauth/db_test.go +++ b/core/apiauth/db_test.go @@ -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 +} diff --git a/core/apiauth/service_session_test.go b/core/apiauth/service_session_test.go index b871f279b..1a83d2ee4 100644 --- a/core/apiauth/service_session_test.go +++ b/core/apiauth/service_session_test.go @@ -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 -} diff --git a/core/apiauth/service_test.go b/core/apiauth/service_test.go index fb51ac6a2..ce6622914 100644 --- a/core/apiauth/service_test.go +++ b/core/apiauth/service_test.go @@ -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})) diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go index 6875b2330..69416c924 100644 --- a/server/apiv1/apiv1_suite_test.go +++ b/server/apiv1/apiv1_suite_test.go @@ -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) { diff --git a/server/apiv1/auth_test.go b/server/apiv1/auth_test.go index f1b3e7c0c..884865d16 100644 --- a/server/apiv1/auth_test.go +++ b/server/apiv1/auth_test.go @@ -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)) }, diff --git a/server/apiv1/capabilities_test.go b/server/apiv1/capabilities_test.go index ac1ec1b69..456d09bd5 100644 --- a/server/apiv1/capabilities_test.go +++ b/server/apiv1/capabilities_test.go @@ -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)) }) diff --git a/server/apiv1/dto_test.go b/server/apiv1/dto_test.go new file mode 100644 index 000000000..49f10f067 --- /dev/null +++ b/server/apiv1/dto_test.go @@ -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) + } + }) +})