Files
sm-user-plugin/password_routes_test.go
Jakub Zych 0f545f5c81 feat: user MCP guard provider, plugin frontend permissions and the user_me tool
- classes.FrontendPermissionCatalog: the seeded rows, then codes activated
  plugins declare through pact.HasFrontendPermissions (seeded row wins);
  both permission editors offer it
- classes.MCPUserGuard (guard "user"): reads user.api_token, user.scope:*
  and user.permission:*, allows with the route's checks (session scopes,
  group grants, every code), dispatches with an in-memory token credential,
  resolves usr_ personal tokens; registered in *pact.MCPGuards and published
  as pact.MCPPersonalTokenResolver
- GET /_user/api/v1/token/me (user.api_token, user.scope:read) and the MCP
  tool me (user_me)
- pact.MCPSubjectChanged on token revoke, Users saves and User Groups saves
  and deletes
2026-10-23 12:08:38 +02:00

306 lines
11 KiB
Go

package user
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
)
const passwordPublicRoutesKey = "golem15.user.password.public_routes"
// passwordRouteKeys are the registrations golem15.user.password.public_routes
// false leaves out.
var passwordRouteKeys = map[string]bool{
http.MethodPost + " /_user/api/v1/login": true,
http.MethodPost + " /_user/api/v1/forgot-password": true,
http.MethodPost + " /_user/api/v1/reset-password": true,
}
// recordRoutesOn records the routes the plugin registers on app and returns
// them with the Routes error.
func recordRoutesOn(t *testing.T, app *backpack.App) ([]recordedRoute, error) {
t.Helper()
rec := newRecordingRouter()
err := (&Plugin{app: app}).Routes(rec)
return *rec.routes, err
}
// passwordRoutesApp is a bareApp with golem15.user.password.public_routes set to value.
func passwordRoutesApp(t *testing.T, value any) *backpack.App {
t.Helper()
app := bareApp(t)
if err := app.Config.Set(passwordPublicRoutesKey, value); err != nil {
t.Fatal(err)
}
return app
}
// withoutPasswordRoutes returns routes without POST /login, /forgot-password and /reset-password.
func withoutPasswordRoutes(routes []recordedRoute) []recordedRoute {
out := make([]recordedRoute, 0, len(routes))
for _, route := range routes {
if !passwordRouteKeys[route.method+" "+route.path] {
out = append(out, route)
}
}
return out
}
// assertSameRoutes requires got to equal want route by route (method, path, middleware, order).
func assertSameRoutes(t *testing.T, got, want []recordedRoute) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%d routes, want %d", len(got), len(want))
}
for i := range got {
g, w := got[i], want[i]
if g.method != w.method || g.path != w.path {
t.Fatalf("route %d is %s %s, want %s %s", i, g.method, g.path, w.method, w.path)
}
if strings.Join(g.middleware, ",") != strings.Join(w.middleware, ",") {
t.Errorf("%s %s middleware %v, want %v", g.method, g.path, g.middleware, w.middleware)
}
}
}
// assertLikeUnknown requires a 404 with the status, body and Content-Type of unknown and no cookie.
func assertLikeUnknown(t *testing.T, got, unknown *httptest.ResponseRecorder) {
t.Helper()
if unknown.Code != http.StatusNotFound {
t.Fatalf("unknown path = %d %s, want 404", unknown.Code, unknown.Body.String())
}
if got.Code != unknown.Code || got.Body.String() != unknown.Body.String() {
t.Fatalf("answer %d %q, want %d %q", got.Code, got.Body.String(), unknown.Code, unknown.Body.String())
}
if g, u := got.Header().Get("Content-Type"), unknown.Header().Get("Content-Type"); g != u {
t.Fatalf("Content-Type %q, want %q", g, u)
}
assertNoCookie(t, got)
}
// TestPasswordPublicRoutes pins golem15.user.password.public_routes on the
// route table: unset, true and the config.yaml default register every route;
// false drops exactly POST /login, /forgot-password and /reset-password and
// nothing else, the turnstile guard staying on /register; an invalid value
// fails Routes naming the key.
func TestPasswordPublicRoutes(t *testing.T) {
base, err := recordRoutesOn(t, bareApp(t))
if err != nil {
t.Fatalf("unset: Routes error %v", err)
}
// 18 routes up to v0.1.9, plus GET /backend-link/{code} and POST
// /backend-link/confirm (the account link) and GET /token/me.
if len(base) != 21 {
t.Fatalf("unset: %d routes, want 21", len(base))
}
seen := 0
for _, route := range base {
if passwordRouteKeys[route.method+" "+route.path] {
seen++
}
}
if seen != len(passwordRouteKeys) {
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
}
off := withoutPasswordRoutes(base)
if len(off) != 18 {
t.Fatalf("base without the password routes has %d routes, want 18", len(off))
}
for _, c := range []struct {
name string
value any
want []recordedRoute
}{
{name: "true", value: true, want: base},
{name: "true string", value: "true", want: base},
{name: "upper-case true string", value: "TRUE", want: base},
{name: "empty means default", value: "", want: base},
{name: "false", value: false, want: off},
{name: "false string", value: "false", want: off},
{name: "padded false string", value: " false ", want: off},
{name: "mixed-case false string", value: "False", want: off},
} {
t.Run(c.name, func(t *testing.T) {
got, err := recordRoutesOn(t, passwordRoutesApp(t, c.value))
if err != nil {
t.Fatalf("Routes error %v", err)
}
assertSameRoutes(t, got, c.want)
})
}
t.Run("config.yaml default", func(t *testing.T) {
app := bareApp(t)
if err := app.Config.MergePlugin("golem15.user", configFS); err != nil {
t.Fatal(err)
}
if v, ok := app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true {
t.Fatalf("config.yaml default = %#v (set %v), want true", v, ok)
}
got, err := recordRoutesOn(t, app)
if err != nil {
t.Fatalf("Routes error %v", err)
}
assertSameRoutes(t, got, base)
})
t.Run("env false", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-bare\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_ENV=development",
"SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false",
}})
if err != nil {
t.Fatal(err)
}
got, err := recordRoutesOn(t, backpack.New(cfg))
if err != nil {
t.Fatalf("Routes error %v", err)
}
assertSameRoutes(t, got, off)
})
t.Run("false with turnstile closed", func(t *testing.T) {
app := passwordRoutesApp(t, false)
if err := app.Config.Set("golem15.user.turnstile", "closed"); err != nil {
t.Fatal(err)
}
got, err := recordRoutesOn(t, app)
if err != nil {
t.Fatalf("Routes error %v", err)
}
want := make([]recordedRoute, len(off))
for i, route := range off {
want[i] = route
if route.method == http.MethodPost && route.path == "/_user/api/v1/register" {
want[i].middleware = append(append([]string{}, route.middleware...), "turnstile:closed")
}
}
assertSameRoutes(t, got, want)
for _, route := range got {
if names := turnstileOf(route); len(names) > 0 && route.path != "/_user/api/v1/register" {
t.Errorf("%s %s carries %v", route.method, route.path, names)
}
}
})
t.Run("invalid", func(t *testing.T) {
for _, bad := range []any{"nope", "yes", "1", "0", "t", "f", 0} {
if _, err := recordRoutesOn(t, passwordRoutesApp(t, bad)); err == nil || !strings.Contains(err.Error(), passwordPublicRoutesKey) {
t.Errorf("%#v: error %v, want one naming %s", bad, err, passwordPublicRoutesKey)
}
}
})
}
// TestPasswordPublicRoutesAnswer404 pins that, with
// golem15.user.password.public_routes false, the dropped routes answer
// exactly like an unknown path through the assembled router, while the
// default answers are unchanged and /register, /fetch, /refresh and /logout
// keep working.
func TestPasswordPublicRoutesAnswer404(t *testing.T) {
const unknownPath = "/_user/api/v1/no-such-route"
t.Run("default answers", func(t *testing.T) {
e := newAdminEnv(t, tokenCookieSettings(t, nil))
if v, ok := e.app.Config.Lookup(passwordPublicRoutesKey); !ok || v != true {
t.Fatalf("merged default = %#v (set %v), want true", v, ok)
}
if rec := e.call(t, http.MethodPost, unknownPath, "", map[string]string{}); rec.Code != http.StatusNotFound {
t.Fatalf("unknown path = %d %s, want 404", rec.Code, rec.Body.String())
}
user := e.seedUser(t, "public-routes-default", nil)
if rec := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "wrong-horse"}); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("login = %d %s, want 401 %s", rec.Code, rec.Body.String(), invalidLoginBody)
}
rec := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email})
if rec.Code != http.StatusOK {
t.Fatalf("forgot-password = %d %s, want 200", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "message")
if rec := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{}); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("reset-password = %d %s, want 422", rec.Code, rec.Body.String())
}
if rec := e.call(t, http.MethodGet, userLoginPath, "", nil); rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET login = %d %s, want 405", rec.Code, rec.Body.String())
}
})
e := newAdminEnv(t, tokenCookieSettings(t, map[string]any{passwordPublicRoutesKey: false}))
t.Run("off answers like an unknown path", func(t *testing.T) {
unknown := e.call(t, http.MethodPost, unknownPath, "", map[string]string{})
unknownGet := e.call(t, http.MethodGet, unknownPath, "", nil)
user := e.seedUser(t, "public-routes-off", nil)
login := e.call(t, http.MethodPost, userLoginPath, "", map[string]string{"email": user.Email, "password": "correct-horse"})
assertLikeUnknown(t, login, unknown)
forgot := e.call(t, http.MethodPost, "/_user/api/v1/forgot-password", "", map[string]string{"email": user.Email})
assertLikeUnknown(t, forgot, unknown)
code, err := classes.IssueResetCode(t.Context(), e.gdb, user)
if err != nil {
t.Fatal(err)
}
reset := e.call(t, http.MethodPost, "/_user/api/v1/reset-password", "", map[string]string{
"code": userSub(user) + "!" + code,
"password": "another-horse",
"password_confirmation": "another-horse",
})
assertLikeUnknown(t, reset, unknown)
assertLikeUnknown(t, e.call(t, http.MethodGet, userLoginPath, "", nil), unknownGet)
if got := e.reload(t, user.ID); got.LastSeen != nil {
t.Fatalf("last_seen stamped %v", *got.LastSeen)
}
if got := e.reload(t, user.ID); got.Password != user.Password {
t.Fatal("the password changed")
}
if sent := e.mails(user.Email); len(sent) != 0 {
t.Fatalf("mails sent %#v", sent)
}
})
t.Run("off keeps the rest", func(t *testing.T) {
rec := e.call(t, http.MethodPost, userRegisterPath, "", registerBody("public-routes-register"))
if rec.Code != http.StatusOK {
t.Fatalf("register = %d %s", rec.Code, rec.Body.String())
}
body := assertBodyKeys(t, rec, "token", "user")
token, _ := body["token"].(string)
if token == "" {
t.Fatalf("register token %#v", body["token"])
}
if code := e.fetchCode(t, token); code != http.StatusOK {
t.Fatalf("fetch = %d", code)
}
refreshed := e.refresh(t, token)
if refreshed.Code != http.StatusOK {
t.Fatalf("refresh = %d %s", refreshed.Code, refreshed.Body.String())
}
rotated, _ := assertBodyKeys(t, refreshed, "token")["token"].(string)
if rotated == "" {
t.Fatal("refresh returned no token")
}
if rec := e.call(t, http.MethodPost, "/_user/api/v1/logout", rotated, nil); rec.Code != http.StatusOK {
t.Fatalf("logout = %d %s", rec.Code, rec.Body.String())
}
if rec := e.call(t, http.MethodGet, "/_user/api/v1/oauth-providers", "", nil); rec.Code != http.StatusOK {
t.Fatalf("oauth-providers = %d %s", rec.Code, rec.Body.String())
}
})
}