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", ``}}, http.StatusUnprocessableEntity, "avatar"},
{"html named png", [][3]string{{"file_data", "fake.png", ``}}, 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", ""})
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", ""})
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")
}
})
}