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