Files
sm-user-plugin/registration_test.go
Jakub Zych 8a658903ed test: cover the exported registration steps
- RegisterUser: nil handle, validation refusals that write nothing, the
  activation and IP options, the configured bcrypt cost, input untouched
- FireRegisterEvent: payload without password keys, listener error
  returned, nil app and nil fields
- IssueToken: issuer from the request URL and X-Forwarded-Proto, nil
  user, nil request and a missing JWT secret refused
- APIArray: listener merge, listener error, nil user
2026-10-03 11:17:32 +02:00

184 lines
7.5 KiB
Go

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