Files
summercms/modules/cabana/account_test.go
Jakub Zych 64d21c8caf feat(cabana): add backend user avatars to My account and the top bar
- cabana.BackendUser is an attach.Owner (MorphName Backend\Models\User,
  cabana.BackendUserMorphType) with one public attachOne avatar
  (cabana.BackendUserAvatarField), as WinterCMS stores it
- POST /account/avatar stores one file_data image (jpg, jpeg, png, gif or
  webp, image guard, at most cabana.AvatarMaxBytes = 2 MiB, under a
  server-generated name) and replaces the previous avatar: old rows are
  deleted in the transaction, their blobs after the commit
- DELETE /account/avatar removes the avatar and is idempotent
- GET /auth/me and the account routes return avatar {url, thumb_url}
  (250x250 crop); cabana.BackendUserAvatar lets plugins read it
- swag annotations, regenerated admin.json and schema.d.ts; route
  inventories, CSRF walk (29) and OpenAPI conformance learn both routes
- SPA: avatar upload and remove on My account, avatar image in the user
  menu trigger and header; backend::lang.myaccount avatar strings
- rebuilt boardwalk dist; cabana README, users-and-permissions and
  admin-spa docs
2026-10-09 17:45:43 +02:00

195 lines
6.4 KiB
Go

package cabana_test
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// accountStamp is a per-run suffix for logins and emails, so the shared
// harness database can hold every run's admins side by side.
func accountStamp() string {
return time.Now().Format("150405.000000000")
}
// putAccount sends PUT /account with a Bearer token.
func putAccount(t *testing.T, h http.Handler, token string, body any) *httpResult {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
rec := postAuth(t, h, http.MethodPut, adminAPI("/account"), token, raw)
return &httpResult{Code: rec.Code, Body: rec.Body.Bytes(), Header: rec.Header()}
}
// httpResult is a recorded response.
type httpResult struct {
Code int
Body []byte
Header http.Header
}
// profileOf decodes an AdminProfile envelope.
func profileOf(t *testing.T, raw []byte) cabana.AdminProfile {
t.Helper()
var body cabana.Envelope[cabana.AdminProfile]
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("profile json: %v body=%s", err, raw)
}
return body.Data
}
// signIn logs an admin in over Bearer transport and returns the token.
func signIn(t *testing.T, h http.Handler, login string) string {
t.Helper()
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
return accessToken(t, rec.Body.Bytes())
}
func TestAccountProfileSmoke(t *testing.T) {
gdb := adminGorm(t)
h := adminHandler(t, gdb, nil)
stamp := accountStamp()
login := "acct-smoke-" + stamp
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
res := putAccount(t, h, token, map[string]any{
"first_name": "Grace",
"last_name": "Hopper",
"email": "grace-" + stamp + "@example.test",
"subtitle": "Rear admiral",
})
if res.Code != http.StatusOK {
t.Fatalf("put status=%d body=%s", res.Code, res.Body)
}
if got := profileOf(t, res.Body); got.FirstName != "Grace" || got.Subtitle != "Rear admiral" {
t.Fatalf("put profile = %+v", got)
}
me := getAuth(t, h, adminAPI("/auth/me"), token)
if me.Code != http.StatusOK {
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
}
got := profileOf(t, me.Body.Bytes())
if got.FirstName != "Grace" || got.LastName != "Hopper" || got.Email != "grace-"+stamp+"@example.test" || got.Subtitle != "Rear admiral" || got.Login != login {
t.Fatalf("me profile = %+v", got)
}
refused := putAccount(t, h, token, map[string]any{"email": "grace-" + stamp + "@example.test", "is_superuser": true})
if refused.Code != http.StatusUnprocessableEntity {
t.Fatalf("is_superuser status=%d body=%s", refused.Code, refused.Body)
}
var superuser bool
if err := gdb.Raw(`SELECT is_superuser FROM backend_users WHERE login = ?`, login).Scan(&superuser).Error; err != nil || superuser {
t.Fatalf("is_superuser=%v err=%v", superuser, err)
}
}
// avatarHandler assembles the admin router like adminHandler, with a memblob
// attachment bucket published and an 8 MiB upload cap. withBucket false
// leaves the bucket out.
func avatarHandler(t *testing.T, gdb *gorm.DB, withBucket bool) (http.Handler, *blob.Bucket) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1 << 20, "http.body_limits.upload_bytes": 8 << 20} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
var bucket *blob.Bucket
if withBucket {
bucket = memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
}
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
if err != nil {
t.Fatal(err)
}
return h, bucket
}
// uploadAvatar posts one multipart part (name field, file name name) to
// POST /account/avatar with a Bearer token.
func uploadAvatar(t *testing.T, h http.Handler, token, field, name string, data []byte) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile(field, 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)
}
req := httptest.NewRequest(http.MethodPost, adminAPI("/account/avatar"), &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAccountAvatarSmoke(t *testing.T) {
gdb := adminGorm(t)
h, _ := avatarHandler(t, gdb, true)
login := "acct-avatar-" + accountStamp()
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
up := uploadAvatar(t, h, token, "file_data", "me.png", conformPNG(t))
if up.Code != http.StatusOK {
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
}
me := getAuth(t, h, adminAPI("/auth/me"), token)
got := profileOf(t, me.Body.Bytes())
if got.Avatar == nil || got.Avatar.URL == "" || got.Avatar.ThumbURL == "" {
t.Fatalf("me avatar = %+v body=%s", got.Avatar, me.Body.String())
}
del := postAuth(t, h, http.MethodDelete, adminAPI("/account/avatar"), token, nil)
if del.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", del.Code, del.Body.String())
}
me = getAuth(t, h, adminAPI("/auth/me"), token)
if got := profileOf(t, me.Body.Bytes()); got.Avatar != nil {
t.Fatalf("avatar after delete = %+v", got.Avatar)
}
}