feat: carry the register input on RegisterEvent and export the registration steps

- RegisterEvent.Payload: a copy of the register input without password and
  password_confirmation (Winter's golem15.user.register data array)
- RegisterUser, RegisterOptions, FireRegisterEvent, IssueToken and APIArray:
  the steps POST /register runs, now called by Register itself; its status
  codes and bodies are unchanged
- README documents the exports and Payload
This commit is contained in:
Jakub Zych
2026-10-03 07:12:00 +02:00
parent c258e9fade
commit d80d7990f6
5 changed files with 235 additions and 42 deletions

View File

@@ -155,6 +155,99 @@ func TestRegisterEvent(t *testing.T) {
}
}
// 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)