diff --git a/registration_test.go b/registration_test.go new file mode 100644 index 0000000..adb1e57 --- /dev/null +++ b/registration_test.go @@ -0,0 +1,183 @@ +package user + +import ( + "context" + "errors" + "maps" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "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" +) + +// TestRegisterUserExports covers the registration steps other plugins call +// instead of POST /register: RegisterUser's validation, activation, IP +// recording and password hashing; FireRegisterEvent's payload and +// listener error; IssueToken's issuer and refusals; APIArray's listener +// merge and refusals. +func TestRegisterUserExports(t *testing.T) { + app, gdb := sessionApp(t, "0s") + ctx := t.Context() + // The session database is shared by the package's tests: every email + // here is unique to this run. + tag := strconv.FormatInt(time.Now().UnixNano(), 36) + + t.Run("register-user", func(t *testing.T) { + if _, _, err := controllers.RegisterUser(ctx, app, nil, map[string]any{}, controllers.RegisterOptions{}); err == nil { + t.Fatal("a nil handle must be an error") + } + var before int64 + if err := gdb.Model(&models.User{}).Count(&before).Error; err != nil { + t.Fatal(err) + } + for name, fields := range map[string]map[string]any{ + "nil": nil, + "missing email": {"password": "correct-horse"}, + "short password": {"email": "short-pw@example.test", "password": "x"}, + "bad email": {"email": "not-an-email", "password": "correct-horse"}, + } { + u, errs, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{}) + if err != nil || u != nil || len(errs) == 0 { + t.Errorf("%s: user %v errs %v err %v, want validation errors and no user", name, u, errs, err) + } + } + var after int64 + if err := gdb.Model(&models.User{}).Count(&after).Error; err != nil || after != before { + t.Fatalf("a refused registration wrote users: %d -> %d (%v)", before, after, err) + } + + if err := app.Config.Set("golem15.user.password.bcrypt_cost", 4); err != nil { + t.Fatal(err) + } + fields := map[string]any{"email": "exported-" + tag + "@example.test", "password": "correct-horse", "password_confirmation": "correct-horse", "name": "Ex"} + original := maps.Clone(fields) + u, errs, err := controllers.RegisterUser(ctx, app, gdb, fields, controllers.RegisterOptions{Activate: true, IP: "198.51.100.7"}) + if err != nil || len(errs) != 0 || u == nil || u.ID == 0 { + t.Fatalf("register: %v %v %v", u, errs, err) + } + if !maps.Equal(fields, original) { + t.Fatalf("RegisterUser modified its input: %#v", fields) + } + var stored models.User + if err := gdb.Take(&stored, u.ID).Error; err != nil { + t.Fatal(err) + } + if !stored.IsActivated || stored.ActivatedAt == nil || !stored.HasSelfSetPassword { + t.Fatalf("activation: %+v", stored) + } + if stored.CreatedIPAddress == nil || *stored.CreatedIPAddress != "198.51.100.7" || stored.LastIPAddress == nil || *stored.LastIPAddress != "198.51.100.7" { + t.Fatalf("ip columns %v %v", stored.CreatedIPAddress, stored.LastIPAddress) + } + if stored.Password == "correct-horse" || !strings.HasPrefix(stored.Password, "$2") || !strings.Contains(stored.Password, "$04$") { + t.Fatalf("password not hashed at the configured cost: %q", stored.Password) + } + + plain, errs, err := controllers.RegisterUser(ctx, app, gdb, map[string]any{"email": "inactive-" + tag + "@example.test", "password": "correct-horse", "password_confirmation": "correct-horse"}, controllers.RegisterOptions{}) + if err != nil || len(errs) != 0 || plain.IsActivated || plain.ActivatedAt != nil || plain.CreatedIPAddress != nil { + t.Fatalf("default options: %+v %v %v", plain, errs, err) + } + }) + + t.Run("fire-register-event", func(t *testing.T) { + u := &models.User{ID: 1, Email: "event@example.test"} + if err := controllers.FireRegisterEvent(ctx, nil, u, map[string]any{"password": "x"}); err != nil { + t.Fatalf("nil app: %v", err) + } + if err := controllers.FireRegisterEvent(ctx, &backpack.App{}, u, nil); err != nil { + t.Fatalf("app without events: %v", err) + } + var got map[string]any + boom := errors.New("listener failed") + app.Events.Listen[*classes.RegisterEvent]("exports-test", func(_ context.Context, e *classes.RegisterEvent) error { + got = e.Payload + if e.Payload["fail"] == true { + return boom + } + return nil + }) + fields := map[string]any{"email": "event@example.test", "password": "pw-1", "password_confirmation": "pw-1", "invitation_token": "tok"} + if err := controllers.FireRegisterEvent(ctx, app, u, fields); err != nil { + t.Fatal(err) + } + if _, ok := got["password"]; ok || got["invitation_token"] != "tok" { + t.Fatalf("payload %#v", got) + } + if _, ok := got["password_confirmation"]; ok { + t.Fatalf("payload carries the confirmation: %#v", got) + } + if _, ok := fields["password"]; !ok { + t.Fatal("FireRegisterEvent modified its input") + } + if err := controllers.FireRegisterEvent(ctx, app, u, map[string]any{"fail": true}); !errors.Is(err, boom) { + t.Fatalf("listener error = %v, want it returned", err) + } + if err := controllers.FireRegisterEvent(ctx, app, u, nil); err != nil || got == nil || len(got) != 0 { + t.Fatalf("nil fields: payload %#v err %v", got, err) + } + }) + + t.Run("issue-token", func(t *testing.T) { + u := &models.User{ID: 42, Email: "token@example.test"} + req := httptest.NewRequest(http.MethodPost, "/_fonoteka/api/v1/onboarding/bootstrap", nil) + req.Host = "example.com" + if _, err := controllers.IssueToken(app, nil, req); err == nil { + t.Fatal("a nil user must be an error") + } + if _, err := controllers.IssueToken(app, u, nil); err == nil { + t.Fatal("a nil request must be an error") + } + token, err := controllers.IssueToken(app, u, req) + if err != nil { + t.Fatal(err) + } + claims := parseToken(t, token) + if claims["sub"] != "42" || claims["iss"] != "http://example.com/_fonoteka/api/v1/onboarding/bootstrap" { + t.Fatalf("claims %v", claims) + } + req.Header.Set("X-Forwarded-Proto", "https") + token, err = controllers.IssueToken(app, u, req) + if err != nil || parseToken(t, token)["iss"] != "https://example.com/_fonoteka/api/v1/onboarding/bootstrap" { + t.Fatalf("forwarded issuer: %v", err) + } + if _, err := controllers.IssueToken(&backpack.App{}, u, req); err == nil { + t.Fatal("an app without a JWT secret must be an error") + } + }) + + t.Run("api-array", func(t *testing.T) { + if _, err := controllers.APIArray(ctx, app, nil); err == nil { + t.Fatal("a nil user must be an error") + } + u := &models.User{ID: 7, Email: "payload@example.test", IsActivated: true} + base, err := controllers.APIArray(ctx, app, u) + if err != nil || base["email"] != "payload@example.test" || base["feedback_widget_hidden"] != false { + t.Fatalf("payload %#v %v", base, err) + } + app.Events.Listen[*classes.GetApiArrayEvent]("exports-merge", func(_ context.Context, e *classes.GetApiArrayEvent) error { + if e.User.ID == 8 { + return errors.New("collect failed") + } + e.Collected()["feedback_widget_hidden"] = true + e.Collected()["acme_flag"] = "on" + return nil + }) + merged, err := controllers.APIArray(ctx, app, u) + if err != nil || merged["feedback_widget_hidden"] != true || merged["acme_flag"] != "on" || merged["id"] != uint(7) { + t.Fatalf("merged payload %#v %v", merged, err) + } + if _, err := controllers.APIArray(ctx, app, &models.User{ID: 8, Email: "broken@example.test"}); err == nil { + t.Fatal("a failing listener must fail the payload") + } + plain, err := controllers.APIArray(ctx, &backpack.App{}, u) + if err != nil || plain["acme_flag"] != nil { + t.Fatalf("app without events: %#v %v", plain, err) + } + }) +}