package user import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" ) // TestUserMCPGuard (D-15, D-16, D-35) extends TestUserMCPGuardSmoke: the // requirement parser refuses a user.* middleware it does not know and a // scope or permission middleware without its name; a soft-deleted user is // neither allowed nor dispatched and its personal tokens stop resolving, as // do expired ones; and the in-memory credential MCPDispatch builds passes // the route's own user.scope:read middleware when the subject was granted // read and is refused (403) when it was not, through the plugin's real // route chain in-process. Skips under -short. func TestUserMCPGuard(t *testing.T) { env := newAdminEnv(t) ctx := context.Background() guards, _ := env.app.Lookup[*pact.MCPGuards]() guard, ok := guards.Lookup("user") if !ok { t.Fatal("no user guard") } resolver, _ := env.app.Lookup[pact.MCPPersonalTokenResolver]() t.Run("requirement parser refusals", func(t *testing.T) { for _, mw := range [][]string{{"user.api_token", "user.unknown"}, {"user.api_token", "user.scope:"}, {"user.api_token", "user.permission:"}} { if _, err := guard.MCPRequirements(mw); err == nil { t.Fatalf("%v accepted", mw) } } if req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:read"}); err != nil || len(req.Scopes) != 1 { t.Fatalf("duplicate scope = %+v, %v", req, err) } }) t.Run("soft-deleted user", func(t *testing.T) { user := env.seedUser(t, "mcp-gone", nil) secret, _ := env.mintUserToken(t, user, "read") subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}} if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || !ok { t.Fatalf("before deletion = %v, %v", ok, err) } if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil { t.Fatal(err) } if ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{}, nil); err != nil || ok { t.Fatalf("after deletion Allows = %v, %v", ok, err) } if _, err := guard.MCPDispatch(ctx, subject, http.MethodGet); err == nil { t.Fatal("a deleted user was dispatched") } if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) { t.Fatalf("a deleted user's token = %v, %v", matched, err) } }) t.Run("expired token", func(t *testing.T) { user := env.seedUser(t, "mcp-expired", nil) secret, token := env.mintUserToken(t, user, "read") if err := env.gdb.Model(&models.APIToken{}).Where("id = ?", token.ID).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { t.Fatal(err) } if _, matched, err := resolver.MCPResolvePersonalToken(ctx, secret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) { t.Fatalf("an expired token = %v, %v", matched, err) } }) t.Run("dispatch credential against user.scope", func(t *testing.T) { user := env.seedUser(t, "mcp-scope", nil) me := func(scopes ...string) *httptest.ResponseRecorder { t.Helper() d, err := guard.MCPDispatch(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: scopes}, http.MethodGet) if err != nil { t.Fatal(err) } principal, _ := d.Principal.(*bouncer.Principal) req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/token/me", nil) req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential})) rec := httptest.NewRecorder() env.h.ServeHTTP(rec, req) return rec } if rec := me("read"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) { t.Fatalf("read subject = %d %s", rec.Code, rec.Body.String()) } if rec := me("write"); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Missing required scope: read") { t.Fatalf("write-only subject = %d %s", rec.Code, rec.Body.String()) } if _, isToken := mustDispatchCredential(t, guard, user.ID).(*models.APIToken); !isToken { t.Fatal("the dispatch credential is not an in-memory API token") } }) } // mustDispatchCredential returns the credential MCPDispatch builds for a // read subject; it is never saved (ID 0). func mustDispatchCredential(t *testing.T, guard pact.MCPGuard, userID uint) any { t.Helper() d, err := guard.MCPDispatch(context.Background(), pact.MCPSubject{Guard: "user", UserID: userID, Scopes: []string{"read"}}, http.MethodGet) if err != nil { t.Fatal(err) } if tok, ok := d.Credential.(*models.APIToken); !ok || tok.ID != 0 { t.Fatalf("credential = %+v", d.Credential) } return d.Credential }