package user import ( "context" "errors" "net/http" "slices" "strconv" "strings" "testing" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" ) // mintUserToken stores a personal API token of the user with the scopes and // returns its secret and row. func (e *adminEnv) mintUserToken(t *testing.T, user *models.User, scopes ...string) (string, *models.APIToken) { t.Helper() secret, token, err := classes.MintAPIToken(classes.DefaultAPITokenPrefix, user.ID, "mcp "+adminStamp(), scopes, nil, nil) if err != nil { t.Fatal(err) } if err := e.gdb.Create(token).Error; err != nil { t.Fatal(err) } return secret, token } // grantGroup stores the codes as the group's permissions. func (e *adminEnv) grantGroup(t *testing.T, group *models.UserGroup, codes ...string) { t.Helper() parts := make([]string, 0, len(codes)) for _, code := range codes { parts = append(parts, strconv.Quote(code)+":1") } if err := e.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, "{"+strings.Join(parts, ",")+"}", group.ID).Error; err != nil { t.Fatal(err) } } // TestUserMCPGuardSmoke: the "user" guard provider is registered and // published as the personal token resolver; it reads the plugin's route // middleware, allows a tool with exactly the checks the routes run (scopes // from the session, permission codes from the user's groups, every code // required), dispatches with the group grants and an in-memory token // credential, and resolves usr_ tokens. GET /token/me, the user_me target, // answers the user payload to a read token only, and revoking a token fires // the revoked subject event. func TestUserMCPGuardSmoke(t *testing.T) { env := newAdminEnv(t) spy := env.spySubjects() ctx := context.Background() guards, ok := env.app.Lookup[*pact.MCPGuards]() if !ok || guards == nil { t.Fatal("*pact.MCPGuards is not published") } guard, ok := guards.Lookup("user") if !ok || guard.MCPGuardName() != "user" { t.Fatalf("user guard = %v %v", guard, ok) } resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]() if !ok || resolver == nil { t.Fatal("pact.MCPPersonalTokenResolver is not published") } if !guard.MCPTokenGuarded([]string{"throttle:user-api", "user.api_token", "user.scope:read"}) || guard.MCPTokenGuarded([]string{"throttle:user-api", "jwt.auth"}) { t.Fatal("MCPTokenGuarded must answer true only with user.api_token") } req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:write", "user.permission:acme.posts.edit"}) if err != nil || !slices.Equal(req.Scopes, []string{"read", "write"}) || !slices.Equal(req.Permissions, []string{"acme.posts.edit"}) { t.Fatalf("MCPRequirements = %+v %v", req, err) } if _, err := guard.MCPRequirements([]string{"user.api_token", "user.bogus"}); err == nil { t.Fatal("an unknown user.* middleware must be an error") } stamp := adminStamp() codeA, codeB, codeOwn := "mcp."+stamp+".a", "mcp."+stamp+".b", "mcp."+stamp+".own" user := env.seedUser(t, "mcp", func(u *models.User) { u.Permissions = models.PermissionSet{codeOwn: 1} }) groupA, groupB := env.seedGroup(t, "MCP A "+stamp, "mcp-a-"+stamp), env.seedGroup(t, "MCP B "+stamp, "mcp-b-"+stamp) env.grantGroup(t, groupA, codeA) env.grantGroup(t, groupB, codeB) env.join(t, user, groupA, groupB) subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}} allows := func(req pact.MCPRequirements, perms ...string) bool { t.Helper() ok, err := guard.MCPAllows(ctx, subject, req, perms) if err != nil { t.Fatal(err) } return ok } if allows(pact.MCPRequirements{Scopes: []string{"read", "write"}}) { t.Fatal("a scope the session lacks must refuse") } if !allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA}}, codeB) { t.Fatal("codes granted by the user's groups must allow") } if allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA, "mcp." + stamp + ".missing"}}) { t.Fatal("every code is required: one missing code must refuse") } if allows(pact.MCPRequirements{Scopes: []string{"read"}}, codeOwn) { t.Fatal("a user-level permission alone must not grant (the route checks group grants)") } if ok, err := guard.MCPAllows(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID + 1_000_000, Scopes: []string{"read"}}, pact.MCPRequirements{}, nil); err != nil || ok { t.Fatalf("an unknown user = %v %v", ok, err) } dispatch, err := guard.MCPDispatch(ctx, subject, http.MethodGet) if err != nil { t.Fatal(err) } principal, ok := dispatch.Principal.(*bouncer.Principal) if !ok || principal.ID != user.ID || !principal.PermissionGrants[codeA] || !principal.PermissionGrants[codeB] || principal.PermissionGrants[codeOwn] { t.Fatalf("dispatch principal = %+v", dispatch.Principal) } credential, ok := dispatch.Credential.(*models.APIToken) if !ok || credential.ID != 0 || credential.UserID != user.ID || !credential.HasScope("read") || credential.HasScope("write") { t.Fatalf("dispatch credential = %+v", dispatch.Credential) } if !slices.Equal(dispatch.Guards, []string{"api_token"}) { t.Fatalf("dispatch guards = %v", dispatch.Guards) } if _, matched, err := resolver.MCPResolvePersonalToken(ctx, "oas_at_x"); matched || err != nil { t.Fatalf("a foreign prefix = %v %v", matched, err) } readSecret, readToken := env.mintUserToken(t, user, "read", "ai") got, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret) if err != nil || !matched || got.Guard != "user" || got.UserID != user.ID || got.TokenID != readToken.ID || !slices.Equal(got.Scopes, []string{"read", "ai"}) { t.Fatalf("resolve = %+v %v %v", got, matched, err) } var stamped models.APIToken if err := env.gdb.First(&stamped, readToken.ID).Error; err != nil || stamped.LastUsedAt == nil { t.Fatalf("last_used_at not stamped: %+v %v", stamped, err) } if _, matched, err := resolver.MCPResolvePersonalToken(ctx, classes.DefaultAPITokenPrefix+"not-a-token"); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) { t.Fatalf("an unknown usr_ token = %v %v", matched, err) } // GET /token/me: the user_me target. rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/token/me", readSecret, nil) if !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) || !strings.HasPrefix(rec.Body.String(), `{"user":`) { t.Fatalf("token/me = %s", rec.Body.String()) } writeSecret, _ := env.mintUserToken(t, user, "write") rec = env.expect(t, http.StatusForbidden, http.MethodGet, "/_user/api/v1/token/me", writeSecret, nil) if !strings.Contains(rec.Body.String(), "Missing required scope: read") { t.Fatalf("token/me with write = %s", rec.Body.String()) } env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/token/me", freshUserToken(t, user), nil) // Revoking the token through the API fires revoked for that token only // (TokenID); the resolver then refuses it. spy.reset() env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/tokens/"+strconv.FormatUint(uint64(readToken.ID), 10), freshUserToken(t, user), nil) if !spy.hasEvent(pact.MCPSubjectChanged{Guard: "user", UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: readToken.ID}) { t.Fatalf("revoke fired %+v", spy.events) } if _, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) { t.Fatalf("a revoked token = %v %v", matched, err) } // Saving a group's permissions on the admin screen fires permissions // for each member after the save commits. if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'A', 'MCP'), (?, 'B', 'MCP')`, codeA, codeB).Error; err != nil { t.Fatal(err) } spy.reset() manager := env.admin(t, permAccessGroups) env.expect(t, http.StatusOK, http.MethodPut, groupPath(groupA.ID), manager, map[string]any{"name": groupA.Name, "code": *groupA.Code, "permissions": map[string]int{codeA: 1, codeB: 1}}) if !spy.has("user", user.ID, pact.MCPSubjectPermissions) { t.Fatalf("group save fired %+v", spy.events) } // Deleting a group fires permissions for its members once it commits. spy.reset() env.expect(t, http.StatusOK, http.MethodDelete, groupPath(groupB.ID), manager, nil) if !spy.has("user", user.ID, pact.MCPSubjectPermissions) { t.Fatalf("group delete fired %+v", spy.events) } // Saving the user on the Users screen fires permissions too. spy.reset() users := env.admin(t, permAccessUsers) env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), users, map[string]any{"name": "MCP renamed", "email": user.Email}) if !spy.has("user", user.ID, pact.MCPSubjectPermissions) { t.Fatalf("user save fired %+v", spy.events) } }