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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user