package user import ( "context" "database/sql" "database/sql/driver" "io" "slices" "strings" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" pgdriver "gorm.io/driver/postgres" "gorm.io/gorm" ) // impliesServersRead is the one declared scope the tracer exercises: an // OAuth grant of read satisfies it, a grant of write does not. func impliesServersRead() []pact.FrontendTokenScope { return []pact.FrontendTokenScope{{ Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read", }} } // impliesApp boots the member-token catalog with scopes declared and // publishes a database handle the guard can load a principal from. The // handle answers one user and no group grants, in memory, so the scope // check can reach a credential without Postgres. Nothing is written. func impliesApp(t *testing.T, scopes []pact.FrontendTokenScope) (*backpack.App, *classes.MCPUserGuard) { t.Helper() t.Cleanup(classes.ResetDeclaredAPITokenScopes) app := scopeBootApp(t, scopeDeclarer{"acme.scopes", scopes}) if err := (&Plugin{}).bootAPITokenScopes(app); err != nil { t.Fatal(err) } sqlDB, err := sql.Open("user_implies", "") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = sqlDB.Close() }) db, err := gorm.Open(pgdriver.New(pgdriver.Config{Conn: sqlDB}), &gorm.Config{DisableAutomaticPing: true}) if err != nil { t.Fatal(err) } if err := app.Publish(db); err != nil { t.Fatal(err) } return app, &classes.MCPUserGuard{App: app} } // TestMCPImpliesOAuth pins the OAuth half of D-01a: a grant of read satisfies // a declared scope whose Implies is read, in MCPAllows and on the credential // MCPDispatch builds, and that credential keeps ID 0. The grant slice itself // is not rewritten, and the catalog is published as pact.MemberTokenScopeCatalog. func TestMCPImpliesOAuth(t *testing.T) { app, guard := impliesApp(t, impliesServersRead()) catalog, ok := app.Lookup[pact.MemberTokenScopeCatalog]() if !ok || catalog == nil { t.Fatal("pact.MemberTokenScopeCatalog is not published") } if _, ok := app.Lookup[*classes.APITokenScopeCatalog](); !ok { t.Fatal("the concrete catalog publish was removed") } var sawRead, sawDeclared bool for _, scope := range catalog.MemberTokenScopes() { switch scope.Name { case "read", "write", "ai": if scope.Implies != "" { t.Fatalf("built-in %s Implies = %q", scope.Name, scope.Implies) } if scope.Name == "read" { sawRead = true } case "servers:read": sawDeclared = scope.Implies == "read" } } if !sawRead || !sawDeclared { t.Fatalf("catalog = %+v", catalog.MemberTokenScopes()) } ctx := context.Background() subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}} orig := subject.Scopes ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil) if err != nil || !ok { t.Fatalf("MCPAllows(servers:read) = %v, %v", ok, err) } dispatch, err := guard.MCPDispatch(ctx, subject, "GET") if err != nil { t.Fatal(err) } credential, isToken := dispatch.Credential.(*models.APIToken) if !isToken || credential.ID != 0 || credential.OAuthClientID != nil || !credential.HasScope("servers:read") || !credential.HasScope("read") { t.Fatalf("credential = %+v", dispatch.Credential) } if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" { t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes) } if slices.Contains(subject.Scopes, "servers:read") { t.Fatal("implied name was written onto subject.Scopes") } } // TestMCPImpliesPersonalUnchanged pins D-11: a personal token is not expanded, // and the credential ID stays the subject's TokenID. func TestMCPImpliesPersonalUnchanged(t *testing.T) { _, guard := impliesApp(t, impliesServersRead()) ctx := context.Background() subject := pact.MCPSubject{Guard: "user", UserID: 7, TokenID: 42, Scopes: []string{"read"}} orig := subject.Scopes ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil) if err != nil || ok { t.Fatalf("MCPAllows = %v, %v", ok, err) } if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" { t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes) } dispatch, err := guard.MCPDispatch(ctx, subject, "GET") if err != nil { t.Fatal(err) } credential, isToken := dispatch.Credential.(*models.APIToken) if !isToken || credential.ID != subject.TokenID || credential.HasScope("servers:read") || !credential.HasScope("read") { t.Fatalf("credential = %+v, want ID %d and scopes [read]", dispatch.Credential, subject.TokenID) } got := credential.Scopes.Get() if len(got) != 1 || &got[0] != &orig[0] { t.Fatalf("credential scopes = %v, want the unexpanded grant slice", got) } } // TestMCPImpliesWriteDoesNotGrantRead pins that a write grant does not // satisfy a scope whose Implies is read. func TestMCPImpliesWriteDoesNotGrantRead(t *testing.T) { _, guard := impliesApp(t, impliesServersRead()) ctx := context.Background() subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"write"}} ok, err := guard.MCPAllows(ctx, subject, pact.MCPRequirements{Scopes: []string{"servers:read"}}, nil) if err != nil || ok { t.Fatalf("MCPAllows = %v, %v", ok, err) } dispatch, err := guard.MCPDispatch(ctx, subject, "GET") if err != nil { t.Fatal(err) } credential, isToken := dispatch.Credential.(*models.APIToken) if !isToken || credential.ID != 0 || credential.HasScope("servers:read") || !credential.HasScope("write") { t.Fatalf("credential = %+v", dispatch.Credential) } } // TestMCPImpliesNotPersisted pins that MCPDispatch expands scopes only on // the credential it returns. The subject's grant slice is the same backing // array afterwards. func TestMCPImpliesNotPersisted(t *testing.T) { _, guard := impliesApp(t, impliesServersRead()) ctx := context.Background() subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read"}} orig := subject.Scopes dispatch, err := guard.MCPDispatch(ctx, subject, "GET") if err != nil { t.Fatal(err) } credential, isToken := dispatch.Credential.(*models.APIToken) if !isToken || !credential.HasScope("servers:read") || !credential.HasScope("read") { t.Fatalf("credential = %+v, want the expanded scopes on the credential only", dispatch.Credential) } if len(subject.Scopes) != 1 || &subject.Scopes[0] != &orig[0] || subject.Scopes[0] != "read" { t.Fatalf("subject.Scopes = %v, want the original grant slice", subject.Scopes) } if slices.Contains(subject.Scopes, "servers:read") { t.Fatal("implied name was written onto subject.Scopes") } } // TestMCPImpliesAlreadyPresent does not append a declared name the grant // already holds, and does not append a catalog name whose Implies is empty. func TestMCPImpliesAlreadyPresent(t *testing.T) { _, guard := impliesApp(t, []pact.FrontendTokenScope{ {Name: "servers:read", Label: "Read servers", Description: "List servers.", Implies: "read"}, {Name: "servers:list", Label: "List servers", Description: "List only.", Implies: ""}, }) ctx := context.Background() subject := pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "oauth-client", Scopes: []string{"read", "servers:read"}} dispatch, err := guard.MCPDispatch(ctx, subject, "GET") if err != nil { t.Fatal(err) } credential, isToken := dispatch.Credential.(*models.APIToken) if !isToken || !slices.Equal(credential.Scopes.Get(), []string{"read", "servers:read"}) { t.Fatalf("credential scopes = %+v", dispatch.Credential) } } // TestMCPImpliesCompileRejects pins the boot errors for a bad Implies and // for the reserved names backend and offline_access. The error names the // plugin and the bad value. Empty, read, and write still compile. func TestMCPImpliesCompileRejects(t *testing.T) { t.Cleanup(classes.ResetDeclaredAPITokenScopes) one := func(name, implies string) pact.FrontendTokenScope { return pact.FrontendTokenScope{Name: name, Label: "Label", Description: "Description", Implies: implies} } boot := func(scope pact.FrontendTokenScope) error { classes.ResetDeclaredAPITokenScopes() return (&Plugin{}).bootAPITokenScopes(scopeBootApp(t, scopeDeclarer{"acme.scopes", []pact.FrontendTokenScope{scope}})) } for _, implies := range []string{"ai", "backend", "READ", " read"} { err := boot(one("servers:read", implies)) if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), implies) { t.Fatalf("implies %q err = %v", implies, err) } } for _, name := range []string{"backend", "offline_access"} { err := boot(one(name, "")) if err == nil || !strings.Contains(err.Error(), "acme.scopes") || !strings.Contains(err.Error(), `"`+name+`"`) { t.Fatalf("name %q err = %v", name, err) } } for _, implies := range []string{"", "read", "write"} { if err := boot(one("servers:read", implies)); err != nil { t.Fatalf("implies %q: %v", implies, err) } } } func init() { sql.Register("user_implies", impliesDriver{}) } // impliesDriver is an in-memory database/sql driver for the Implies tests. // A users query returns one row. A group-grant query returns none. type impliesDriver struct{} func (impliesDriver) Open(string) (driver.Conn, error) { return impliesConn{}, nil } type impliesConn struct{} func (impliesConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip } func (impliesConn) Close() error { return nil } func (impliesConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip } func (impliesConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { return impliesRowsFor(query), nil } func (impliesConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) { return impliesResult(0), nil } func impliesRowsFor(query string) driver.Rows { upper := strings.ToUpper(query) if strings.Contains(upper, "FROM \"USERS\"") || strings.Contains(upper, "FROM USERS") { cols := impliesSelectCols(query) vals := make([]driver.Value, len(cols)) for i, col := range cols { vals[i] = impliesColValue(col) } return &impliesRows{cols: cols, vals: [][]driver.Value{vals}} } return &impliesRows{cols: []string{"permissions"}} } func impliesSelectCols(query string) []string { upper := strings.ToUpper(query) start := strings.Index(upper, "SELECT") from := strings.Index(upper, " FROM ") if start < 0 || from < 0 || from <= start+6 { return []string{"id"} } raw := strings.Split(query[start+6:from], ",") cols := make([]string, 0, len(raw)) for _, part := range raw { name := strings.TrimSpace(part) name = strings.Trim(name, `"`) if i := strings.LastIndex(name, "."); i >= 0 { name = name[i+1:] } name = strings.Trim(name, `"`) name = strings.TrimSpace(name) if name == "" || name == "*" { continue } cols = append(cols, name) } if len(cols) == 0 { return []string{"id"} } return cols } func impliesColValue(col string) driver.Value { switch col { case "id": return int64(7) case "email": return "ada@example.test" case "password": return "x" case "must_change_password", "is_activated", "has_self_set_password", "marketing_consent", "is_onboarded": return false case "created_at", "updated_at": return time.Time{} default: return nil } } type impliesRows struct { cols []string vals [][]driver.Value i int } func (r *impliesRows) Columns() []string { return r.cols } func (r *impliesRows) Close() error { return nil } func (r *impliesRows) Next(dest []driver.Value) error { if r.i >= len(r.vals) { return io.EOF } copy(dest, r.vals[r.i]) r.i++ return nil } type impliesResult int64 func (r impliesResult) LastInsertId() (int64, error) { return int64(r), nil } func (r impliesResult) RowsAffected() (int64, error) { return int64(r), nil }