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) } }) }