Files
summercms/modules/cabana/account_test.go
Jakub Zych 89611eaaf9 test(cabana): cover My account profile, password and avatar
- account_internal_test: metadata subtitle reader and writer (NULL, blank,
  null, [], invalid JSON, non-objects, key preservation, NULL on the last
  key), the attachOne contract, avatar cap, unique-violation mapping,
  English fallbacks, and 401 for non-backend principals on all three routes
- account_test (Postgres): profile payload incl. copied WinterCMS metadata
  and avatar rows, trimmed profile saves and metadata keys, the forbidden-key
  allow-list with unchanged rows, email rules, password rules, the failed
  attempt log, Bearer and cookie session effects, CSRF, avatar upload,
  refusals (SVG, HTML as PNG, size, part shape, 413, no bucket) with no row
  or blob left, replace and remove with blob cleanup
- lagoon: TestBackendAdminMetadataColumn covers type, Winter row round trip,
  rollback of only the column and re-migrate
- SPA: AccountView, the user menu account item and avatar image,
  setCurrentUser and profileInitials; lang fixture gains myaccount keys
2026-10-09 17:59:27 +02:00

1139 lines
44 KiB
Go

package cabana_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"image"
"image/color"
"image/gif"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"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/phrasebook"
"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
}
// validationDetails decodes the details of an error envelope.
func validationDetails(t *testing.T, raw []byte) map[string][]string {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("error json: %v body=%s", err, raw)
}
if body.Error.Code != "validation_failed" {
t.Fatalf("error code %q body=%s", body.Error.Code, raw)
}
return body.Error.Details
}
// 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()
return signInWith(t, h, login, adminTestPassword)
}
func signInWith(t *testing.T, h http.Handler, login, password string) string {
t.Helper()
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": password})
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
return accessToken(t, rec.Body.Bytes())
}
// accountHandler assembles the admin router like adminHandler, with the
// phrasebook translator active, a memblob attachment bucket when withBucket
// is set and uploadBytes as http.body_limits.upload_bytes.
func accountHandler(t *testing.T, gdb *gorm.DB, withBucket bool, uploadBytes int) (http.Handler, *blob.Bucket) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\nlocale: en\nfallback_locale: en\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)
}
// These tests sign many admins in from one address: lift the login
// throttle so it never answers 429.
for key, value := range map[string]any{"http.body_limits.default_bytes": 1 << 20, "http.body_limits.upload_bytes": uploadBytes, "admin.login.max_attempts": 1000} {
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)
}
}
plugins := []party.Plugin{demoPlugin{fsys: demoFS()}}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
return h, bucket
}
// avatarHandler is accountHandler with an 8 MiB upload cap.
func avatarHandler(t *testing.T, gdb *gorm.DB, withBucket bool) (http.Handler, *blob.Bucket) {
t.Helper()
return accountHandler(t, gdb, withBucket, 8<<20)
}
// multipartBody builds a multipart body; each part is {field, file name, data}.
func multipartBody(t *testing.T, parts ...[3]string) ([]byte, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for _, p := range parts {
w, err := mw.CreateFormFile(p[0], p[1])
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(w, p[2]); err != nil {
t.Fatal(err)
}
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes(), mw.FormDataContentType()
}
// sendRaw sends a raw body with a content type and a Bearer token.
func sendRaw(t *testing.T, h http.Handler, method, path, token string, body []byte, contentType string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// 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()
body, contentType := multipartBody(t, [3]string{field, name, string(data)})
return sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType)
}
// gifBytes is a small valid GIF.
func gifBytes(t *testing.T) []byte {
t.Helper()
img := image.NewPaletted(image.Rect(0, 0, 3, 3), color.Palette{color.Black, color.White})
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// avatarRows are the user's avatar rows in system_files.
func avatarRows(t *testing.T, gdb *gorm.DB, userID uint) []attach.File {
t.Helper()
var rows []attach.File
if err := gdb.Where("attachment_type = ? AND field = ? AND attachment_id = ?", cabana.BackendUserMorphType, cabana.BackendUserAvatarField, itoa(userID)).Order("id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// bucketKeys lists every key under prefix.
func bucketKeys(t *testing.T, bucket *blob.Bucket, prefix string) []string {
t.Helper()
var keys []string
iter := bucket.List(&blob.ListOptions{Prefix: prefix})
for {
obj, err := iter.Next(context.Background())
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
keys = append(keys, obj.Key)
}
return keys
}
// userRow is the raw backend_users columns My account must never touch
// outside its allow-list.
type userRow struct {
FirstName string
LastName string
Login string
Email string
Password string
Permissions sql.NullString
Metadata sql.NullString
IsActivated bool
IsSuperuser bool
RoleID sql.NullInt64
UpdatedAt time.Time
TokensValid sql.NullTime
}
func rawUser(t *testing.T, gdb *gorm.DB, id uint) userRow {
t.Helper()
var row userRow
if err := gdb.Raw(`SELECT first_name, last_name, login, email, password, permissions, metadata, is_activated, is_superuser, role_id, updated_at, tokens_valid_after AS tokens_valid
FROM backend_users WHERE id = ?`, id).Scan(&row).Error; err != nil {
t.Fatal(err)
}
return row
}
func setMetadata(t *testing.T, gdb *gorm.DB, id uint, raw any) {
t.Helper()
if err := gdb.Exec(`UPDATE backend_users SET metadata = ? WHERE id = ?`, raw, id).Error; err != nil {
t.Fatal(err)
}
}
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)
}
}
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)
}
}
// TestAuthMeProfilePayload covers the subtitle and avatar keys of GET
// /auth/me, and admins whose copied metadata is not a JSON object.
func TestAuthMeProfilePayload(t *testing.T) {
gdb := adminGorm(t)
h, bucket := avatarHandler(t, gdb, true)
stamp := accountStamp()
rawData := func(t *testing.T, token string) map[string]json.RawMessage {
t.Helper()
rec := getAuth(t, h, adminAPI("/auth/me"), token)
if rec.Code != http.StatusOK {
t.Fatalf("me status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Data
}
t.Run("fresh admin has an empty subtitle and no avatar", func(t *testing.T) {
login := "me-fresh-" + stamp
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
data := rawData(t, signIn(t, h, login))
if string(data["subtitle"]) != `""` {
t.Fatalf("subtitle = %s", data["subtitle"])
}
if _, ok := data["avatar"]; ok {
t.Fatalf("fresh admin has an avatar key: %s", data["avatar"])
}
})
t.Run("winter metadata", func(t *testing.T) {
login := "me-winter-" + stamp
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
setMetadata(t, gdb, user.ID, `{"subtitle":"Editor","x":1}`)
data := rawData(t, signIn(t, h, login))
if string(data["subtitle"]) != `"Editor"` {
t.Fatalf("subtitle = %s", data["subtitle"])
}
})
for name, raw := range map[string]any{"empty array": "[]", "invalid json": "{broken", "blank": "", "null": nil, "number value": `{"subtitle":5}`} {
t.Run("metadata "+name+" still signs in", func(t *testing.T) {
login := "me-odd-" + strings.ReplaceAll(name, " ", "-") + "-" + stamp
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
setMetadata(t, gdb, user.ID, raw)
data := rawData(t, signIn(t, h, login))
if string(data["subtitle"]) != `""` {
t.Fatalf("subtitle = %s", data["subtitle"])
}
})
}
t.Run("an avatar row copied from WinterCMS is picked up", func(t *testing.T) {
login := "me-copied-" + stamp
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
disk := "c0p1ed" + strings.ReplaceAll(stamp, ".", "") + ".png"
if err := bucket.WriteAll(context.Background(), attach.BlobKey(disk), conformPNG(t), nil); err != nil {
t.Fatal(err)
}
public := true
row := attach.File{DiskName: disk, FileName: "portrait.png", FileSize: int64(len(conformPNG(t))), ContentType: "image/png", Field: "avatar", AttachmentID: itoa(user.ID), AttachmentType: `Backend\Models\User`, IsPublic: &public}
if err := gdb.Create(&row).Error; err != nil {
t.Fatal(err)
}
token := signIn(t, h, login)
got := profileOf(t, getAuth(t, h, adminAPI("/auth/me"), token).Body.Bytes())
if got.Avatar == nil || !strings.Contains(got.Avatar.URL, disk) || !strings.Contains(got.Avatar.ThumbURL, "thumb_") {
t.Fatalf("copied avatar = %+v", got.Avatar)
}
found, err := cabana.BackendUserAvatar(context.Background(), gdb, user.ID)
if err != nil || found == nil || found.ID != row.ID {
t.Fatalf("BackendUserAvatar = %+v, %v", found, err)
}
})
t.Run("a protected avatar row has no public URL", func(t *testing.T) {
login := "me-protected-" + stamp
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
private := false
row := attach.File{DiskName: "pr0tect" + strings.ReplaceAll(stamp, ".", "") + ".png", FileName: "secret.png", ContentType: "image/png", Field: "avatar", AttachmentID: itoa(user.ID), AttachmentType: cabana.BackendUserMorphType, IsPublic: &private}
if err := gdb.Create(&row).Error; err != nil {
t.Fatal(err)
}
data := rawData(t, signIn(t, h, login))
if _, ok := data["avatar"]; ok {
t.Fatalf("protected avatar was exposed: %s", data["avatar"])
}
})
}
// TestAccountProfileUpdate covers the saved and trimmed profile, the
// metadata key handling and updated_at.
func TestAccountProfileUpdate(t *testing.T) {
gdb := adminGorm(t)
h, _ := avatarHandler(t, gdb, false)
stamp := accountStamp()
login := "acct-update-" + stamp
email := login + "@example.test"
user := insertAdmin(t, gdb, login, email, adminTestPassword, true, false)
setMetadata(t, gdb, user.ID, `{"subtitle":"Old","x":1}`)
token := signIn(t, h, login)
before := rawUser(t, gdb, user.ID)
time.Sleep(20 * time.Millisecond)
t.Run("names, email and subtitle are trimmed and saved", func(t *testing.T) {
res := putAccount(t, h, token, map[string]any{
"first_name": " Grace ",
"last_name": " Hopper",
"email": " " + email + " ",
"subtitle": " Rear admiral ",
})
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
got := profileOf(t, res.Body)
if got.FirstName != "Grace" || got.LastName != "Hopper" || got.Email != email || got.Subtitle != "Rear admiral" || got.ID != user.ID || got.Login != login {
t.Fatalf("profile = %+v", got)
}
row := rawUser(t, gdb, user.ID)
var meta map[string]any
if err := json.Unmarshal([]byte(row.Metadata.String), &meta); err != nil {
t.Fatalf("metadata %q: %v", row.Metadata.String, err)
}
if meta["subtitle"] != "Rear admiral" || meta["x"] != float64(1) || len(meta) != 2 {
t.Fatalf("metadata = %v", meta)
}
if !row.UpdatedAt.After(before.UpdatedAt) {
t.Fatalf("updated_at %v did not advance from %v", row.UpdatedAt, before.UpdatedAt)
}
if row.Password != before.Password || row.TokensValid.Valid {
t.Fatal("a profile save without a password changed the password or the token cutoff")
}
})
t.Run("an empty subtitle removes the key and keeps the others", func(t *testing.T) {
res := putAccount(t, h, token, map[string]any{"first_name": "Grace", "last_name": "Hopper", "email": email, "subtitle": ""})
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if row := rawUser(t, gdb, user.ID); row.Metadata.String != `{"x":1}` {
t.Fatalf("metadata = %q", row.Metadata.String)
}
if got := profileOf(t, res.Body); got.Subtitle != "" {
t.Fatalf("subtitle = %q", got.Subtitle)
}
})
t.Run("absent and null keys save empty values", func(t *testing.T) {
if res := putAccount(t, h, token, map[string]any{"subtitle": "Back", "email": email}); res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
res := putAccount(t, h, token, map[string]any{"email": email, "first_name": nil})
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
got := profileOf(t, res.Body)
if got.FirstName != "" || got.LastName != "" || got.Subtitle != "" {
t.Fatalf("profile = %+v", got)
}
})
t.Run("the last key cleared stores NULL", func(t *testing.T) {
setMetadata(t, gdb, user.ID, `{"subtitle":"Only"}`)
if res := putAccount(t, h, token, map[string]any{"email": email}); res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if row := rawUser(t, gdb, user.ID); row.Metadata.Valid {
t.Fatalf("metadata = %q, want NULL", row.Metadata.String)
}
})
t.Run("metadata that is not an object is never overwritten", func(t *testing.T) {
setMetadata(t, gdb, user.ID, `"legacy text"`)
ok := putAccount(t, h, token, map[string]any{"email": email, "first_name": "Kept"})
if ok.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", ok.Code, ok.Body)
}
refused := putAccount(t, h, token, map[string]any{"email": email, "subtitle": "New"})
if refused.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", refused.Code, refused.Body)
}
if details := validationDetails(t, refused.Body); len(details["subtitle"]) != 1 {
t.Fatalf("details = %v", details)
}
if row := rawUser(t, gdb, user.ID); row.Metadata.String != `"legacy text"` || row.FirstName != "Kept" {
t.Fatalf("row = %+v", row)
}
})
t.Run("field rules", func(t *testing.T) {
long := strings.Repeat("a", 256)
for name, body := range map[string]map[string]any{
"first_name": {"email": email, "first_name": long},
"last_name": {"email": email, "last_name": 5},
"subtitle": {"email": email, "subtitle": long},
} {
res := putAccount(t, h, token, body)
if res.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s status=%d body=%s", name, res.Code, res.Body)
}
if details := validationDetails(t, res.Body); len(details[name]) == 0 {
t.Fatalf("%s details = %v", name, details)
}
}
})
t.Run("malformed and oversized bodies", func(t *testing.T) {
for _, raw := range []string{`{`, `[]`, `{"email":"x"} {}`, `"text"`} {
rec := postAuth(t, h, http.MethodPut, adminAPI("/account"), token, []byte(raw))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s status=%d body=%s", raw, rec.Code, rec.Body.String())
}
}
huge, _ := json.Marshal(map[string]string{"email": email, "subtitle": strings.Repeat("x", 2<<20)})
rec := postAuth(t, h, http.MethodPut, adminAPI("/account"), token, huge)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("huge status=%d", rec.Code)
}
})
}
// TestAccountForbiddenKeys covers the server-side allow-list and the
// guarantee that only the token subject's row is written.
func TestAccountForbiddenKeys(t *testing.T) {
gdb := adminGorm(t)
h, _ := avatarHandler(t, gdb, false)
stamp := accountStamp()
login := "acct-keys-" + stamp
email := login + "@example.test"
user := insertAdmin(t, gdb, login, email, adminTestPassword, true, false)
other := insertAdmin(t, gdb, "acct-keys-other-"+stamp, "acct-keys-other-"+stamp+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
var publisherID uint
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 {
t.Fatalf("publisher role: %d %v", publisherID, err)
}
before := rawUser(t, gdb, user.ID)
for key, value := range map[string]any{
"login": "hijack-" + stamp,
"role_id": publisherID,
"is_superuser": true,
"permissions": map[string]any{"acme.demo.access": 1},
"is_activated": false,
"id": other.ID,
"password_hash": "x",
"metadata": map[string]any{"subtitle": "x"},
} {
t.Run(key, func(t *testing.T) {
res := putAccount(t, h, token, map[string]any{"email": email, "first_name": "Changed", key: value})
if res.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
details := validationDetails(t, res.Body)
if len(details) != 1 || len(details[key]) != 1 || details[key][0] != "This field cannot be changed on My account." {
t.Fatalf("details = %v", details)
}
if after := rawUser(t, gdb, user.ID); after != before {
t.Fatalf("row changed:\nbefore %+v\nafter %+v", before, after)
}
})
}
t.Run("refusals are translated", func(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"email": email, "login": "x"})
req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept-Language", "pl")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if details := validationDetails(t, rec.Body.Bytes()); len(details["login"]) != 1 || details["login"][0] != "Tego pola nie można zmienić w ustawieniach konta." {
t.Fatalf("details = %v", details)
}
})
t.Run("only the token subject is written", func(t *testing.T) {
otherBefore := rawUser(t, gdb, other.ID)
res := putAccount(t, h, token, map[string]any{"email": email, "first_name": "Mine", "subtitle": "Me"})
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if got := profileOf(t, res.Body); got.ID != user.ID || got.FirstName != "Mine" {
t.Fatalf("profile = %+v", got)
}
if after := rawUser(t, gdb, other.ID); after != otherBefore {
t.Fatalf("other admin changed:\nbefore %+v\nafter %+v", otherBefore, after)
}
if row := rawUser(t, gdb, user.ID); row.Login != login || row.IsSuperuser || row.RoleID != before.RoleID || row.Permissions != before.Permissions || !row.IsActivated {
t.Fatalf("protected columns changed: %+v", row)
}
})
}
// TestAccountEmailRules covers the email rules of PUT /account.
func TestAccountEmailRules(t *testing.T) {
gdb := adminGorm(t)
h, _ := avatarHandler(t, gdb, false)
stamp := accountStamp()
login := "acct-email-" + stamp
email := login + "@example.test"
insertAdmin(t, gdb, login, email, adminTestPassword, true, false)
other := insertAdmin(t, gdb, "acct-email-other-"+stamp, "Other-"+stamp+"@Example.Test", adminTestPassword, true, false)
loginLike := insertAdmin(t, gdb, "login-"+stamp+"@example.test", "login-owner-"+stamp+"@example.test", adminTestPassword, true, false)
deleted := insertAdmin(t, gdb, "acct-email-gone-"+stamp, "gone-"+stamp+"@example.test", adminTestPassword, true, true)
token := signIn(t, h, login)
for name, value := range map[string]any{
"missing": nil,
"blank": " ",
"invalid": "not-an-email",
"short": "a@b.c",
"too long": strings.Repeat("a", 250) + "@example.test",
"another admin": strings.ToUpper(other.Email),
"another admin login": strings.ToUpper(loginLike.Login),
"a deleted admin": deleted.Email,
} {
t.Run(name, func(t *testing.T) {
body := map[string]any{"first_name": "X"}
if value != nil {
body["email"] = value
}
res := putAccount(t, h, token, body)
if res.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if details := validationDetails(t, res.Body); len(details["email"]) == 0 {
t.Fatalf("details = %v", details)
}
var stored string
if err := gdb.Raw(`SELECT email FROM backend_users WHERE login = ?`, login).Scan(&stored).Error; err != nil || stored != email {
t.Fatalf("email = %q %v", stored, err)
}
})
}
t.Run("taken message", func(t *testing.T) {
res := putAccount(t, h, token, map[string]any{"email": other.Email})
if details := validationDetails(t, res.Body); details["email"][0] != "The email has already been taken." {
t.Fatalf("details = %v", details)
}
})
t.Run("own email in another case is accepted", func(t *testing.T) {
res := putAccount(t, h, token, map[string]any{"email": strings.ToUpper(email)})
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if got := profileOf(t, res.Body); got.Email != strings.ToUpper(email) {
t.Fatalf("email = %q", got.Email)
}
})
}
// cookieLogin signs in over cookie transport and returns the session cookie.
func cookieLogin(t *testing.T, h http.Handler, login string) *http.Cookie {
t.Helper()
raw, _ := json.Marshal(map[string]string{"login": login, "password": adminTestPassword})
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/login"), bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if c := sessionCookie(rec); c != nil && rec.Code == http.StatusOK {
return c
}
t.Fatalf("cookie login status=%d body=%s", rec.Code, rec.Body.String())
return nil
}
func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == cabana.AdminCookieName && c.Value != "" && c.MaxAge > 0 {
return c
}
}
return nil
}
func cookieRequest(t *testing.T, h http.Handler, method, path string, cookie *http.Cookie, body any, ajax bool) *httptest.ResponseRecorder {
t.Helper()
var payload []byte
if body != nil {
payload, _ = json.Marshal(body)
}
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if ajax {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
}
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// nextSecond sleeps into the next wall-clock second, so a token minted
// before the call has an earlier iat than anything after it.
func nextSecond() {
now := time.Now()
time.Sleep(now.Truncate(time.Second).Add(time.Second).Sub(now) + 5*time.Millisecond)
}
// TestAccountPasswordChange covers the password rules, the failed-attempt
// log and the session effects of a password change.
func TestAccountPasswordChange(t *testing.T) {
gdb := adminGorm(t)
h, _ := avatarHandler(t, gdb, false)
stamp := accountStamp()
const newPassword = "brand-new-secret"
newAdmin := func(t *testing.T, suffix string) (cabana.BackendUser, string) {
t.Helper()
login := "acct-pw-" + suffix + "-" + stamp
return insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false), login + "@example.test"
}
refusals := map[string]struct {
body map[string]any
field string
}{
"missing current password": {map[string]any{"password": newPassword, "password_confirmation": newPassword}, "current_password"},
"mismatched confirmation": {map[string]any{"current_password": adminTestPassword, "password": newPassword, "password_confirmation": "other"}, "password"},
"too short": {map[string]any{"current_password": adminTestPassword, "password": "abc", "password_confirmation": "abc"}, "password"},
"over 72 bytes": {map[string]any{"current_password": adminTestPassword, "password": strings.Repeat("ż", 40), "password_confirmation": strings.Repeat("ż", 40)}, "password"},
"current password not a string": {map[string]any{"current_password": 5, "password": newPassword, "password_confirmation": newPassword}, "current_password"},
}
for name, tc := range refusals {
t.Run(name, func(t *testing.T) {
user, email := newAdmin(t, strings.ReplaceAll(name, " ", "-"))
token := signIn(t, h, user.Login)
before := rawUser(t, gdb, user.ID)
body := map[string]any{"email": email}
for k, v := range tc.body {
body[k] = v
}
res := putAccount(t, h, token, body)
if res.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if details := validationDetails(t, res.Body); len(details[tc.field]) == 0 {
t.Fatalf("details = %v", details)
}
if after := rawUser(t, gdb, user.ID); after.Password != before.Password || after.TokensValid.Valid {
t.Fatal("a refused change wrote the password or the cutoff")
}
})
}
t.Run("wrong current password is logged", func(t *testing.T) {
user, email := newAdmin(t, "wrong")
token := signIn(t, h, user.Login)
before := rawUser(t, gdb, user.ID)
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
res := putAccount(t, h, token, map[string]any{"email": email, "current_password": "not-the-password", "password": newPassword, "password_confirmation": newPassword})
slog.SetDefault(prev)
if res.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if details := validationDetails(t, res.Body); len(details["current_password"]) != 1 || details["current_password"][0] != "The current password is incorrect." {
t.Fatalf("details = %v", details)
}
assertLog(t, &buf, "failed", user.ID, "not-the-password", token)
if strings.Contains(buf.String(), newPassword) {
t.Fatalf("log contains the new password: %s", buf.String())
}
if after := rawUser(t, gdb, user.ID); after.Password != before.Password {
t.Fatal("the hash changed after a wrong current password")
}
})
t.Run("success over Bearer ends every other session", func(t *testing.T) {
user, email := newAdmin(t, "bearer")
earlier := signIn(t, h, user.Login)
nextSecond()
current := signIn(t, h, user.Login)
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
res := putAccount(t, h, current, map[string]any{"email": email, "current_password": adminTestPassword, "password": newPassword, "password_confirmation": newPassword})
slog.SetDefault(prev)
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body)
}
if c := sessionCookieOf(res.Header); c != nil {
t.Fatal("a Bearer request received a session cookie")
}
assertLog(t, &buf, "password_changed", user.ID, newPassword, current)
row := rawUser(t, gdb, user.ID)
if !bouncer.CheckPassword(row.Password, newPassword) || bouncer.CheckPassword(row.Password, adminTestPassword) {
t.Fatal("the stored hash does not match the new password")
}
if !row.TokensValid.Valid || row.TokensValid.Time.Nanosecond() != 0 || time.Since(row.TokensValid.Time) > time.Minute {
t.Fatalf("tokens_valid_after = %+v", row.TokensValid)
}
for name, token := range map[string]string{"earlier session": earlier, "changing session": current} {
if rec := getAuth(t, h, adminAPI("/auth/me"), token); rec.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d", name, rec.Code)
}
}
if rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": user.Login, "password": adminTestPassword}); rec.Code != http.StatusUnauthorized {
t.Fatalf("old password login status=%d", rec.Code)
}
fresh := signInWith(t, h, user.Login, newPassword)
if rec := getAuth(t, h, adminAPI("/auth/me"), fresh); rec.Code != http.StatusOK {
t.Fatalf("fresh session status=%d", rec.Code)
}
})
t.Run("success over cookie keeps the browser signed in", func(t *testing.T) {
user, email := newAdmin(t, "cookie")
old := cookieLogin(t, h, user.Login)
rec := cookieRequest(t, h, http.MethodPut, adminAPI("/account"), old, map[string]any{"email": email, "current_password": adminTestPassword, "password": newPassword, "password_confirmation": newPassword}, true)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
next := sessionCookie(rec)
if next == nil || next.Value == old.Value || !next.HttpOnly || next.SameSite != http.SameSiteStrictMode {
t.Fatalf("fresh cookie = %+v", next)
}
if strings.Contains(rec.Body.String(), next.Value) {
t.Fatal("the cookie token leaked into the body")
}
if me := cookieRequest(t, h, http.MethodGet, adminAPI("/auth/me"), next, nil, false); me.Code != http.StatusOK {
t.Fatalf("fresh cookie /auth/me status=%d", me.Code)
}
if me := cookieRequest(t, h, http.MethodGet, adminAPI("/auth/me"), old, nil, false); me.Code != http.StatusUnauthorized {
t.Fatalf("old cookie /auth/me status=%d", me.Code)
}
})
t.Run("cookie request without the CSRF header is refused", func(t *testing.T) {
user, email := newAdmin(t, "csrf")
cookie := cookieLogin(t, h, user.Login)
before := rawUser(t, gdb, user.ID)
rec := cookieRequest(t, h, http.MethodPut, adminAPI("/account"), cookie, map[string]any{"email": email, "first_name": "Forged"}, false)
if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if after := rawUser(t, gdb, user.ID); after != before {
t.Fatal("a forged request wrote the row")
}
})
}
func sessionCookieOf(header http.Header) *http.Cookie {
rec := httptest.NewRecorder()
for _, v := range header.Values("Set-Cookie") {
rec.Header().Add("Set-Cookie", v)
}
return sessionCookie(rec)
}
// TestAccountAvatarUpload covers the stored row, the generated storage name
// and the URLs on the profile.
func TestAccountAvatarUpload(t *testing.T) {
gdb := adminGorm(t)
h, bucket := avatarHandler(t, gdb, true)
login := "acct-av-up-" + accountStamp()
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
up := uploadAvatar(t, h, token, "file_data", "../../evil.png", conformPNG(t))
if up.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", up.Code, up.Body.String())
}
rows := avatarRows(t, gdb, user.ID)
if len(rows) != 1 {
t.Fatalf("avatar rows = %d", len(rows))
}
f := rows[0]
if f.AttachmentType != `Backend\Models\User` || f.Field != "avatar" || f.AttachmentID != itoa(user.ID) || !f.Public() || f.ContentType != "image/png" {
t.Fatalf("row = %+v", f)
}
if f.FileName != "evil.png" || strings.Contains(f.DiskName, "/") || strings.Contains(f.DiskName, "evil") || strings.Contains(f.DiskName, "..") {
t.Fatalf("names: file_name=%q disk_name=%q", f.FileName, f.DiskName)
}
key := attach.BlobKey(f.DiskName)
if !strings.HasPrefix(key, attach.PartitionDirectory(f.DiskName)) || strings.Contains(key, "..") {
t.Fatalf("blob key %q", key)
}
if ok, err := bucket.Exists(context.Background(), key); err != nil || !ok {
t.Fatalf("original blob exists=%v err=%v", ok, err)
}
uploaded := profileOf(t, up.Body.Bytes())
me := profileOf(t, getAuth(t, h, adminAPI("/auth/me"), token).Body.Bytes())
for name, p := range map[string]cabana.AdminProfile{"upload": uploaded, "me": me} {
if p.Avatar == nil || p.Avatar.URL != f.URL() || p.Avatar.ThumbURL == "" || !strings.Contains(p.Avatar.ThumbURL, "thumb_") || !strings.Contains(p.Avatar.ThumbURL, "250_250") {
t.Fatalf("%s avatar = %+v (url %s)", name, p.Avatar, f.URL())
}
}
found, err := cabana.BackendUserAvatar(context.Background(), gdb, user.ID)
if err != nil || found == nil || found.ID != f.ID {
t.Fatalf("BackendUserAvatar = %+v, %v", found, err)
}
}
// TestAccountAvatarRefusals covers what the upload refuses; no refusal
// leaves a row or a blob behind.
func TestAccountAvatarRefusals(t *testing.T) {
gdb := adminGorm(t)
h, bucket := avatarHandler(t, gdb, true)
stamp := accountStamp()
login := "acct-av-ref-" + stamp
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
png := string(conformPNG(t))
oversized := png + strings.Repeat("\x00", int(cabana.AvatarMaxBytes))
countFiles := func() int64 {
var n int64
if err := gdb.Model(&attach.File{}).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
for _, tc := range []struct {
name string
parts [][3]string
status int
detail string
}{
{"svg", [][3]string{{"file_data", "logo.svg", `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`}}, http.StatusUnprocessableEntity, "avatar"},
{"html named png", [][3]string{{"file_data", "fake.png", `<html><script>alert(1)</script></html>`}}, http.StatusUnprocessableEntity, "avatar"},
{"text file", [][3]string{{"file_data", "notes.txt", "hello"}}, http.StatusUnprocessableEntity, "avatar"},
{"over AvatarMaxBytes", [][3]string{{"file_data", "big.png", oversized}}, http.StatusUnprocessableEntity, "avatar"},
{"wrong part name", [][3]string{{"file", "me.png", png}}, http.StatusUnprocessableEntity, "body"},
{"no file name", [][3]string{{"file_data", " ", png}}, http.StatusUnprocessableEntity, "body"},
{"no part", nil, http.StatusUnprocessableEntity, "body"},
{"two parts", [][3]string{{"file_data", "a.png", png}, {"file_data", "b.png", png}}, http.StatusUnprocessableEntity, "body"},
} {
t.Run(tc.name, func(t *testing.T) {
before := countFiles()
body, contentType := multipartBody(t, tc.parts...)
rec := sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType)
if rec.Code != tc.status {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if details := validationDetails(t, rec.Body.Bytes()); len(details[tc.detail]) == 0 {
t.Fatalf("details = %v", details)
}
if after := countFiles(); after != before {
t.Fatalf("system_files rows %d -> %d", before, after)
}
if keys := bucketKeys(t, bucket, ""); len(keys) != 0 {
t.Fatalf("blobs left behind: %v", keys)
}
})
}
t.Run("refusal messages", func(t *testing.T) {
svg, ct := multipartBody(t, [3]string{"file_data", "logo.svg", "<svg/>"})
rec := sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, svg, ct)
if got := validationDetails(t, rec.Body.Bytes())["avatar"][0]; got != "The avatar must be a file of type: jpg, jpeg, png, gif, webp." {
t.Fatalf("svg message = %q", got)
}
big, ct := multipartBody(t, [3]string{"file_data", "big.png", oversized})
rec = sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, big, ct)
if got := validationDetails(t, rec.Body.Bytes())["avatar"][0]; got != "The avatar may not be greater than 2048 kilobytes." {
t.Fatalf("size message = %q", got)
}
html, ct := multipartBody(t, [3]string{"file_data", "fake.png", "<html></html>"})
rec = sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, html, ct)
if got := validationDetails(t, rec.Body.Bytes())["avatar"][0]; got != "The avatar must be an image." {
t.Fatalf("image message = %q", got)
}
})
t.Run("malformed bodies", func(t *testing.T) {
for name, rec := range map[string]*httptest.ResponseRecorder{
"json": sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, []byte(`{}`), "application/json"),
"bad boundary": sendRaw(t, h, http.MethodPost, adminAPI("/account/avatar"), token, []byte("--other\r\ngarbage"), "multipart/form-data; boundary=expected"),
} {
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s status=%d body=%s", name, rec.Code, rec.Body.String())
}
}
})
t.Run("a body over upload_bytes is 413", func(t *testing.T) {
small, smallBucket := accountHandler(t, gdb, true, 1<<20)
before := countFiles()
body, contentType := multipartBody(t, [3]string{"file_data", "big.png", png + strings.Repeat("\x00", 3<<19)})
rec := sendRaw(t, small, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if countFiles() != before || len(bucketKeys(t, smallBucket, "")) != 0 {
t.Fatal("an oversized body left a row or a blob")
}
})
t.Run("no bucket is a 500", func(t *testing.T) {
bare, _ := avatarHandler(t, gdb, false)
body, contentType := multipartBody(t, [3]string{"file_data", "me.png", png})
rec := sendRaw(t, bare, http.MethodPost, adminAPI("/account/avatar"), token, body, contentType)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
if rows := avatarRows(t, gdb, user.ID); len(rows) != 0 {
t.Fatalf("refusals stored %d avatar rows", len(rows))
}
}
// TestAccountAvatarReplaceAndRemove covers replace semantics, removal, the
// idempotent DELETE and another admin's untouched avatar.
func TestAccountAvatarReplaceAndRemove(t *testing.T) {
gdb := adminGorm(t)
h, bucket := avatarHandler(t, gdb, true)
stamp := accountStamp()
login := "acct-av-rep-" + stamp
user := insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
otherLogin := "acct-av-other-" + stamp
other := insertAdmin(t, gdb, otherLogin, otherLogin+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
ctx := context.Background()
if rec := uploadAvatar(t, h, signIn(t, h, otherLogin), "file_data", "other.png", conformPNG(t)); rec.Code != http.StatusOK {
t.Fatalf("other upload status=%d", rec.Code)
}
otherRows := avatarRows(t, gdb, other.ID)
if rec := uploadAvatar(t, h, token, "file_data", "first.png", conformPNG(t)); rec.Code != http.StatusOK {
t.Fatalf("first upload status=%d body=%s", rec.Code, rec.Body.String())
}
first := avatarRows(t, gdb, user.ID)
if len(first) != 1 {
t.Fatalf("rows after first upload = %d", len(first))
}
firstThumbs := attach.PartitionDirectory(first[0].DiskName) + "thumb_" + itoa(first[0].ID) + "_"
if keys := bucketKeys(t, bucket, firstThumbs); len(keys) == 0 {
t.Fatal("the first upload's profile made no thumbnail")
}
second := uploadAvatar(t, h, token, "file_data", "second.gif", gifBytes(t))
if second.Code != http.StatusOK {
t.Fatalf("second upload status=%d body=%s", second.Code, second.Body.String())
}
rows := avatarRows(t, gdb, user.ID)
if len(rows) != 1 || rows[0].ID == first[0].ID || rows[0].ContentType != "image/gif" {
t.Fatalf("rows after replace = %+v", rows)
}
if ok, _ := bucket.Exists(ctx, attach.BlobKey(first[0].DiskName)); ok {
t.Fatal("the replaced original blob is still stored")
}
if keys := bucketKeys(t, bucket, firstThumbs); len(keys) != 0 {
t.Fatalf("the replaced thumbnails are still stored: %v", keys)
}
if p := profileOf(t, second.Body.Bytes()); p.Avatar == nil || p.Avatar.URL != rows[0].URL() {
t.Fatalf("replace profile avatar = %+v", p.Avatar)
}
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())
}
if strings.Contains(del.Body.String(), `"avatar"`) {
t.Fatalf("delete answered an avatar: %s", del.Body.String())
}
if left := avatarRows(t, gdb, user.ID); len(left) != 0 {
t.Fatalf("rows after delete = %d", len(left))
}
part := attach.PartitionDirectory(rows[0].DiskName)
if ok, _ := bucket.Exists(ctx, attach.BlobKey(rows[0].DiskName)); ok {
t.Fatal("the removed original blob is still stored")
}
if keys := bucketKeys(t, bucket, part+"thumb_"+itoa(rows[0].ID)+"_"); len(keys) != 0 {
t.Fatalf("the removed thumbnails are still stored: %v", keys)
}
again := postAuth(t, h, http.MethodDelete, adminAPI("/account/avatar"), token, nil)
if again.Code != http.StatusOK {
t.Fatalf("second delete status=%d body=%s", again.Code, again.Body.String())
}
if found, err := cabana.BackendUserAvatar(ctx, gdb, user.ID); found != nil || err != nil {
t.Fatalf("BackendUserAvatar = %+v, %v", found, err)
}
if after := avatarRows(t, gdb, other.ID); len(after) != 1 || after[0].ID != otherRows[0].ID {
t.Fatalf("another admin's avatar changed: %+v", after)
}
t.Run("removal without a bucket keeps the blobs", func(t *testing.T) {
if rec := uploadAvatar(t, h, token, "file_data", "third.png", conformPNG(t)); rec.Code != http.StatusOK {
t.Fatalf("upload status=%d", rec.Code)
}
third := avatarRows(t, gdb, user.ID)
bare, _ := avatarHandler(t, gdb, false)
if rec := postAuth(t, bare, http.MethodDelete, adminAPI("/account/avatar"), token, nil); rec.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String())
}
if left := avatarRows(t, gdb, user.ID); len(left) != 0 {
t.Fatalf("rows after bucketless delete = %d", len(left))
}
if ok, _ := bucket.Exists(ctx, attach.BlobKey(third[0].DiskName)); !ok {
t.Fatal("a delete without a bucket cannot have removed the blob")
}
})
}