diff --git a/middleware/api_token_test.go b/middleware/api_token_test.go new file mode 100644 index 0000000..1eb4617 --- /dev/null +++ b/middleware/api_token_test.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +// forbiddenBody is the 403 body of Scope and Permission. +type forbiddenBody struct { + Error bool `json:"error"` + Message string `json:"message"` + MissingScope string `json:"missing_scope"` + MissingPermission string `json:"missing_permission"` +} + +// tokenWith is an in-memory credential holding scopes, built the way +// MCPDispatch builds it. +func tokenWith(scopes ...string) *models.APIToken { + return &models.APIToken{Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}} +} + +// serve runs mw over a handler answering 200, with ctx on the request. +func serve(t *testing.T, mw func(http.Handler) http.Handler, ctx context.Context) (int, forbiddenBody) { + t.Helper() + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + mw(next).ServeHTTP(rec, req) + var body forbiddenBody + if rec.Code == http.StatusForbidden { + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("403 body %q: %v", rec.Body.String(), err) + } + } + return rec.Code, body +} + +// TestForbiddenBodiesNameWhatIsMissing (D-15, D-20): a refusal by Scope or +// Permission keeps error true and the message, and adds missing_scope or +// missing_permission naming what is missing. No database. +func TestForbiddenBodiesNameWhatIsMissing(t *testing.T) { + scopeRefused := forbiddenBody{Error: true, Message: "Missing required scope: write", MissingScope: "write"} + + t.Run("scope missing from the credential", func(t *testing.T) { + ctx := bouncer.WithCredential(context.Background(), tokenWith("read")) + code, body := serve(t, Scope("write"), ctx) + if code != http.StatusForbidden || body != scopeRefused { + t.Fatalf("Scope(write) with read = %d %+v, want 403 %+v", code, body, scopeRefused) + } + }) + + t.Run("no credential", func(t *testing.T) { + code, body := serve(t, Scope("write"), context.Background()) + if code != http.StatusForbidden || body != scopeRefused { + t.Fatalf("Scope(write) without a credential = %d %+v, want 403 %+v", code, body, scopeRefused) + } + }) + + t.Run("scope held", func(t *testing.T) { + ctx := bouncer.WithCredential(context.Background(), tokenWith("read")) + if code, _ := serve(t, Scope("read"), ctx); code != http.StatusOK { + t.Fatalf("Scope(read) with read = %d, want 200", code) + } + }) + + t.Run("permission missing", func(t *testing.T) { + ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_schedule": true}}) + want := forbiddenBody{Error: true, Message: "Forbidden.", MissingPermission: "jz.bm.access_courses"} + code, body := serve(t, Permission("jz.bm.access_courses"), ctx) + if code != http.StatusForbidden || body != want { + t.Fatalf("Permission without the grant = %d %+v, want 403 %+v", code, body, want) + } + }) + + t.Run("permission granted", func(t *testing.T) { + ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_courses": true}}) + if code, _ := serve(t, Permission("jz.bm.access_courses"), ctx); code != http.StatusOK { + t.Fatalf("Permission with the grant = %d, want 200", code) + } + }) +}