Files
sm-feedback-plugin/feedback_test.go
Jakub Zych b4b4135ad3 feat: golem15.feedback plugin with the widget config route
- plugin golem15.feedback (requires golem15.user), its config, lang (en, pl), models and migrations
- typed settings singleton golem15_feedback_settings with the WinterCMS defaults and a widget key generated on first creation
- GET /_feedback/api/v1/{key}/config behind the constant-time key check and the fail-closed Origin allow-list, with the feedback-config and feedback-submit buckets
2026-10-04 00:01:22 +02:00

362 lines
13 KiB
Go

package feedback
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/sm-feedback-plugin/controllers/api"
"git.golem15.com/golem15/sm-feedback-plugin/internal/pgtest"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"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"
"gorm.io/gorm"
)
// testJWTSecret is the test-only golem15.user JWT secret.
const testJWTSecret = "feedback-test-jwt-secret-0123456789abcdef"
// testWidgetKey is the widget key the tests store.
const testWidgetKey = "wk_TestKey0123456789abcdefghijklmnop"
func testConfig(t testing.TB) *compass.Config {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: feedback-test\n"), 0o644); err != nil {
t.Fatal(err)
}
httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 16777216\ncors:\n paths:\n - _feedback/api/*\n allowed_methods: [\"*\"]\n allowed_origins: [\"*\"]\n allowed_headers: [\"*\"]\n"
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_ENV=development",
"SUMMER_GOLEM15__USER__JWT__SECRET=" + testJWTSecret,
}})
if err != nil {
t.Fatal(err)
}
return cfg
}
// newHandler assembles golem15.user and golem15.feedback over the shared
// test database, with a public uploads bucket in a temp directory.
func newHandler(t *testing.T) (http.Handler, *backpack.App, *gorm.DB) {
t.Helper()
gdb := pgtest.DB(t)
cfg := testConfig(t)
if err := cfg.Set("storage.uploads.bucket_url", "file://"+t.TempDir()); err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, pgtest.SQL(t), gdb); err != nil {
t.Fatal(err)
}
bucket, err := attach.OpenBucket(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, pgtest.PluginIDs)
if err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
return h, app, gdb
}
// resetSettings stores the settings row: the defaults, testWidgetKey and
// the given allowed origins, then applies mutate.
func resetSettings(t *testing.T, gdb *gorm.DB, origins string, mutate func(*models.Settings)) *models.Settings {
t.Helper()
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
s := models.DefaultSettings()
s.WidgetKey = testWidgetKey
if origins != "" {
s.AllowedOrigins = &origins
}
if mutate != nil {
mutate(&s)
}
if err := gdb.Create(&s).Error; err != nil {
t.Fatal(err)
}
return &s
}
func do(t *testing.T, h http.Handler, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("body %q: %v", rec.Body.String(), err)
}
return out
}
// TestFeedbackPluginBoot activates the plugin after golem15.user, which it
// requires, and checks its ID, config defaults, lang namespace, buckets
// and the settings row's first-access defaults.
func TestFeedbackPluginBoot(t *testing.T) {
cfg := testConfig(t)
app := backpack.New(cfg)
if _, err := party.Activate(app, []string{"golem15.feedback"}); err == nil {
t.Fatal("golem15.feedback activated without golem15.user")
}
app = backpack.New(testConfig(t))
plugins, err := party.Activate(app, []string{"golem15.user", "golem15.feedback"})
if err != nil {
t.Fatal(err)
}
if len(plugins) != 2 || plugins[1].ID() != "golem15.feedback" {
t.Fatalf("activated %v", plugins)
}
p := plugins[1].(*Plugin)
if got := p.Requires(); len(got) != 1 || got[0] != "golem15.user" {
t.Fatalf("Requires() = %v", got)
}
for _, key := range []string{"base_url", "token", "project"} {
v, ok := app.Config.Lookup("golem15.feedback.g15_office." + key)
if !ok || v != "" {
t.Fatalf("golem15.feedback.g15_office.%s = %#v (set %v), want empty", key, v, ok)
}
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok {
t.Fatal("no translator")
}
if got := tr.Get(context.Background(), "golem15.feedback::lang.settings.menu_label", nil); got != "Feedback Widget" {
t.Fatalf("menu_label = %q", got)
}
buckets := p.Buckets()
if b := buckets[BucketConfig]; b.Max != 60 || b.Decay.Seconds() != 60 || b.Key == nil {
t.Fatalf("config bucket = %+v", b)
}
if b := buckets[BucketSubmit]; b.Max != 10 || b.Decay.Seconds() != 60 || b.Key == nil {
t.Fatalf("submit bucket = %+v", b)
}
gdb := pgtest.DB(t)
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
first, err := models.Instance(t.Context(), gdb)
if err != nil {
t.Fatal(err)
}
if !first.Enabled || first.AllowHide || *first.Position != "bottom-right" || *first.G15OfficeTaskPriority != "normal" ||
*first.LabelTitlePL != "Zgłoś opinię" || *first.LabelSuccessEN != "Thanks for the feedback!" || first.ColorPrimary != nil {
t.Fatalf("defaults = %+v", first)
}
if !strings.HasPrefix(first.WidgetKey, "wk_") || len(first.WidgetKey) != 35 {
t.Fatalf("widget key %q", first.WidgetKey)
}
again, err := models.Instance(t.Context(), gdb)
if err != nil {
t.Fatal(err)
}
if again.WidgetKey != first.WidgetKey {
t.Fatal("the widget key changed on the second access")
}
other, err := models.NewWidgetKey()
if err != nil || other == first.WidgetKey {
t.Fatalf("two generated keys are equal (%v)", err)
}
}
// TestFeedbackConfig drives GET {key}/config through the assembled router:
// the key gate (constant-time, disabled widget, wrong key), the Origin gate
// (no Origin passes, an empty allow-list fails closed, hosts compare
// lower-cased) and the locale of the labels.
func TestFeedbackConfig(t *testing.T) {
h, _, gdb := newHandler(t)
get := func(key, origin, query string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/_feedback/api/v1/"+key+"/config"+query, nil)
req.Header.Set("Accept", "application/json")
if origin != "" {
req.Header.Set("Origin", origin)
}
return do(t, h, req)
}
wantError := func(rec *httptest.ResponseRecorder, status int, message string) {
t.Helper()
if rec.Code != status {
t.Fatalf("status %d want %d: %s", rec.Code, status, rec.Body.String())
}
if body := rec.Body.String(); body != `{"error":true,"message":"`+message+`"}` {
t.Fatalf("body %s", body)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" {
t.Fatalf("Cache-Control %q", cc)
}
}
resetSettings(t, gdb, "https://Widget.Example.test/\nlocalhost:3000\n\n other.test ", func(s *models.Settings) {
primary := "#6366f1"
s.ColorPrimary = &primary
s.AllowHide = true
})
t.Run("no-origin", func(t *testing.T) {
rec := get(testWidgetKey, "", "")
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
want := `{"success":true,"data":{"position":"bottom-right","allowHide":true,"colors":{"primary":"#6366f1","accent":null},"labels":{"title":"Zgłoś opinię","placeholder":"Co poszło nie tak?","success":"Dzięki za zgłoszenie!"}}}`
if rec.Body.String() != want {
t.Fatalf("body %s", rec.Body.String())
}
})
t.Run("lang-en", func(t *testing.T) {
labels := decode(t, get(testWidgetKey, "", "?lang=en"))["data"].(map[string]any)["labels"].(map[string]any)
if labels["title"] != "Send feedback" || labels["placeholder"] != "What went wrong?" || labels["success"] != "Thanks for the feedback!" {
t.Fatalf("labels %v", labels)
}
labels = decode(t, get(testWidgetKey, "", "?lang=de"))["data"].(map[string]any)["labels"].(map[string]any)
if labels["title"] != "Zgłoś opinię" {
t.Fatalf("lang=de labels %v", labels)
}
})
t.Run("origins", func(t *testing.T) {
for _, origin := range []string{"https://widget.example.test", "http://WIDGET.example.test:8080", "http://localhost:3000", "https://other.test"} {
if rec := get(testWidgetKey, origin, ""); rec.Code != http.StatusOK {
t.Fatalf("origin %s: status %d", origin, rec.Code)
}
}
for _, origin := range []string{"https://evil.test", "null", "widget.example.test", "https://widget.example.test.evil.test"} {
wantError(get(testWidgetKey, origin, ""), http.StatusForbidden, "Origin not allowed")
}
})
t.Run("key", func(t *testing.T) {
wantError(get("wk_wrong", "", ""), http.StatusNotFound, "Not found")
wantError(get(testWidgetKey+"x", "", ""), http.StatusNotFound, "Not found")
wantError(get(testWidgetKey[:len(testWidgetKey)-1], "", ""), http.StatusNotFound, "Not found")
// The key gate runs before the Origin gate.
wantError(get("wk_wrong", "https://evil.test", ""), http.StatusNotFound, "Not found")
})
t.Run("disabled", func(t *testing.T) {
resetSettings(t, gdb, "https://widget.example.test", func(s *models.Settings) { s.Enabled = false })
wantError(get(testWidgetKey, "", ""), http.StatusNotFound, "Not found")
})
t.Run("empty-allow-list-fails-closed", func(t *testing.T) {
resetSettings(t, gdb, "", nil)
wantError(get(testWidgetKey, "https://widget.example.test", ""), http.StatusForbidden, "Origin not allowed")
if rec := get(testWidgetKey, "", ""); rec.Code != http.StatusOK {
t.Fatalf("no Origin with an empty list: %d", rec.Code)
}
})
t.Run("first-access-creates-the-row", func(t *testing.T) {
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
wantError(get("wk_guess", "", ""), http.StatusNotFound, "Not found")
var s models.Settings
if err := gdb.Take(&s, models.SettingsID).Error; err != nil {
t.Fatal(err)
}
if rec := get(s.WidgetKey, "", ""); rec.Code != http.StatusOK {
t.Fatalf("generated key: %d", rec.Code)
}
})
}
// TestKeyMatches pins the key gate: only the exact stored key of an
// enabled widget matches; an empty stored key never does.
func TestKeyMatches(t *testing.T) {
s := &models.Settings{Enabled: true, WidgetKey: "wk_abc"}
cases := map[string]bool{"wk_abc": true, "wk_ab": false, "wk_abcd": false, "WK_ABC": false, "": false}
for key, want := range cases {
if got := api.KeyMatches(s, key); got != want {
t.Errorf("KeyMatches(%q) = %v", key, got)
}
}
if api.KeyMatches(&models.Settings{Enabled: false, WidgetKey: "wk_abc"}, "wk_abc") {
t.Error("a disabled widget matched")
}
if api.KeyMatches(&models.Settings{Enabled: true}, "") {
t.Error("an empty key matched")
}
if api.KeyMatches(nil, "wk_abc") {
t.Error("nil settings matched")
}
}
// TestURLHost compares URLHost with PHP's parse_url($s, PHP_URL_HOST) on
// the shapes an Origin header or an allowed-origins line takes (the PHP
// results were taken from php -r).
func TestURLHost(t *testing.T) {
cases := []struct {
in string
want string
ok bool
}{
{"localhost:3000", "localhost", true},
{"example.com", "", false},
{"example.com/", "", false},
{"https://Example.COM/", "Example.COM", true},
{"//a.test", "a.test", true},
{"https://[::1]:8080", "[::1]", true},
{"https://a.test:3000/x", "a.test", true},
{"null", "", false},
{"http://user:pw@b.test", "b.test", true},
{"a.test:abc", "", false},
{"a.test:3000/path", "a.test", true},
{"a.test:3000?x", "", false},
{"mailto:x@y.com", "", false},
{"https://a.test:abc", "", false},
{"https://a.test:", "a.test", true},
{"https://a.test?x=1", "a.test", true},
{"https://a.test#f", "a.test", true},
{"HTTPS://B.TEST", "B.TEST", true},
{"https://user@[::1]", "[::1]", true},
{"a:1:2", "", false},
{"https://a.test:99999", "", false},
{"https://", "", false},
}
for _, c := range cases {
got, ok := models.URLHost(c.in)
if got != c.want || ok != c.ok {
t.Errorf("URLHost(%q) = %q, %v; want %q, %v", c.in, got, ok, c.want, c.ok)
}
}
}
// TestAllowedOriginHosts is Settings::getAllowedOriginHosts: lines split on
// CR/LF, trimmed, blanks and "0" dropped, hosts lower-cased, duplicates
// removed, a line without a URL host kept as written.
func TestAllowedOriginHosts(t *testing.T) {
raw := "https://Example.com/\r\nexample.com\n\n //Example.com \n0\nexample.com/\nlocalhost:3000\r\rOTHER.test"
s := &models.Settings{AllowedOrigins: &raw}
got := strings.Join(s.AllowedOriginHosts(), ",")
if want := "example.com,example.com/,localhost,other.test"; got != want {
t.Fatalf("hosts %q want %q", got, want)
}
if hosts := (&models.Settings{}).AllowedOriginHosts(); len(hosts) != 0 {
t.Fatalf("nil origins gave %v", hosts)
}
}