package user import ( "encoding/json" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "context" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/postcard" ) func TestRegister(t *testing.T) { app, _ := sessionApp(t, "0s") disableRegisterThrottle(t, app) body := `{"email":"new-ada@example.test","password":"correct-horse","name":"Ada"}` rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body) if rec.Code != http.StatusOK { t.Fatalf("register %d %s", rec.Code, rec.Body.String()) } var created map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { t.Fatal(err) } token, _ := created["token"].(string) if token == "" { t.Fatalf("body %s", rec.Body.String()) } userBody, _ := created["user"].(map[string]any) if userBody["feedback_widget_hidden"] != false { t.Fatalf("feedback_widget_hidden = %#v", userBody["feedback_widget_hidden"]) } fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token) if fetch.Code != http.StatusOK || !strings.Contains(fetch.Body.String(), `"email":"new-ada@example.test"`) { t.Fatalf("fetch %d %s", fetch.Code, fetch.Body.String()) } missing := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"password":"correct-horse"}`) if missing.Code != http.StatusUnprocessableEntity { t.Fatalf("missing email %d %s", missing.Code, missing.Body.String()) } var verr map[string]any if err := json.Unmarshal(missing.Body.Bytes(), &verr); err != nil { t.Fatal(err) } errorsBody, _ := verr["errors"].(map[string]any) if _, ok := errorsBody["email"]; !ok || verr["error"] == "" { t.Fatalf("validation body %#v", verr) } } func TestRegisterDisabled(t *testing.T) { app, _ := sessionApp(t, "0s") if err := app.Config.Set("golem15.user.registration.allow_registration", false); err != nil { t.Fatal(err) } if err := app.Config.Set("app.debug", false); err != nil { t.Fatal(err) } rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"off@example.test","password":"correct-horse"}`) if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"error":"Internal server error"}` { t.Fatalf("production %d %s", rec.Code, rec.Body.String()) } if err := app.Config.Set("app.debug", true); err != nil { t.Fatal(err) } debug := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"off@example.test","password":"correct-horse"}`) if debug.Code != http.StatusInternalServerError || debug.Body.String() != `{"error":"Registrations are currently disabled."}` { t.Fatalf("debug %d %s", debug.Code, debug.Body.String()) } } func TestRegisterThrottle(t *testing.T) { app, gdb := sessionApp(t, "0s") ip := "203.0.113.9" for i := 1; i <= 3; i++ { addr := ip user := &models.User{Email: "throttle-reg-" + string(rune('a'+i-1)) + "@example.test", Password: "x", CreatedIPAddress: &addr} if err := gdb.Create(user).Error; err != nil { t.Fatal(err) } } rec := postRegister(t, app, ip, `{"email":"fourth@example.test","password":"correct-horse"}`) if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"error":"Internal server error"}` { t.Fatalf("throttled %d %s", rec.Code, rec.Body.String()) } if err := app.Config.Set("app.debug", true); err != nil { t.Fatal(err) } debug := postRegister(t, app, ip, `{"email":"fifth@example.test","password":"correct-horse"}`) if debug.Body.String() != `{"error":"Registration is throttled. Please try again later."}` { t.Fatalf("debug throttle %d %s", debug.Code, debug.Body.String()) } } func TestRegisterUserModeMail(t *testing.T) { app, _ := sessionApp(t, "0s") disableRegisterThrottle(t, app) if err := app.Config.Set("golem15.user.activation.activate_mode", "user"); err != nil { t.Fatal(err) } mail := &captureMail{} if err := app.Publish[postcard.Mailer](mail); err != nil { t.Fatal(err) } rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"en-user@example.test","password":"correct-horse","preferred_locale":"en"}`) if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Activation email sent"}` { t.Fatalf("user mode %d %s", rec.Code, rec.Body.String()) } if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.activate-en" { t.Fatalf("template = %#v", mail.got) } } func TestRegisterAdminMode(t *testing.T) { app, _ := sessionApp(t, "0s") disableRegisterThrottle(t, app) if err := app.Config.Set("golem15.user.activation.activate_mode", "admin"); err != nil { t.Fatal(err) } if err := app.Config.Set("golem15.user.activation.require_activation", true); err != nil { t.Fatal(err) } rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"admin-mode@example.test","password":"correct-horse"}`) if rec.Code != http.StatusOK || rec.Body.String() != `{}` { t.Fatalf("admin mode %d %s", rec.Code, rec.Body.String()) } } func TestRegisterEvent(t *testing.T) { app, _ := sessionApp(t, "0s") disableRegisterThrottle(t, app) var seen string app.Events.Listen[*classes.RegisterEvent]("test", func(_ context.Context, e *classes.RegisterEvent) error { if e != nil && e.User != nil { seen = e.User.Email } return nil }) rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"event@example.test","password":"correct-horse"}`) if rec.Code != http.StatusOK { t.Fatalf("register %d %s", rec.Code, rec.Body.String()) } if seen != "event@example.test" { t.Fatalf("event email = %q", seen) } } // TestRegisterEventPayload: a listener sees the register input, extra keys // included, and never password or password_confirmation; a listener that // only reads User still compiles and runs; the exported steps register, // fire, mint and serialize exactly as /register does. func TestRegisterEventPayload(t *testing.T) { app, gdb := sessionApp(t, "0s") disableRegisterThrottle(t, app) var got map[string]any var userOnly string app.Events.Listen[*classes.RegisterEvent]("payload-test", func(_ context.Context, e *classes.RegisterEvent) error { got = e.Payload return nil }) // A listener written before Payload existed: it reads User only. app.Events.Listen[*classes.RegisterEvent]("user-only-test", func(_ context.Context, e *classes.RegisterEvent) error { userOnly = e.User.Email return nil }) body := `{"email":"payload@example.test","password":"correct-horse","password_confirmation":"correct-horse","name":"Pay","invitation_token":"abc123"}` rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", body) if rec.Code != http.StatusOK { t.Fatalf("register %d %s", rec.Code, rec.Body.String()) } if userOnly != "payload@example.test" { t.Fatalf("User-only listener saw %q", userOnly) } if got["email"] != "payload@example.test" || got["invitation_token"] != "abc123" || got["name"] != "Pay" { t.Fatalf("payload = %#v", got) } for _, k := range []string{"password", "password_confirmation"} { if _, ok := got[k]; ok { t.Fatalf("payload carries %s: %#v", k, got) } } for _, v := range got { if s, ok := v.(string); ok && (strings.Contains(s, "correct-horse") || strings.HasPrefix(s, "$2")) { t.Fatalf("payload carries a password or hash: %#v", got) } } // Without an explicit confirmation /register defaults it; neither key // reaches the payload. got = nil if rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"payload2@example.test","password":"correct-horse"}`); rec.Code != http.StatusOK { t.Fatalf("register %d %s", rec.Code, rec.Body.String()) } if len(got) != 1 || got["email"] != "payload2@example.test" { t.Fatalf("payload = %#v", got) } // The exported steps: RegisterUser leaves the input untouched and // returns validation errors without writing; FireRegisterEvent strips // the passwords; IssueToken and APIArray match /register. fields := map[string]any{"email": "exported@example.test", "password": "correct-horse", "password_confirmation": "correct-horse", "extra": "x"} user, errs, err := controllers.RegisterUser(t.Context(), app, gdb, fields, controllers.RegisterOptions{Activate: true, IP: "10.1.2.3"}) if err != nil || len(errs) != 0 || user == nil { t.Fatalf("RegisterUser = %v, %v, %v", user, errs, err) } if fields["password"] != "correct-horse" { t.Fatalf("RegisterUser modified the input: %#v", fields) } var stored models.User if err := gdb.Where("id = ?", user.ID).Take(&stored).Error; err != nil { t.Fatal(err) } if !stored.IsActivated || stored.ActivatedAt == nil || !stored.HasSelfSetPassword || stored.Password == "correct-horse" || stored.CreatedIPAddress == nil || *stored.CreatedIPAddress != "10.1.2.3" { t.Fatalf("stored user = %+v", stored) } got = nil if err := controllers.FireRegisterEvent(t.Context(), app, user, fields); err != nil { t.Fatal(err) } if len(got) != 2 || got["extra"] != "x" || got["email"] != "exported@example.test" { t.Fatalf("FireRegisterEvent payload = %#v", got) } req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/register", nil) token, err := controllers.IssueToken(app, user, req) if err != nil || token == "" { t.Fatalf("IssueToken = %q, %v", token, err) } if fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token); fetch.Code != http.StatusOK { t.Fatalf("fetch with the issued token %d %s", fetch.Code, fetch.Body.String()) } payload, err := controllers.APIArray(t.Context(), app, user) if err != nil || payload["email"] != "exported@example.test" || payload["is_activated"] != true { t.Fatalf("APIArray = %#v, %v", payload, err) } _, errs, err = controllers.RegisterUser(t.Context(), app, gdb, map[string]any{"email": "exported@example.test", "password": "correct-horse"}, controllers.RegisterOptions{}) if err != nil || len(errs) == 0 { t.Fatalf("a duplicate email must fail validation: %v %v", errs, err) } } func TestRegisterImportDirection(t *testing.T) { cmd := exec.Command("go", "list", "-deps", "./...") cmd.Dir = moduleRoot(t) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("go list: %v\n%s", err, out) } if strings.Contains(string(out), "plugins/golem15/fonoteka") { t.Fatal("golem15.user imports golem15.fonoteka") } } func TestRegisterCORSPath(t *testing.T) { body, err := os.ReadFile(filepath.Join(appRoot(t), "config", "http.yaml")) if err != nil { t.Fatal(err) } if !strings.Contains(string(body), "_user/api/*") { t.Fatal("cors paths missing _user/api/*") } } func disableRegisterThrottle(t *testing.T, app *backpack.App) { t.Helper() if err := app.Config.Set("golem15.user.registration.use_register_throttle", false); err != nil { t.Fatal(err) } } func postRegister(t *testing.T, app *backpack.App, ip, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/register", strings.NewReader(body)) req.Host = "example.com" req.RemoteAddr = ip + ":9999" req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() controllers.Register(app)(rec, req) return rec } func moduleRoot(t *testing.T) string { t.Helper() return findUp(t, "go.mod") } func appRoot(t *testing.T) string { t.Helper() return findUp(t, filepath.Join("config", "http.yaml")) } func findUp(t *testing.T, rel string) string { t.Helper() dir, err := os.Getwd() if err != nil { t.Fatal(err) } for { if _, err := os.Stat(filepath.Join(dir, rel)); err == nil { return dir } parent := filepath.Dir(dir) if parent == dir { t.Fatalf("%s not found", rel) } dir = parent } }