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") } }) }