- 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
1139 lines
44 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|