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}, POST // /backend-link/show and POST /backend-link/confirm (the account link), // GET /token/me and GET /tokens/scopes. if len(base) != 23 { t.Fatalf("unset: %d routes, want 23", 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) != 20 { t.Fatalf("base without the password routes has %d routes, want 20", 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()) } }) }