Files
sm-feedback-plugin/feedback_test.go
Jakub Zych af5d77c997 feat: G15Office job, widget settings and submissions admin, embed.js and the settings importer
- SyncG15Office files each submission as a G15Office task with its screenshot through a fetchguard client limited to the configured host; a River job with 3 attempts, PHP's messages on failure, nothing sent when unconfigured
- the replay of PHP's recorded G15Office exchange (classes/testdata) asserts the task JSON, headers and the screenshot part
- settings screen (cabana field types only), read-only submissions list, manage_settings permission and navigation
- embed.js served byte for byte at /plugins/golem15/feedback/assets/js/embed.js
- feedback:import-settings copies the WinterCMS settings row once
2026-10-04 00:22:08 +02:00

955 lines
38 KiB
Go

package feedback
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"image"
"image/png"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-feedback-plugin/classes"
"git.golem15.com/golem15/sm-feedback-plugin/console"
"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"
usercontrollers "git.golem15.com/golem15/sm-user-plugin/controllers"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"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"
)
// testJWTSecret is the test-only golem15.user JWT secret.
const testJWTSecret = "feedback-test-jwt-secret-0123456789abcdef"
// testAdminSecret is the test-only admin JWT secret (the plugin has admin
// controllers, so the admin needs one to boot).
const testAdminSecret = "feedback-test-admin-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: 33554432\n upload_bytes: 33554432\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,
"SUMMER_ADMIN__JWT__SECRET=" + testAdminSecret,
}})
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)
}
}
// pngBytes is a valid 2x1 PNG, optionally padded to size bytes with
// trailing data (a decoder reads the header only, so it stays an image).
func pngBytes(t *testing.T, size int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 2, 1))
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
out := buf.Bytes()
if size > len(out) {
out = append(out, make([]byte, size-len(out))...)
}
return out
}
type part struct {
name, value, filename, ctype string
data []byte
}
func multipartRequest(t *testing.T, target string, parts []part, origin string) *http.Request {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for _, p := range parts {
if p.filename == "" {
if err := mw.WriteField(p.name, p.value); err != nil {
t.Fatal(err)
}
continue
}
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", `form-data; name="`+p.name+`"; filename="`+p.filename+`"`)
h.Set("Content-Type", p.ctype)
w, err := mw.CreatePart(h)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(p.data); err != nil {
t.Fatal(err)
}
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, target, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
if origin != "" {
req.Header.Set("Origin", origin)
}
return req
}
func validParts(overrides map[string]string) []part {
fields := []string{"message", "type", "email", "page_url", "user_agent", "console_log"}
values := map[string]string{
"message": "The cover is wrong.",
"type": "bug",
"email": "reporter@example.test",
"page_url": "https://widget.example.test/albums/7",
"user_agent": "Mozilla/5.0 Test",
"console_log": "[error] x is undefined",
}
for k, v := range overrides {
values[k] = v
}
out := make([]part, 0, len(fields))
for _, f := range fields {
if v, ok := values[f]; ok && v != "\x00" {
out = append(out, part{name: f, value: v})
}
}
return out
}
func countRows(t *testing.T, gdb *gorm.DB, query string, args ...any) int64 {
t.Helper()
var n int64
if err := gdb.Raw(query, args...).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
// TestFeedbackSubmit drives POST {key}/submit: each validation limit one
// step either side, the image guard, the stored submission with its public
// screenshot, the G15Office job queued in the same transaction, a rolled
// back write queueing nothing, and the per-IP throttle.
func TestFeedbackSubmit(t *testing.T) {
submitURL := "/_feedback/api/v1/" + testWidgetKey + "/submit"
email255 := strings.Repeat("a", 64) + "@" + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 61)
t.Run("limits", func(t *testing.T) {
cases := []struct {
name string
parts []part
status int
field string
}{
{"message-5000", validParts(map[string]string{"message": strings.Repeat("ą", 5000)}), 202, ""},
{"message-5001", validParts(map[string]string{"message": strings.Repeat("ą", 5001)}), 422, "message"},
{"page-url-2000", validParts(map[string]string{"page_url": strings.Repeat("p", 2000)}), 202, ""},
{"page-url-2001", validParts(map[string]string{"page_url": strings.Repeat("p", 2001)}), 422, "page_url"},
{"user-agent-500", validParts(map[string]string{"user_agent": strings.Repeat("u", 500)}), 202, ""},
{"user-agent-501", validParts(map[string]string{"user_agent": strings.Repeat("u", 501)}), 422, "user_agent"},
{"console-log-20000", validParts(map[string]string{"console_log": strings.Repeat("c", 20000)}), 202, ""},
{"console-log-20001", validParts(map[string]string{"console_log": strings.Repeat("c", 20001)}), 422, "console_log"},
{"email-255", validParts(map[string]string{"email": email255}), 202, ""},
{"email-256", validParts(map[string]string{"email": "a" + email255}), 422, "email"},
{"type-other", validParts(map[string]string{"type": "other"}), 202, ""},
{"type-unknown", validParts(map[string]string{"type": "question"}), 422, "type"},
{"screenshot-10240-kb", append(validParts(nil), part{name: "screenshot", filename: "screenshot.png", ctype: "image/png", data: pngBytes(t, 10240*1024)}), 202, ""},
{"screenshot-10241-kb", append(validParts(nil), part{name: "screenshot", filename: "screenshot.png", ctype: "image/png", data: pngBytes(t, 10240*1024+1)}), 422, "screenshot"},
{"screenshot-not-an-image", append(validParts(nil), part{name: "screenshot", filename: "screenshot.png", ctype: "image/png", data: []byte("plain text, not a picture")}), 422, "screenshot"},
{"screenshot-header-only", append(validParts(nil), part{name: "screenshot", filename: "screenshot.jpg", ctype: "image/jpeg", data: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, 'J', 'F', 'I', 'F', 0}}), 422, "screenshot"},
}
h, _, gdb := newHandler(t)
resetSettings(t, gdb, "https://widget.example.test", nil)
for i, c := range cases {
if i > 0 && i%9 == 0 {
// A fresh router per nine posts: the bucket allows ten a minute.
h, _, _ = newHandler(t)
}
rec := do(t, h, multipartRequest(t, submitURL, c.parts, "https://widget.example.test"))
if rec.Code != c.status {
t.Fatalf("%s: status %d want %d: %s", c.name, rec.Code, c.status, rec.Body.String())
}
if c.status == 202 {
if rec.Body.String() != `{"success":true}` {
t.Fatalf("%s: body %s", c.name, rec.Body.String())
}
continue
}
body := decode(t, rec)
details, _ := body["details"].(map[string]any)
if body["error"] != true || body["message"] != "Validation failed" || details[c.field] == nil || len(details) != 1 {
t.Fatalf("%s: body %s", c.name, rec.Body.String())
}
}
})
t.Run("guard-message", func(t *testing.T) {
h, _, gdb := newHandler(t)
resetSettings(t, gdb, "", nil)
spoofed := append([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, 'J', 'F', 'I', 'F', 0}, []byte(strings.Repeat("x", 40))...)
rec := do(t, h, multipartRequest(t, submitURL, append(validParts(nil), part{name: "screenshot", filename: "screenshot.jpg", ctype: "image/jpeg", data: spoofed}), ""))
if rec.Code != 422 || rec.Body.String() != `{"error":true,"message":"Validation failed","details":{"screenshot":["The file is not a valid image."]}}` {
t.Fatalf("spoofed: %d %s", rec.Code, rec.Body.String())
}
})
t.Run("missing-fields-in-rule-order", func(t *testing.T) {
h, _, gdb := newHandler(t)
resetSettings(t, gdb, "", nil)
rec := do(t, h, multipartRequest(t, submitURL, []part{{name: "email", value: "x"}}, ""))
var raw struct {
Details json.RawMessage `json:"details"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil || rec.Code != 422 {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
keys := regexp.MustCompile(`"([a-z_]+)":\[`).FindAllStringSubmatch(string(raw.Details), -1)
var got []string
for _, k := range keys {
got = append(got, k[1])
}
if strings.Join(got, ",") != "message,type,email,page_url" {
t.Fatalf("details keys %v", got)
}
})
t.Run("gate", func(t *testing.T) {
h, _, gdb := newHandler(t)
resetSettings(t, gdb, "https://widget.example.test", nil)
wantError := func(rec *httptest.ResponseRecorder, status int, message string) {
t.Helper()
if rec.Code != status || rec.Body.String() != `{"error":true,"message":"`+message+`"}` {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
}
wantError(do(t, h, multipartRequest(t, "/_feedback/api/v1/wk_wrong/submit", validParts(nil), "")), 404, "Not found")
wantError(do(t, h, multipartRequest(t, submitURL, validParts(nil), "https://evil.test")), 403, "Origin not allowed")
resetSettings(t, gdb, "", nil)
wantError(do(t, h, multipartRequest(t, submitURL, validParts(nil), "https://widget.example.test")), 403, "Origin not allowed")
})
t.Run("stored-attached-and-queued", func(t *testing.T) {
h, _, gdb := newHandler(t)
resetSettings(t, gdb, "https://widget.example.test", nil)
jobsBefore := countRows(t, gdb, `SELECT COUNT(*) FROM river_job WHERE kind = ?`, classes.SyncG15OfficeKind)
shot := pngBytes(t, 0)
rec := do(t, h, multipartRequest(t, submitURL, append(validParts(map[string]string{"email": ""}), part{name: "screenshot", filename: "Screen Shot.PNG", ctype: "image/png", data: shot}), "https://widget.example.test"))
if rec.Code != http.StatusAccepted || rec.Body.String() != `{"success":true}` {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
var sub models.Submission
if err := gdb.Order("id DESC").Take(&sub).Error; err != nil {
t.Fatal(err)
}
if sub.Status != models.StatusPending || sub.Type != "bug" || sub.Message != "The cover is wrong." || sub.Email == nil || *sub.Email != "" ||
sub.PageURL != "https://widget.example.test/albums/7" || sub.UserAgent == nil || *sub.ConsoleLog != "[error] x is undefined" {
t.Fatalf("submission %+v", sub)
}
file, err := classes.ScreenshotOf(t.Context(), gdb, sub.ID)
if err != nil || file == nil {
t.Fatalf("screenshot %v %v", file, err)
}
if !file.Public() || file.FileName != "Screen Shot.PNG" || file.ContentType != "image/png" || file.FileSize != int64(len(shot)) ||
!strings.HasSuffix(file.DiskName, ".png") || file.SortOrder != int(file.ID) {
t.Fatalf("file %+v", file)
}
var args string
if err := gdb.Raw(`SELECT args::text FROM river_job WHERE kind = ? ORDER BY id DESC LIMIT 1`, classes.SyncG15OfficeKind).Scan(&args).Error; err != nil {
t.Fatal(err)
}
if countRows(t, gdb, `SELECT COUNT(*) FROM river_job WHERE kind = ?`, classes.SyncG15OfficeKind) != jobsBefore+1 ||
!strings.Contains(args, `"submission_id": `+strconv.FormatUint(uint64(sub.ID), 10)) {
t.Fatalf("job args %s", args)
}
var queue string
var maxAttempts int
if err := gdb.Raw(`SELECT queue, max_attempts FROM river_job WHERE kind = ? ORDER BY id DESC LIMIT 1`, classes.SyncG15OfficeKind).Row().Scan(&queue, &maxAttempts); err != nil {
t.Fatal(err)
}
if queue != classes.SyncG15OfficeQueue || maxAttempts != 3 {
t.Fatalf("queue %q attempts %d", queue, maxAttempts)
}
if countRows(t, gdb, `SELECT COUNT(*) FROM summer_jobs WHERE label = ?`, classes.SyncG15OfficeLabel) == 0 {
t.Fatal("no summer_jobs row")
}
})
t.Run("rolled-back-write-queues-nothing", func(t *testing.T) {
_, app, gdb := newHandler(t)
subsBefore := countRows(t, gdb, `SELECT COUNT(*) FROM golem15_feedback_submissions`)
jobsBefore := countRows(t, gdb, `SELECT COUNT(*) FROM river_job WHERE kind = ?`, classes.SyncG15OfficeKind)
recordsBefore := countRows(t, gdb, `SELECT COUNT(*) FROM summer_jobs WHERE label = ?`, classes.SyncG15OfficeLabel)
closed, err := blob.OpenBucket(t.Context(), "mem://")
if err != nil {
t.Fatal(err)
}
_ = closed.Close()
sub := &models.Submission{Message: "x", Type: "bug", PageURL: "https://widget.example.test/"}
err = classes.StoreSubmission(t.Context(), app, gdb, closed, sub, &classes.Screenshot{Filename: "s.png", Data: pngBytes(t, 0)})
if err == nil {
t.Fatal("a failed screenshot write was stored")
}
if countRows(t, gdb, `SELECT COUNT(*) FROM golem15_feedback_submissions`) != subsBefore ||
countRows(t, gdb, `SELECT COUNT(*) FROM river_job WHERE kind = ?`, classes.SyncG15OfficeKind) != jobsBefore ||
countRows(t, gdb, `SELECT COUNT(*) FROM summer_jobs WHERE label = ?`, classes.SyncG15OfficeLabel) != recordsBefore {
t.Fatal("the rolled-back submission left a row or a job behind")
}
})
t.Run("throttle", func(t *testing.T) {
h, _, gdb := newHandler(t)
resetSettings(t, gdb, "", nil)
for i := 1; i <= 11; i++ {
rec := do(t, h, multipartRequest(t, submitURL, []part{{name: "message", value: "x"}}, ""))
if i <= 10 && rec.Code != 422 {
t.Fatalf("post %d: %d", i, rec.Code)
}
if i == 11 && rec.Code != http.StatusTooManyRequests {
t.Fatalf("post 11: %d %s", rec.Code, rec.Body.String())
}
}
})
}
func createUser(t *testing.T, gdb *gorm.DB, email string) *usermodels.User {
t.Helper()
if err := gdb.Exec(`DELETE FROM golem15_feedback_user_preferences WHERE user_id IN (SELECT id FROM users WHERE email = ?)`, email).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Unscoped().Where("email = ?", email).Delete(&usermodels.User{}).Error; err != nil {
t.Fatal(err)
}
u := &usermodels.User{Email: email, IsActivated: true}
if err := gdb.Create(u).Error; err != nil {
t.Fatal(err)
}
return u
}
func bearer(t *testing.T, app *backpack.App, u *usermodels.User) string {
t.Helper()
tok, err := usercontrollers.IssueToken(app, u, httptest.NewRequest(http.MethodGet, "http://localhost/", nil))
if err != nil {
t.Fatal(err)
}
return "Bearer " + tok
}
func putHidden(t *testing.T, h http.Handler, auth, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPut, "/_feedback/api/v1/me/hidden", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if auth != "" {
req.Header.Set("Authorization", auth)
}
return do(t, h, req)
}
// TestMeHidden: PUT me/hidden accepts Laravel booleans, answers
// {"hidden":bool}, writes only the caller's row and refuses other values
// and a missing token.
func TestMeHidden(t *testing.T) {
h, app, gdb := newHandler(t)
alice := createUser(t, gdb, "alice-hidden@example.test")
bob := createUser(t, gdb, "bob-hidden@example.test")
aliceAuth := bearer(t, app, alice)
if err := models.SetWidgetHidden(t.Context(), gdb, bob.ID, true); err != nil {
t.Fatal(err)
}
for _, c := range []struct {
body string
want bool
}{
{`{"hidden":true}`, true}, {`{"hidden":false}`, false}, {`{"hidden":"1"}`, true},
{`{"hidden":0}`, false}, {`{"hidden":1}`, true}, {`{"hidden":"0"}`, false},
} {
rec := putHidden(t, h, aliceAuth, c.body)
if rec.Code != http.StatusOK || rec.Body.String() != `{"hidden":`+strconv.FormatBool(c.want)+`}` {
t.Fatalf("%s: %d %s", c.body, rec.Code, rec.Body.String())
}
got, err := models.IsWidgetHidden(t.Context(), gdb, alice.ID)
if err != nil || got != c.want {
t.Fatalf("%s: stored %v (%v)", c.body, got, err)
}
}
if n := countRows(t, gdb, `SELECT COUNT(*) FROM golem15_feedback_user_preferences WHERE user_id = ?`, alice.ID); n != 1 {
t.Fatalf("alice has %d preference rows", n)
}
if hidden, _ := models.IsWidgetHidden(t.Context(), gdb, bob.ID); !hidden {
t.Fatal("bob's preference changed")
}
for _, body := range []string{`{"hidden":"yes"}`, `{"hidden":2}`, `{}`, `{"hidden":null}`, `{"hidden":"true"}`} {
rec := putHidden(t, h, aliceAuth, body)
out := decode(t, rec)
errs, _ := out["errors"].(map[string]any)
if rec.Code != http.StatusUnprocessableEntity || out["error"] != "Validation failed" || errs["hidden"] == nil {
t.Fatalf("%s: %d %s", body, rec.Code, rec.Body.String())
}
}
if rec := putHidden(t, h, "", `{"hidden":true}`); rec.Code != http.StatusUnauthorized {
t.Fatalf("no token: %d %s", rec.Code, rec.Body.String())
}
}
// TestFeedbackApiArrayHook: the user plugin's payload carries
// feedback_widget_hidden, false without a row and true after PUT me/hidden,
// both through GET /_user/api/v1/fetch and the payload builder.
func TestFeedbackApiArrayHook(t *testing.T) {
h, app, gdb := newHandler(t)
u := createUser(t, gdb, "carol-hidden@example.test")
auth := bearer(t, app, u)
fetch := func() any {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/_user/api/v1/fetch", nil)
req.Header.Set("Authorization", auth)
req.Header.Set("Accept", "application/json")
rec := do(t, h, req)
if rec.Code != http.StatusOK {
t.Fatalf("fetch %d %s", rec.Code, rec.Body.String())
}
return decode(t, rec)["user"].(map[string]any)["feedback_widget_hidden"]
}
if got := fetch(); got != false {
t.Fatalf("without a row: %v", got)
}
if rec := putHidden(t, h, auth, `{"hidden":true}`); rec.Code != http.StatusOK {
t.Fatalf("put %d", rec.Code)
}
if got := fetch(); got != true {
t.Fatalf("after PUT: %v", got)
}
payload, err := usercontrollers.APIArray(t.Context(), app, u)
if err != nil || payload["feedback_widget_hidden"] != true {
t.Fatalf("APIArray %v %v", payload["feedback_widget_hidden"], err)
}
}
// TestFeedbackOptionsPreflight: OPTIONS on the feedback paths is answered
// 204 with an empty body by surf's CORS layer (the plugin registers no
// OPTIONS route), a preflight with the requested method echoed.
func TestFeedbackOptionsPreflight(t *testing.T) {
h, _, gdb := newHandler(t)
resetSettings(t, gdb, "", nil)
pre := httptest.NewRequest(http.MethodOptions, "/_feedback/api/v1/"+testWidgetKey+"/submit", nil)
pre.Header.Set("Origin", "https://widget.example.test")
pre.Header.Set("Access-Control-Request-Method", "POST")
pre.Header.Set("Access-Control-Request-Headers", "content-type")
rec := do(t, h, pre)
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Fatalf("preflight %d %q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" || rec.Header().Get("Access-Control-Allow-Methods") != "POST" ||
rec.Header().Get("Access-Control-Allow-Headers") != "content-type" || rec.Header().Get("Cache-Control") != "no-cache, private" {
t.Fatalf("preflight headers %v", rec.Header())
}
for _, path := range []string{"/_feedback/api/v1/anything/else", "/_feedback/api/v1/me/hidden"} {
rec := do(t, h, httptest.NewRequest(http.MethodOptions, path, nil))
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 || rec.Header().Get("Cache-Control") != "no-cache, private" {
t.Fatalf("OPTIONS %s: %d %q %v", path, rec.Code, rec.Body.String(), rec.Header())
}
}
}
// phpEmbedJSSHA256 is the sha256 of the WinterCMS plugin's
// assets/js/embed.js, which the Go plugin serves byte for byte.
const phpEmbedJSSHA256 = "a76d27dc5debfee5834fb533320be201ff22324a0e845c373dbef8c2dd79bb50"
// TestEmbedJSServed: GET /plugins/golem15/feedback/assets/js/embed.js is
// the PHP file byte for byte, as JavaScript, with conditional requests.
func TestEmbedJSServed(t *testing.T) {
h, _, _ := newHandler(t)
rec := do(t, h, httptest.NewRequest(http.MethodGet, EmbedJSPath, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
sum := sha256.Sum256(rec.Body.Bytes())
if hex.EncodeToString(sum[:]) != phpEmbedJSSHA256 {
t.Fatal("embed.js differs from the PHP file")
}
if ct := rec.Header().Get("Content-Type"); ct != "application/javascript; charset=utf-8" {
t.Fatalf("Content-Type %q", ct)
}
etag := rec.Header().Get("ETag")
req := httptest.NewRequest(http.MethodGet, EmbedJSPath, nil)
req.Header.Set("If-None-Match", etag)
if rec := do(t, h, req); etag == "" || rec.Code != http.StatusNotModified {
t.Fatalf("conditional GET: %d (ETag %q)", rec.Code, etag)
}
}
// TestFeedbackImportSettings: feedback:import-settings copies the WinterCMS
// row into the singleton once (widget key included), refuses changed
// settings without --force, and reports a missing source cleanly.
func TestFeedbackImportSettings(t *testing.T) {
ctx := context.Background()
gdb := pgtest.DB(t)
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`DROP TABLE IF EXISTS system_settings`).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { gdb.Exec(`DROP TABLE IF EXISTS system_settings`) })
cmds := (&Plugin{}).Commands()
if len(cmds) != 1 || cmds[0].Name != "feedback:import-settings" || len(cmds[0].Flags) != 1 || cmds[0].Flags[0].Name != "force" {
t.Fatalf("commands %+v", cmds)
}
err := console.Import(ctx, gdb, false)
if !console.IsNothingToImport(err) || err.Error() != "No system_settings table: nothing to import." {
t.Fatalf("missing table: %v", err)
}
if err := gdb.Exec(`CREATE TABLE system_settings (id SERIAL PRIMARY KEY, item TEXT, value TEXT)`).Error; err != nil {
t.Fatal(err)
}
if err := console.Import(ctx, gdb, false); !console.IsNothingToImport(err) || err.Error() != "No golem15_feedback_settings row in system_settings: nothing to import." {
t.Fatalf("missing row: %v", err)
}
value := `{"enabled":"1","allow_hide":"0","widget_key":"wk_FromWinter0000000000000000000000","position":"middle-left",` +
`"allowed_origins":"https://example.com\r\nexample.org","color_primary":"#111827","color_accent":null,` +
`"label_title_pl":"Napisz do nas","label_title_en":"Write to us","g15office_task_status":"","g15office_task_priority":"high"}`
if err := gdb.Exec(`INSERT INTO system_settings (item, value) VALUES ('golem15_feedback_settings', ?)`, value).Error; err != nil {
t.Fatal(err)
}
if err := console.Import(ctx, gdb, false); err != nil {
t.Fatal(err)
}
s, err := models.Instance(ctx, gdb)
if err != nil {
t.Fatal(err)
}
if !s.Enabled || s.AllowHide || s.WidgetKey != "wk_FromWinter0000000000000000000000" || *s.Position != "middle-left" ||
*s.AllowedOrigins != "https://example.com\r\nexample.org" || *s.ColorPrimary != "#111827" || s.ColorAccent != nil ||
*s.LabelTitlePL != "Napisz do nas" || *s.LabelTitleEN != "Write to us" || *s.LabelSuccessEN != "Thanks for the feedback!" ||
*s.G15OfficeTaskStatus != "" || *s.G15OfficeTaskPriority != "high" {
t.Fatalf("imported %+v", s)
}
if err := console.Import(ctx, gdb, false); !errors.Is(err, console.ErrSettingsCustomised) {
t.Fatalf("second import: %v", err)
}
if err := gdb.Exec(`UPDATE system_settings SET value = '{"position":"bottom-left"}'`).Error; err != nil {
t.Fatal(err)
}
if err := console.Import(ctx, gdb, true); err != nil {
t.Fatalf("forced import: %v", err)
}
if s, _ := models.Instance(ctx, gdb); *s.Position != "bottom-left" || s.WidgetKey != "wk_FromWinter0000000000000000000000" {
t.Fatalf("forced import kept %+v", s)
}
}
// TestFeedbackAdminSchemas: the settings form (cabana field types only,
// widget key read-only) and the read-only submissions list compile when
// the admin boots, with the permission, the navigation and the settings
// screen registered, and the settings save through cabana with a
// generated widget key.
func TestFeedbackAdminSchemas(t *testing.T) {
h, app, gdb := newHandler(t)
_ = h
p := &Plugin{}
ctls := p.AdminControllers()
if len(ctls) != 1 || ctls[0].ID() != "golem15.feedback.submissions" {
t.Fatalf("controllers %v", ctls)
}
list, err := cabana.CompileList("golem15.feedback", ctls[0], p.AdminFS())
if err != nil {
t.Fatal(err)
}
if len(list.ToolbarButtons) != 0 || len(list.BulkActions) != 0 {
t.Fatalf("the submissions list is not read-only: %+v %+v", list.ToolbarButtons, list.BulkActions)
}
for _, c := range list.Columns {
if c.Type != "text" && c.Type != "datetime" && c.Type != "" {
t.Fatalf("column %s renders as %s", c.Key, c.Type)
}
}
if _, err := fs.Stat(p.AdminFS(), "controllers/submissions/config_form.yaml"); err == nil {
t.Fatal("the submissions controller has a form")
}
raw, err := fs.ReadFile(p.AdminFS(), "models/settings/fields.yaml")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "colorpicker") || strings.Contains(string(raw), "readOnly") {
t.Fatal("the settings form uses a field type cabana does not support")
}
if perms := p.Permissions(); len(perms) != 1 || perms[0].Code != "golem15.feedback.manage_settings" || perms[0].Roles[0] != "developer" {
t.Fatalf("permissions %+v", perms)
}
if nav := p.Navigation(); len(nav) != 1 || nav[0].SideMenu[0].Controller != "golem15.feedback.submissions" {
t.Fatalf("navigation %+v", nav)
}
if st := p.Settings(); len(st) != 1 || st[0].Code != SettingsCode || st[0].Form != "models/settings/fields.yaml" || st[0].Model != `Golem15\Feedback\Models\Settings` {
t.Fatalf("settings %+v", st)
}
// The admin compiled every schema when the router was assembled; the
// settings screen then creates the row with a widget key.
plugins, err := party.Activate(backpack.New(app.Config), pgtest.PluginIDs)
if err != nil {
t.Fatal(err)
}
if _, err := cabana.Activate(app, plugins); err != nil {
t.Fatalf("admin boot: %v", err)
}
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
// cabana's settings save creates the row as id 1 from the form's
// defaults, without a key.
s := &models.Settings{ID: models.SettingsID}
if err := gdb.Create(s).Error; err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(s.WidgetKey, "wk_") {
t.Fatalf("a row created without a key got %q", s.WidgetKey)
}
s.WidgetKey = ""
if err := gdb.Save(s).Error; err != nil || !strings.HasPrefix(s.WidgetKey, "wk_") {
t.Fatalf("an emptied key was saved as %q (%v)", s.WidgetKey, err)
}
}