- 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
306 lines
11 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|