- TestBackendLink: the fresh sign-in rule at the second, 1:1 on the frontend side, the show route's three keys, SetLink and RemoveLink events (none after a rollback), deactivated or deleted administrators, and deactivating the frontend user - TestFrontendPermissionCatalog: a second plugin's duplicate and an empty code dropped; a group saved with a declared code grants it - TestUserMCPGuard: parser refusals, a soft-deleted user, an expired token, and the dispatch credential against the route's own user.scope:read
118 lines
4.8 KiB
Go
118 lines
4.8 KiB
Go
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
|
|
}
|