- 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
195 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|