- 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
184 lines
7.5 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|