Files
sm-feedback-plugin/feedback_test.go
Jakub Zych 5a8b7f816f fix(14): WR-02 derive the screenshot extension from the sniffed image type
The public screenshot kept the client file's extension, so a valid image
named x.html or x.svg was stored and served under that type. The disk
name now carries the client extension only when it names the sniffed
type, else the type's own; file_name keeps only the client's base name.
2026-10-04 07:59:59 +02:00

979 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("client-extension-never-names-the-public-file", func(t *testing.T) {
_, app, gdb := newHandler(t)
bucket, err := blob.OpenBucket(t.Context(), "mem://")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = bucket.Close() })
for name, want := range map[string]string{
"x.html": ".png", "../../x.svg": ".png", "noext": ".png", "Shot.PNG": ".png",
} {
sub := &models.Submission{Message: "x", Type: "bug", PageURL: "https://widget.example.test/"}
if err := classes.StoreSubmission(t.Context(), app, gdb, bucket, sub, &classes.Screenshot{Filename: name, Data: pngBytes(t, 0)}); err != nil {
t.Fatal(err)
}
file, err := classes.ScreenshotOf(t.Context(), gdb, sub.ID)
if err != nil || file == nil {
t.Fatalf("screenshot %v %v", file, err)
}
if !strings.HasSuffix(file.DiskName, want) || strings.Contains(file.FileName, "/") {
t.Fatalf("%q stored as %q (file_name %q)", name, file.DiskName, file.FileName)
}
}
})
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)
}
}