A failing user.Register or user.Created listener is logged at Error level with the user ID; the committed registration still succeeds.
415 lines
16 KiB
Go
415 lines
16 KiB
Go
package user
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"context"
|
|
|
|
"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"
|
|
"git.golem15.com/golem15/summercms/modules/postcard"
|
|
)
|
|
|
|
func TestRegister(t *testing.T) {
|
|
app, _ := sessionApp(t, "0s")
|
|
disableRegisterThrottle(t, app)
|
|
body := `{"email":"new-ada@example.test","password":"correct-horse","name":"Ada"}`
|
|
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())
|
|
}
|
|
var created map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, _ := created["token"].(string)
|
|
if token == "" {
|
|
t.Fatalf("body %s", rec.Body.String())
|
|
}
|
|
userBody, _ := created["user"].(map[string]any)
|
|
if userBody["feedback_widget_hidden"] != false {
|
|
t.Fatalf("feedback_widget_hidden = %#v", userBody["feedback_widget_hidden"])
|
|
}
|
|
fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", token)
|
|
if fetch.Code != http.StatusOK || !strings.Contains(fetch.Body.String(), `"email":"new-ada@example.test"`) {
|
|
t.Fatalf("fetch %d %s", fetch.Code, fetch.Body.String())
|
|
}
|
|
|
|
missing := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"password":"correct-horse"}`)
|
|
if missing.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("missing email %d %s", missing.Code, missing.Body.String())
|
|
}
|
|
var verr map[string]any
|
|
if err := json.Unmarshal(missing.Body.Bytes(), &verr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
errorsBody, _ := verr["errors"].(map[string]any)
|
|
if _, ok := errorsBody["email"]; !ok || verr["error"] == "" {
|
|
t.Fatalf("validation body %#v", verr)
|
|
}
|
|
}
|
|
|
|
func TestRegisterDisabled(t *testing.T) {
|
|
app, _ := sessionApp(t, "0s")
|
|
if err := app.Config.Set("golem15.user.registration.allow_registration", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Config.Set("app.debug", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"off@example.test","password":"correct-horse"}`)
|
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"error":"Internal server error"}` {
|
|
t.Fatalf("production %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if err := app.Config.Set("app.debug", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
debug := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"off@example.test","password":"correct-horse"}`)
|
|
if debug.Code != http.StatusInternalServerError || debug.Body.String() != `{"error":"Registrations are currently disabled."}` {
|
|
t.Fatalf("debug %d %s", debug.Code, debug.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRegisterThrottle(t *testing.T) {
|
|
app, gdb := sessionApp(t, "0s")
|
|
ip := "203.0.113.9"
|
|
for i := 1; i <= 3; i++ {
|
|
addr := ip
|
|
user := &models.User{Email: "throttle-reg-" + string(rune('a'+i-1)) + "@example.test", Password: "x", CreatedIPAddress: &addr}
|
|
if err := gdb.Create(user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
rec := postRegister(t, app, ip, `{"email":"fourth@example.test","password":"correct-horse"}`)
|
|
if rec.Code != http.StatusInternalServerError || rec.Body.String() != `{"error":"Internal server error"}` {
|
|
t.Fatalf("throttled %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if err := app.Config.Set("app.debug", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
debug := postRegister(t, app, ip, `{"email":"fifth@example.test","password":"correct-horse"}`)
|
|
if debug.Body.String() != `{"error":"Registration is throttled. Please try again later."}` {
|
|
t.Fatalf("debug throttle %d %s", debug.Code, debug.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRegisterUserModeMail(t *testing.T) {
|
|
app, _ := sessionApp(t, "0s")
|
|
disableRegisterThrottle(t, app)
|
|
if err := app.Config.Set("golem15.user.activation.activate_mode", "user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mail := &captureMail{}
|
|
if err := app.Publish[postcard.Mailer](mail); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"en-user@example.test","password":"correct-horse","preferred_locale":"en"}`)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Activation email sent"}` {
|
|
t.Fatalf("user mode %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(mail.got) != 1 || mail.got[0] != "golem15.user::mail.activate-en" {
|
|
t.Fatalf("template = %#v", mail.got)
|
|
}
|
|
}
|
|
|
|
func TestRegisterAdminMode(t *testing.T) {
|
|
app, _ := sessionApp(t, "0s")
|
|
disableRegisterThrottle(t, app)
|
|
if err := app.Config.Set("golem15.user.activation.activate_mode", "admin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Config.Set("golem15.user.activation.require_activation", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"admin-mode@example.test","password":"correct-horse"}`)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != `{}` {
|
|
t.Fatalf("admin mode %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRegisterEvent(t *testing.T) {
|
|
app, _ := sessionApp(t, "0s")
|
|
disableRegisterThrottle(t, app)
|
|
var seen string
|
|
app.Events.Listen[*classes.RegisterEvent]("test", func(_ context.Context, e *classes.RegisterEvent) error {
|
|
if e != nil && e.User != nil {
|
|
seen = e.User.Email
|
|
}
|
|
return nil
|
|
})
|
|
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"event@example.test","password":"correct-horse"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if seen != "event@example.test" {
|
|
t.Fatalf("event email = %q", seen)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestRegisterListenerErrors: failing register and created listeners are
|
|
// logged and neither fail nor undo the registration.
|
|
func TestRegisterListenerErrors(t *testing.T) {
|
|
app, gdb := sessionApp(t, "0s")
|
|
disableRegisterThrottle(t, app)
|
|
app.Events.Listen[*classes.RegisterEvent]("failing-register", func(context.Context, *classes.RegisterEvent) error {
|
|
return errors.New("register listener down")
|
|
})
|
|
app.Events.Listen[*classes.CreatedEvent]("failing-created", func(context.Context, *classes.CreatedEvent) error {
|
|
return errors.New("created listener down")
|
|
})
|
|
rec := postJSON(t, controllers.Register(app), "/_user/api/v1/register", `{"email":"listener-fail@example.test","password":"correct-horse"}`)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token"`) {
|
|
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var count int64
|
|
if err := gdb.Model(&models.User{}).Where("email = ?", "listener-fail@example.test").Count(&count).Error; err != nil || count != 1 {
|
|
t.Fatalf("stored users = %d, %v", count, err)
|
|
}
|
|
}
|
|
|
|
// TestRegisterIgnoresProtectedFields: a registration body sets only the
|
|
// RegisterFillable columns. Organisation membership, the IP columns and the
|
|
// account flags in the body are ignored, over HTTP and through RegisterUser,
|
|
// and the IP columns come only from the server-side address.
|
|
func TestRegisterIgnoresProtectedFields(t *testing.T) {
|
|
app, gdb := sessionApp(t, "0s")
|
|
disableRegisterThrottle(t, app)
|
|
org := &models.Organisation{Name: "Mass Assign Org", Slug: "mass-assign-org"}
|
|
if err := gdb.Create(org).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
protected := func(email string) map[string]any {
|
|
return map[string]any{
|
|
"email": email, "password": "correct-horse", "password_confirmation": "correct-horse", "name": "Mal", "surname": "Lory",
|
|
"preferred_locale": "pl", "marketing_consent": true,
|
|
"organisation_id": org.ID, "organisation_role": "owner",
|
|
"created_ip_address": "203.0.113.9", "last_ip_address": "203.0.113.9",
|
|
"is_onboarded": true, "must_change_password": true,
|
|
}
|
|
}
|
|
check := func(t *testing.T, email string, wantIP *string) {
|
|
t.Helper()
|
|
var stored models.User
|
|
if err := gdb.Where("email = ?", email).Take(&stored).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Name == nil || *stored.Name != "Mal" || stored.Surname == nil || *stored.Surname != "Lory" ||
|
|
stored.PreferredLocale == nil || *stored.PreferredLocale != "pl" || !stored.MarketingConsent {
|
|
t.Fatalf("allowed columns not stored: %+v", stored)
|
|
}
|
|
if stored.OrganisationID != nil || stored.OrganisationRole != nil {
|
|
t.Fatalf("organisation from the body: id %v role %v", stored.OrganisationID, stored.OrganisationRole)
|
|
}
|
|
if stored.IsOnboarded || stored.MustChangePassword {
|
|
t.Fatalf("account flags from the body: onboarded %v must_change %v", stored.IsOnboarded, stored.MustChangePassword)
|
|
}
|
|
for name, got := range map[string]*string{"created_ip_address": stored.CreatedIPAddress, "last_ip_address": stored.LastIPAddress} {
|
|
switch {
|
|
case wantIP == nil && got != nil:
|
|
t.Fatalf("%s = %q, want NULL", name, *got)
|
|
case wantIP != nil && (got == nil || *got != *wantIP):
|
|
t.Fatalf("%s = %v, want %q", name, got, *wantIP)
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("http", func(t *testing.T) {
|
|
body, err := json.Marshal(protected("mass-http@example.test"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := postRegister(t, app, "192.0.2.44", string(body))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
ip := "192.0.2.44"
|
|
check(t, "mass-http@example.test", &ip)
|
|
})
|
|
t.Run("RegisterUser with IP", func(t *testing.T) {
|
|
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-ip@example.test"), controllers.RegisterOptions{IP: "198.51.100.7"})
|
|
if err != nil || len(errs) != 0 {
|
|
t.Fatalf("RegisterUser = %v, %v", errs, err)
|
|
}
|
|
ip := "198.51.100.7"
|
|
check(t, "mass-ip@example.test", &ip)
|
|
})
|
|
t.Run("RegisterUser without IP", func(t *testing.T) {
|
|
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-noip@example.test"), controllers.RegisterOptions{})
|
|
if err != nil || len(errs) != 0 {
|
|
t.Fatalf("RegisterUser = %v, %v", errs, err)
|
|
}
|
|
check(t, "mass-noip@example.test", nil)
|
|
})
|
|
}
|
|
|
|
func TestRegisterImportDirection(t *testing.T) {
|
|
cmd := exec.Command("go", "list", "-deps", "./...")
|
|
cmd.Dir = moduleRoot(t)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go list: %v\n%s", err, out)
|
|
}
|
|
if strings.Contains(string(out), "plugins/golem15/fonoteka") {
|
|
t.Fatal("golem15.user imports golem15.fonoteka")
|
|
}
|
|
}
|
|
|
|
func TestRegisterCORSPath(t *testing.T) {
|
|
body, err := os.ReadFile(filepath.Join(appRoot(t), "config", "http.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(body), "_user/api/*") {
|
|
t.Fatal("cors paths missing _user/api/*")
|
|
}
|
|
}
|
|
|
|
func disableRegisterThrottle(t *testing.T, app *backpack.App) {
|
|
t.Helper()
|
|
if err := app.Config.Set("golem15.user.registration.use_register_throttle", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func postRegister(t *testing.T, app *backpack.App, ip, body string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/register", strings.NewReader(body))
|
|
req.Host = "example.com"
|
|
req.RemoteAddr = ip + ":9999"
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
controllers.Register(app)(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func moduleRoot(t *testing.T) string {
|
|
t.Helper()
|
|
return findUp(t, "go.mod")
|
|
}
|
|
|
|
func appRoot(t *testing.T) string {
|
|
t.Helper()
|
|
return findUp(t, filepath.Join("config", "http.yaml"))
|
|
}
|
|
|
|
func findUp(t *testing.T, rel string) string {
|
|
t.Helper()
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, rel)); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatalf("%s not found", rel)
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|