Files
summercms/modules/cabana/record_action_prompt_test.go
Jakub Zych f14eae96dd test(19.7-05): framework unit tests for every validation row and the gate's admin stage
- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
  across two instances and the memory store), TestFanOut; the store clock
  and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
  a stream route) and TestPierMount in both builds; the streaming smoke
  restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
  transaction and unchanged My account payloads), TestRecordActionPrompt,
  TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
  of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
  cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
  since 51d088a, admin stage live
2026-10-23 14:04:16 +02:00

125 lines
5.1 KiB
Go

package cabana
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
// TestRecordActionPrompt (D-34): a prompted record action's prompt is
// localized in the show payload (label, placeholder and comment in the
// request's language, the default cap of 255 when MaxLength is 0); its
// value is trimmed and capped in runes; a non-string or empty payload is
// 422 on payload; an action without a Prompt refuses any payload; and a
// prompt-free value is the empty string. The Postgres run of the value
// into Run, and the empty Value a plain action receives, are pinned by
// TestRecordActionPromptRun.
func TestRecordActionPrompt(t *testing.T) {
cat := phrasebook.NewCatalog()
if err := cat.Load("acme", fstest.MapFS{
"lang/en/lang.yaml": {Data: []byte("site_login: Site login\nsite_login_hint: The login on the public site.\nlogin_placeholder: login\n")},
"lang/pl/lang.yaml": {Data: []byte("site_login: Login na stronie\nsite_login_hint: Login w serwisie publicznym.\nlogin_placeholder: login\n")},
}); err != nil {
t.Fatal(err)
}
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
var got []string
extender := screensExtender{extensions: []pact.AdminActionExtension{{
Controller: AdministratorsControllerID,
RecordActions: []pact.AdminRecordAction{
promptedAction("acme_link", &pact.AdminActionPrompt{Label: "acme::lang.site_login", Placeholder: "acme::lang.login_placeholder", Comment: "acme::lang.site_login_hint", MaxLength: 5}, &got),
promptedAction("acme_note", &pact.AdminActionPrompt{Label: "acme::lang.site_login"}, &got),
pingAction(),
},
}}}
reg, err := compileWithScreens(t, []party.Plugin{extender})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get(AdministratorsControllerID)
super := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
t.Run("prompt localized in the show payload", func(t *testing.T) {
for locale, want := range map[string][2]string{
"en": {"Site login", "The login on the public site."},
"pl": {"Login na stronie", "Login w serwisie publicznym."},
} {
ctx := towel.WithLocale(bouncer.WithUser(context.Background(), super), locale)
offered, err := (CRUDService{tr: tr}).offeredRecordActions(ctx, cc, &BackendUser{ID: 5, IsActivated: true})
if err != nil {
t.Fatal(err)
}
for _, a := range offered {
switch a.Name {
case "acme_link":
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.Comment != want[1] || a.Prompt.Placeholder != "login" || a.Prompt.MaxLength != 5 {
t.Fatalf("%s acme_link prompt = %+v", locale, a.Prompt)
}
case "acme_note":
if a.Prompt == nil || a.Prompt.Label != want[0] || a.Prompt.MaxLength != 255 {
t.Fatalf("%s acme_note prompt = %+v", locale, a.Prompt)
}
case "acme_ping":
if a.Prompt != nil {
t.Fatalf("acme_ping carries a prompt: %+v", a.Prompt)
}
}
}
}
})
t.Run("value trimmed and capped in runes", func(t *testing.T) {
prompt := &pact.AdminActionPrompt{MaxLength: 5}
for raw, want := range map[string]string{`" ąęś "`: "ąęś", `"abcde"`: "abcde", "\"\\tabc\\n\"": "abc"} {
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err != nil || v != want {
t.Fatalf("%s = %q, %v", raw, v, err)
}
}
for _, raw := range []string{`"abcdef"`, `"ąęśćżź"`, `""`, `" "`, `7`, `null`, `["a"]`, `{"v":"a"}`} {
if v, err := promptValue(context.Background(), nil, prompt, json.RawMessage(raw)); err == nil {
t.Fatalf("%s accepted as %q", raw, v)
}
}
if v, err := promptValue(context.Background(), nil, nil, json.RawMessage(`"x"`)); err != nil || v != "" {
t.Fatalf("no prompt = %q, %v", v, err)
}
})
t.Run("route refusals", func(t *testing.T) {
svc := &service{app: backpack.New(nil), reg: reg}
post := func(action, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
for k, v := range map[string]string{"vendor": "summercms", "plugin": "cabana", "controller": "users", "id": "5", "action": action} {
req.SetPathValue(k, v)
}
req = req.WithContext(bouncer.WithUser(req.Context(), super))
rec := httptest.NewRecorder()
svc.recordAction(rec, req)
return rec
}
for _, body := range []string{`{"payload":42}`, `{"payload":true}`, `{"payload":"abcdefgh"}`, `{"payload":""}`, `{}`} {
if rec := post("acme_link", body); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"payload"`) {
t.Fatalf("%s = %d %s", body, rec.Code, rec.Body.String())
}
}
if rec := post("acme_ping", `{"payload":"x"}`); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("payload on a plain action = %d %s", rec.Code, rec.Body.String())
}
if len(got) != 0 {
t.Fatalf("a refused request ran the action: %v", got)
}
})
}