- 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
247 lines
8.8 KiB
Go
247 lines
8.8 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
// TestMetadataSubtitleHelpers covers the lenient reader and the
|
|
// key-preserving writer of Winter's jsonable backend_users.metadata.
|
|
func TestMetadataSubtitleHelpers(t *testing.T) {
|
|
t.Run("read", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
raw *string
|
|
want string
|
|
}{
|
|
{"nil", nil, ""},
|
|
{"blank", strPtr(""), ""},
|
|
{"spaces", strPtr(" "), ""},
|
|
{"null", strPtr("null"), ""},
|
|
{"php empty array", strPtr("[]"), ""},
|
|
{"subtitle", strPtr(`{"subtitle":"Editor"}`), "Editor"},
|
|
{"padded subtitle", strPtr(` {"subtitle":"Editor","x":1} `), "Editor"},
|
|
{"number", strPtr(`{"subtitle":5}`), ""},
|
|
{"null value", strPtr(`{"subtitle":null}`), ""},
|
|
{"missing key", strPtr(`{"x":"y"}`), ""},
|
|
{"invalid json", strPtr(`{"subtitle":`), ""},
|
|
{"string", strPtr(`"text"`), ""},
|
|
{"array", strPtr(`[1]`), ""},
|
|
} {
|
|
if got := metadataString(tc.raw, metadataSubtitle); got != tc.want {
|
|
t.Errorf("%s: metadataString = %q, want %q", tc.name, got, tc.want)
|
|
}
|
|
if got := (BackendUser{Metadata: tc.raw}).Subtitle(); got != tc.want {
|
|
t.Errorf("%s: Subtitle = %q, want %q", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("write onto empty metadata", func(t *testing.T) {
|
|
for _, raw := range []*string{nil, strPtr(""), strPtr("null"), strPtr("[]")} {
|
|
got, err := setMetadataString(raw, metadataSubtitle, "Editor")
|
|
if err != nil || got == nil || *got != `{"subtitle":"Editor"}` {
|
|
t.Fatalf("set on %v = %v, %v", raw, got, err)
|
|
}
|
|
cleared, err := setMetadataString(raw, metadataSubtitle, "")
|
|
if err != nil || cleared != nil {
|
|
t.Fatalf("clear on %v = %v, %v", raw, cleared, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("write keeps other keys byte-equal", func(t *testing.T) {
|
|
raw := strPtr(`{"subtitle":"a","x":{"y":[1,2]}}`)
|
|
got, err := setMetadataString(raw, metadataSubtitle, "b")
|
|
if err != nil || got == nil {
|
|
t.Fatalf("set = %v, %v", got, err)
|
|
}
|
|
var decoded map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(*got), &decoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(decoded["x"]) != `{"y":[1,2]}` || string(decoded["subtitle"]) != `"b"` {
|
|
t.Fatalf("metadata = %s", *got)
|
|
}
|
|
cleared, err := setMetadataString(got, metadataSubtitle, "")
|
|
if err != nil || cleared == nil || *cleared != `{"x":{"y":[1,2]}}` {
|
|
t.Fatalf("clear = %v, %v", cleared, err)
|
|
}
|
|
})
|
|
|
|
t.Run("clearing the last key is NULL", func(t *testing.T) {
|
|
got, err := setMetadataString(strPtr(`{"subtitle":"Editor"}`), metadataSubtitle, "")
|
|
if err != nil || got != nil {
|
|
t.Fatalf("clear = %v, %v", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("text that is not an object is never overwritten", func(t *testing.T) {
|
|
for _, raw := range []string{`{"subtitle":`, `"text"`, `[1]`, `5`} {
|
|
got, err := setMetadataString(strPtr(raw), metadataSubtitle, "Editor")
|
|
if !errors.Is(err, errMetadataNotObject) || got != nil {
|
|
t.Fatalf("set on %q = %v, %v", raw, got, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("values are JSON-escaped", func(t *testing.T) {
|
|
got, err := setMetadataString(nil, metadataSubtitle, `Quote " and \ back`)
|
|
if err != nil || got == nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back := metadataString(got, metadataSubtitle); back != `Quote " and \ back` {
|
|
t.Fatalf("round trip = %q", back)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestBackendUserAttachContract pins the WinterCMS morph name and the avatar
|
|
// attachOne, the account helpers that need no database, and the
|
|
// unique-violation mapping.
|
|
func TestBackendUserAttachContract(t *testing.T) {
|
|
var owner attach.Owner = BackendUser{}
|
|
if owner.MorphName() != `Backend\Models\User` || BackendUserMorphType != `Backend\Models\User` {
|
|
t.Fatalf("MorphName = %q", owner.MorphName())
|
|
}
|
|
var declared attach.HasRelations = BackendUser{}
|
|
rels := declared.AttachRelations()
|
|
if len(rels) != 1 || rels[0] != (attach.Relation{Name: "avatar", Many: false, Public: true}) || BackendUserAvatarField != "avatar" {
|
|
t.Fatalf("AttachRelations = %#v", rels)
|
|
}
|
|
if AvatarMaxBytes != 2<<20 {
|
|
t.Fatalf("AvatarMaxBytes = %d", AvatarMaxBytes)
|
|
}
|
|
|
|
t.Run("avatar cap", func(t *testing.T) {
|
|
if got := (&service{}).avatarCap(); got != AvatarMaxBytes+multipartOverhead {
|
|
t.Fatalf("uncapped = %d", got)
|
|
}
|
|
if got := (&service{uploadBytes: 1 << 20}).avatarCap(); got != 1<<20 {
|
|
t.Fatalf("upload_bytes 1 MiB = %d", got)
|
|
}
|
|
if got := (&service{uploadBytes: 64 << 20}).avatarCap(); got != AvatarMaxBytes+multipartOverhead {
|
|
t.Fatalf("upload_bytes 64 MiB = %d", got)
|
|
}
|
|
})
|
|
|
|
t.Run("unique violation mapping", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
err error
|
|
want bool
|
|
}{
|
|
{&pgconn.PgError{Code: "23505", ConstraintName: "backend_users_email_lower_unique"}, true},
|
|
{fmt.Errorf("wrapped: %w", &pgconn.PgError{Code: "23505", ConstraintName: "backend_users_email_key"}), true},
|
|
{&pgconn.PgError{Code: "23505", ConstraintName: "backend_users_login_key"}, false},
|
|
{&pgconn.PgError{Code: "23503", ConstraintName: "backend_users_email_lower_unique"}, false},
|
|
{errors.New("23505 backend_users_email_lower_unique"), false},
|
|
} {
|
|
if got := emailConflict(tc.err); got != tc.want {
|
|
t.Errorf("emailConflict(%v) = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("messages fall back to English without a translator", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
if got := backendMessage(ctx, nil, "myaccount.current_password_invalid", "fallback"); got != "fallback" {
|
|
t.Fatalf("backendMessage = %q", got)
|
|
}
|
|
for rule, want := range map[string]string{
|
|
"confirmed": "The password confirmation does not match.",
|
|
"unique": "The password has already been taken.",
|
|
"max.string": "The password may not be greater than 72 characters.",
|
|
"required": "The password field is required.",
|
|
} {
|
|
if got := accountMessage(ctx, nil, rule, "password", map[string]string{"max": "72"}); got != want {
|
|
t.Errorf("accountMessage(%s) = %q, want %q", rule, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("details follow the rule order", func(t *testing.T) {
|
|
details := accountDetails(map[string][]string{"password": {"b"}, "email": {"a"}})
|
|
if len(details) != 2 || details["email"].([]string)[0] != "a" || details["password"].([]string)[0] != "b" {
|
|
t.Fatalf("details = %#v", details)
|
|
}
|
|
})
|
|
|
|
t.Run("refused keys", func(t *testing.T) {
|
|
details := refusedAccountKeys(context.Background(), nil, map[string]any{"email": "x", "login": "y", "role_id": 1})
|
|
if len(details) != 2 || details["login"] == nil || details["role_id"] == nil {
|
|
t.Fatalf("details = %#v", details)
|
|
}
|
|
if refusedAccountKeys(context.Background(), nil, map[string]any{"email": "x", "subtitle": nil}) != nil {
|
|
t.Fatal("allowed keys were refused")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAccountRoutesRefuseNonBackendPrincipals calls the three My account
|
|
// handlers without the guard: no principal, a frontend principal and a
|
|
// zero id all answer 401 before any body is read or any query runs.
|
|
func TestAccountRoutesRefuseNonBackendPrincipals(t *testing.T) {
|
|
svc := &service{}
|
|
handlers := map[string]http.HandlerFunc{
|
|
"account update": svc.accountUpdate,
|
|
"avatar upload": svc.avatarUpload,
|
|
"avatar remove": svc.avatarRemove,
|
|
}
|
|
principals := map[string]*bouncer.Principal{
|
|
"none": nil,
|
|
"frontend": {ID: 4, Backend: false},
|
|
"zero id": {ID: 0, Backend: true},
|
|
}
|
|
for name, handler := range handlers {
|
|
for kind, principal := range principals {
|
|
t.Run(name+" "+kind, func(t *testing.T) {
|
|
spy := &readSpy{r: strings.NewReader(`{"email":"x@example.test"}`)}
|
|
req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), spy)
|
|
if principal != nil {
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec.Body.Bytes(), "unauthenticated")
|
|
if spy.reads != 0 {
|
|
t.Fatalf("body read %d times", spy.reads)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
t.Run("backend principal without a database", func(t *testing.T) {
|
|
super := &bouncer.Principal{ID: 1, Backend: true}
|
|
for name, handler := range handlers {
|
|
req := httptest.NewRequest(http.MethodPut, adminAPI("/account"), strings.NewReader(`{"email":"admin@example.test"}`))
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), super))
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, req)
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("%s status=%d body=%s", name, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("avatar lookup without a database", func(t *testing.T) {
|
|
if f, err := BackendUserAvatar(context.Background(), nil, 1); f != nil || err == nil {
|
|
t.Fatalf("lookup = %v, %v", f, err)
|
|
}
|
|
})
|
|
}
|