package feedback import ( "bytes" "context" "encoding/json" "image" "image/png" "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/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/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" // 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, }}) 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()) } } }