@@ -35,7 +35,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
3535 want : meResponse {
3636 ID : "ops-readonly" ,
3737 Scopes : []string {"read" },
38- Capabilities : []string {"cache.read" },
38+ Capabilities : []string {capabilityCacheRead },
3939 },
4040 },
4141 {
@@ -47,7 +47,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
4747 want : meResponse {
4848 ID : "ops-rw" ,
4949 Scopes : []string {"read" , "write" },
50- Capabilities : []string {"cache.read" , "cache.write" },
50+ Capabilities : []string {capabilityCacheRead , capabilityCacheWrite },
5151 },
5252 },
5353 {
@@ -59,7 +59,7 @@ func TestHandleMe_BodyShape(t *testing.T) {
5959 want : meResponse {
6060 ID : "anonymous" ,
6161 Scopes : []string {"read" , "write" , "admin" },
62- Capabilities : []string {"cache.read" , "cache.write" , "cache.admin" },
62+ Capabilities : []string {capabilityCacheRead , capabilityCacheWrite , capabilityCacheAdmin },
6363 },
6464 },
6565 }
@@ -172,3 +172,144 @@ func TestHandleMe_MissingLocals(t *testing.T) {
172172 t .Fatalf ("status: got %d, want 500" , resp .StatusCode )
173173 }
174174}
175+
176+ // TestHandleCan_AllowedAndDenied pins the canonical happy paths:
177+ // an identity holding a capability gets allowed=true; the same
178+ // identity probed against a capability it lacks gets allowed=false.
179+ // Both produce 200 — "allowed=false" is a successful probe, not
180+ // an authorization failure.
181+ func TestHandleCan_AllowedAndDenied (t * testing.T ) {
182+ t .Parallel ()
183+
184+ readWriteIdentity := httpauth.Identity {
185+ ID : "ops-rw" ,
186+ Scopes : []httpauth.Scope {httpauth .ScopeRead , httpauth .ScopeWrite },
187+ }
188+
189+ tests := []struct {
190+ name string
191+ capability string
192+ wantAllowed bool
193+ }{
194+ {"read holder asks for read" , capabilityCacheRead , true },
195+ {"rw holder asks for write" , capabilityCacheWrite , true },
196+ {"rw holder asks for admin" , capabilityCacheAdmin , false },
197+ }
198+
199+ for _ , tc := range tests {
200+ t .Run (tc .name , func (t * testing.T ) {
201+ t .Parallel ()
202+
203+ body := callCanWithIdentity (t , readWriteIdentity , tc .capability )
204+ if body .Capability != tc .capability {
205+ t .Errorf ("capability echo: got %q, want %q" , body .Capability , tc .capability )
206+ }
207+
208+ if body .Allowed != tc .wantAllowed {
209+ t .Errorf ("allowed: got %v, want %v" , body .Allowed , tc .wantAllowed )
210+ }
211+ })
212+ }
213+ }
214+
215+ // TestHandleCan_MissingCapabilityParam pins the input-validation
216+ // posture: a request without the `capability` query param fails
217+ // 400 with the canonical error envelope. We don't silently
218+ // default to allowed=false — that would let typos pass as
219+ // "you can't do it" when the real issue is the missing argument.
220+ func TestHandleCan_MissingCapabilityParam (t * testing.T ) {
221+ t .Parallel ()
222+
223+ app := fiber .New ()
224+ app .Use (func (c fiber.Ctx ) error {
225+ c .Locals (httpauth .IdentityKey , httpauth.Identity {ID : "x" , Scopes : []httpauth.Scope {httpauth .ScopeRead }})
226+
227+ return c .Next ()
228+ })
229+ app .Get ("/v1/me/can" , handleCan )
230+
231+ req := httptest .NewRequestWithContext (t .Context (), http .MethodGet , "/v1/me/can" , strings .NewReader ("" ))
232+
233+ resp , err := app .Test (req )
234+ if err != nil {
235+ t .Fatalf ("app.Test: %v" , err )
236+ }
237+
238+ defer func () { _ = resp .Body .Close () }()
239+
240+ if resp .StatusCode != http .StatusBadRequest {
241+ t .Fatalf ("status: got %d, want 400" , resp .StatusCode )
242+ }
243+ }
244+
245+ // TestHandleCan_UnknownCapability pins that unrecognized
246+ // capability strings fail 400, not silently allowed=false. A
247+ // typo like `cache.reaad` should surface as a client error so
248+ // the caller fixes their code rather than shipping a broken
249+ // authz check.
250+ func TestHandleCan_UnknownCapability (t * testing.T ) {
251+ t .Parallel ()
252+
253+ app := fiber .New ()
254+ app .Use (func (c fiber.Ctx ) error {
255+ c .Locals (httpauth .IdentityKey , httpauth.Identity {
256+ ID : "x" ,
257+ Scopes : []httpauth.Scope {httpauth .ScopeRead , httpauth .ScopeWrite , httpauth .ScopeAdmin },
258+ })
259+
260+ return c .Next ()
261+ })
262+ app .Get ("/v1/me/can" , handleCan )
263+
264+ req := httptest .NewRequestWithContext (t .Context (), http .MethodGet ,
265+ "/v1/me/can?capability=cache.reaad" , strings .NewReader ("" ))
266+
267+ resp , err := app .Test (req )
268+ if err != nil {
269+ t .Fatalf ("app.Test: %v" , err )
270+ }
271+
272+ defer func () { _ = resp .Body .Close () }()
273+
274+ if resp .StatusCode != http .StatusBadRequest {
275+ t .Fatalf ("status: got %d, want 400 (unknown capability)" , resp .StatusCode )
276+ }
277+ }
278+
279+ // callCanWithIdentity drives /v1/me/can with a pre-populated
280+ // IdentityKey local. Returns the decoded canResponse; failed
281+ // status / decode trips the test fatally.
282+ func callCanWithIdentity (t * testing.T , identity httpauth.Identity , capability string ) canResponse {
283+ t .Helper ()
284+
285+ app := fiber .New ()
286+ app .Use (func (c fiber.Ctx ) error {
287+ c .Locals (httpauth .IdentityKey , identity )
288+
289+ return c .Next ()
290+ })
291+ app .Get ("/v1/me/can" , handleCan )
292+
293+ req := httptest .NewRequestWithContext (t .Context (), http .MethodGet ,
294+ "/v1/me/can?capability=" + capability , strings .NewReader ("" ))
295+
296+ resp , err := app .Test (req )
297+ if err != nil {
298+ t .Fatalf ("app.Test: %v" , err )
299+ }
300+
301+ defer func () { _ = resp .Body .Close () }()
302+
303+ if resp .StatusCode != http .StatusOK {
304+ t .Fatalf ("status: got %d, want 200" , resp .StatusCode )
305+ }
306+
307+ var got canResponse
308+
309+ err = json .NewDecoder (resp .Body ).Decode (& got )
310+ if err != nil {
311+ t .Fatalf ("decode body: %v" , err )
312+ }
313+
314+ return got
315+ }
0 commit comments