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) } }) }