feat: create users with a password and an invitation in the admin

- password with confirmation on create, optional reset on update that
  ends the user's sessions; the hash is never returned
- send_invite sends one invitation with an activation link after the
  create commits
- organisation picker, avatar upload shared with the user API and the
  frontend permission editor on the user form
- an email held by a deactivated user answers 422 instead of failing
  on the unique constraint
- the privileged-member guard now also refuses a submitted password
This commit is contained in:
Jakub Zych
2026-10-05 13:21:51 +02:00
parent cdea47a178
commit f7ca3c4f59
12 changed files with 1080 additions and 16 deletions

View File

@@ -3,15 +3,20 @@ package user
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -67,6 +72,7 @@ type adminEnv struct {
app *backpack.App
gdb *gorm.DB
bucket *blob.Bucket
mail *mailCapture
}
// adminAPI builds a path under the admin API prefix.
@@ -143,6 +149,10 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
for key, value := range map[string]any{
"http.body_limits.default_bytes": 1048576,
"http.body_limits.upload_bytes": 8388608,
// The log driver renders every mail and hands it to the app logger,
// which mailCapture reads.
"mail.driver": "log",
"mail.from": "noreply@example.test",
} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
@@ -152,6 +162,10 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
mail := &mailCapture{next: slog.Default().Handler()}
if err := app.Publish(slog.New(mail)); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
@@ -174,7 +188,7 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
if err != nil {
t.Fatal(err)
}
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket}
return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail}
}
// admin inserts a backend role holding exactly permissions and a backend
@@ -227,27 +241,132 @@ func (e *adminEnv) admin(t *testing.T, permissions ...string) string {
// throttle never trips inside one test.
func (e *adminEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
var raw []byte
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
return e.send(t, method, path, token, raw, "application/json", nil)
}
// send is call with a raw body, its content type and extra headers (for
// example the form session key of a file upload).
func (e *adminEnv) send(t *testing.T, method, path, token string, body []byte, contentType string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
req.Host = "example.com"
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", contentType)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for name, value := range headers {
req.Header.Set(name, value)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// sentMail is one message the application sent, as the log mail driver
// reports it: rendered, with the text part.
type sentMail struct {
To []string
Subject string
Text string
}
// mailCapture is the slog handler behind the app logger: it keeps the
// records of the log mail driver and passes everything else on.
type mailCapture struct {
mu sync.Mutex
sent []sentMail
next slog.Handler
}
func (m *mailCapture) Enabled(context.Context, slog.Level) bool { return true }
func (m *mailCapture) Handle(ctx context.Context, record slog.Record) error {
if record.Message != "postcard mail" {
if m.next != nil && m.next.Enabled(ctx, record.Level) {
return m.next.Handle(ctx, record)
}
return nil
}
var mail sentMail
record.Attrs(func(attr slog.Attr) bool {
switch attr.Key {
case "to":
mail.To, _ = attr.Value.Any().([]string)
case "subject":
mail.Subject = attr.Value.String()
case "text":
mail.Text = attr.Value.String()
}
return true
})
m.mu.Lock()
m.sent = append(m.sent, mail)
m.mu.Unlock()
return nil
}
func (m *mailCapture) WithAttrs([]slog.Attr) slog.Handler { return m }
func (m *mailCapture) WithGroup(string) slog.Handler { return m }
// mails returns the messages sent to the address since the env booted.
func (e *adminEnv) mails(to string) []sentMail {
e.mail.mu.Lock()
defer e.mail.mu.Unlock()
var out []sentMail
for _, mail := range e.mail.sent {
for _, recipient := range mail.To {
if recipient == to {
out = append(out, mail)
}
}
}
return out
}
// adminSessionKey returns a fresh form session key, as the admin SPA makes
// one when it opens a form.
func adminSessionKey(t *testing.T) string {
t.Helper()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
// upload posts one file to a fileupload field of a user's form under a form
// session key; the next save sent with the same key attaches it.
func (e *adminEnv) upload(t *testing.T, token string, id uint, field, name string, data []byte, sessionKey string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file_data", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
return e.send(t, http.MethodPost, userPath(id, "/files/"+field), token, buf.Bytes(), mw.FormDataContentType(),
map[string]string{cabana.SessionKeyHeader: sessionKey})
}
// expect is call plus a status assertion.
func (e *adminEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
@@ -525,12 +644,14 @@ func (e *adminEnv) userLogin(t *testing.T, email, password string) (int, string)
return rec.Code, body.Token
}
// secretKey matches a JSON key that names a password or a one-time code.
var secretKey = regexp.MustCompile(`(?i)"[a-z_]*(password|activation_code|reset_password_code)[a-z_]*"\s*:`)
// assertNoSecret fails when an admin response carries a password key, a
// stored hash or the given plain text.
func assertNoSecret(t *testing.T, raw string, plain ...string) {
t.Helper()
lower := strings.ToLower(raw)
if strings.Contains(lower, `"password`) || strings.Contains(raw, "$2a$") || strings.Contains(raw, "$2y$") {
if secretKey.MatchString(raw) || strings.Contains(raw, "$2a$") || strings.Contains(raw, "$2y$") {
t.Fatalf("admin response carries a password: %s", raw)
}
for _, secret := range plain {