Files
sm-feedback-plugin/classes/classes_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

374 lines
15 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package classes_test
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
// The plugin registers itself; pgtest activates it.
_ "git.golem15.com/golem15/sm-feedback-plugin"
"git.golem15.com/golem15/sm-feedback-plugin/classes"
"git.golem15.com/golem15/sm-feedback-plugin/internal/pgtest"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/tide"
"gocloud.dev/blob"
_ "gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return b
}
func encoded(t *testing.T, enc func(*bytes.Buffer, image.Image) error) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
img.Set(1, 1, color.RGBA{R: 200, A: 255})
var buf bytes.Buffer
if err := enc(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// TestImageGuardCopy pins the feedback copy of ImageContentGuard: the
// shared vectors (the PHP-recorded screenshot fixtures among them) get the
// verdicts PHP gave and the framework's own guard (attach.IsAllowedImage)
// gives, and the sniffed type of each accepted image is one of the four.
func TestImageGuardCopy(t *testing.T) {
cases := []struct {
name string
data []byte
want bool
}{
{"png", encoded(t, func(b *bytes.Buffer, i image.Image) error { return png.Encode(b, i) }), true},
{"jpeg", encoded(t, func(b *bytes.Buffer, i image.Image) error { return jpeg.Encode(b, i, nil) }), true},
{"gif", encoded(t, func(b *bytes.Buffer, i image.Image) error { return gif.Encode(b, i, nil) }), true},
// The parity fixtures: a PNG and a WebP PHP accepted, and a JPEG
// header without an image PHP refused ("The file is not a valid
// image.").
{"fixture-png", mustHex(t, "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc0000001949444154789c626160a8906360c0442c20021b189c128001005509023d9970f43c0000000049454e44ae426082"), true},
{"fixture-webp", mustHex(t, "524946464800000057454250565038203c0000005003009d012a180010003e6d2c9245a422a19804004006c4a009d3284600fc000108a000feec35853fff497f283d1d7ff3f3396879687ee0b7790000"), true},
{"fixture-spoofed-jpeg", mustHex(t, "ffd8ffe000104a46494600010100000100010000706f6c79676c6f743a2061204a5045472068656164657220776974686f757420616e7920696d616765ffd9"), false},
{"text", []byte("plain text, not a picture"), false},
{"empty", nil, false},
{"svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>`), false},
{"bmp", append([]byte("BM"), make([]byte, 60)...), false},
{"png-header-only", mustHex(t, "89504e470d0a1a0a"), false},
}
for _, c := range cases {
got := classes.IsAllowedImage(c.data)
if got != c.want {
t.Errorf("%s: IsAllowedImage = %v, want %v", c.name, got, c.want)
}
if fw := attach.IsAllowedImage(c.data); fw != got {
t.Errorf("%s: the framework guard says %v, the copy %v", c.name, fw, got)
}
if got {
switch classes.SniffImageMIME(c.data) {
case "image/png", "image/jpeg", "image/gif", "image/webp":
default:
t.Errorf("%s: sniffed %q", c.name, classes.SniffImageMIME(c.data))
}
}
}
}
// roundTrip adapts a func to http.RoundTripper.
type roundTrip func(*http.Request) (*http.Response, error)
func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func jsonAnswer(status int, body string) roundTrip {
return func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
_, _ = io.Copy(io.Discard, r.Body)
}
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
// syncEnv is an app over the shared test database with a memory uploads
// bucket, for StoreSubmission and SyncG15Office.
func syncEnv(t *testing.T) (*backpack.App, *gorm.DB, *blob.Bucket) {
t.Helper()
gdb := pgtest.DB(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: feedback-classes\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if 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 := blob.OpenBucket(t.Context(), "mem://")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = bucket.Close() })
return app, gdb, bucket
}
// recordedSubmission stores the submission PHP filed when the sidecar was
// recorded (the same fields and screenshot) and returns it.
func recordedSubmission(t *testing.T, app *backpack.App, gdb *gorm.DB, bucket *blob.Bucket) *models.Submission {
t.Helper()
str := func(s string) *string { return &s }
sub := &models.Submission{
Message: "Okładka albumu\tjest zła.\n\nDruga linia z dłuższym opisem problemu, który na pewno przekracza osiemdziesiąt znaków tytułu zadania.",
Type: "feature",
Email: str("reporter@parity.test"),
PageURL: "https://app.parity.test/albums/7?tab=photos",
UserAgent: str("Mozilla/5.0 (X11; Linux x86_64) Parity/1.0"),
ConsoleLog: str("[log] " + strings.Repeat("żółć ąę ", 520)),
}
shot := mustHex(t, "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc0000001949444154789c626160a8906360c0442c20021b189c128001005509023d9970f43c0000000049454e44ae426082")
if err := classes.StoreSubmission(t.Context(), app, gdb, bucket, sub, &classes.Screenshot{Filename: "screenshot.png", Data: shot}); err != nil {
t.Fatal(err)
}
return sub
}
func reload(t *testing.T, gdb *gorm.DB, id uint) models.Submission {
t.Helper()
var s models.Submission
if err := gdb.Take(&s, id).Error; err != nil {
t.Fatal(err)
}
return s
}
func defaultSettings(t *testing.T, gdb *gorm.DB) {
t.Helper()
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
// Background: Cleanup runs it after the test's context is cancelled.
if _, err := models.Instance(context.Background(), gdb); err != nil {
t.Fatal(err)
}
}
const parityToken = "parityG15OfficeTokenNotReal01"
func parityClient() *classes.G15OfficeClient {
return classes.NewG15OfficeClientWith("https://office.parity.test/", parityToken, "parity-project", nil)
}
// TestSyncG15Office replays the G15Office exchange PHP's
// SyncFeedbackToG15Office made (testdata/g15office-task.upstream.yaml,
// recorded through the upstream proxy): the task JSON, the Bearer token,
// Accept: application/json, no User-Agent, the screenshot as the multipart
// part "file" with its name, type and sha256. The submission ends as PHP's
// did (sent, task tskParity01). The failure paths store status failed and
// PHP's message and return the error so the job is retried; an unconfigured
// client sends nothing.
func TestSyncG15Office(t *testing.T) {
app, gdb, bucket := syncEnv(t)
defaultSettings(t, gdb)
t.Run("php-recorded", func(t *testing.T) {
sub := recordedSubmission(t, app, gdb, bucket)
sidecar, err := tide.LoadUpstream(filepath.Join("testdata", "g15office-task.upstream.yaml"))
if err != nil {
t.Fatal(err)
}
store, err := tide.OpenStore("")
if err != nil {
t.Fatal(err)
}
store.Set("secret:g15office-token", parityToken)
fake := tide.NewUpstreamFake(sidecar, store)
ctx := fetchguard.WithTransport(t.Context(), fake)
if err := classes.SyncG15Office(ctx, classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
t.Fatal(err)
}
if err := fake.Verify(); err != nil {
t.Fatal(err)
}
got := reload(t, gdb, sub.ID)
if got.Status != models.StatusSent || got.G15OfficeTaskID == nil || *got.G15OfficeTaskID != "tskParity01" || got.G15OfficeError != nil {
t.Fatalf("submission %+v", got)
}
})
failing := func(t *testing.T, client *classes.G15OfficeClient, rt http.RoundTripper, wantErr string) {
t.Helper()
sub := recordedSubmission(t, app, gdb, bucket)
ctx := t.Context()
if rt != nil {
ctx = fetchguard.WithTransport(ctx, rt)
}
err := classes.SyncG15Office(ctx, classes.SyncDeps{DB: gdb, Bucket: bucket, Client: client}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID})
if err == nil || err.Error() != wantErr {
t.Fatalf("error %v, want %q", err, wantErr)
}
got := reload(t, gdb, sub.ID)
if got.Status != models.StatusFailed || got.G15OfficeError == nil || *got.G15OfficeError != wantErr || got.G15OfficeTaskID != nil {
t.Fatalf("submission %+v", got)
}
}
never := roundTrip(func(r *http.Request) (*http.Response, error) {
t.Errorf("unexpected request to %s", r.URL)
return nil, errors.New("no request expected")
})
t.Run("unconfigured-sends-nothing", func(t *testing.T) {
for _, c := range []*classes.G15OfficeClient{
classes.NewG15OfficeClient(nil),
classes.NewG15OfficeClientWith("https://office.parity.test", "", "parity-project", nil),
classes.NewG15OfficeClientWith("https://office.parity.test", parityToken, "", nil),
classes.NewG15OfficeClientWith("", parityToken, "parity-project", nil),
} {
if c.IsConfigured() {
t.Fatal("a partial configuration counts as configured")
}
failing(t, c, never, "g15office is not configured (G15_OFFICE_BASE_URL / G15_OFFICE_TOKEN / G15_OFFICE_PROJECT).")
}
})
t.Run("error-envelope", func(t *testing.T) {
failing(t, parityClient(), jsonAnswer(404, `{"error":true,"message":"Project not found"}`), "g15office create task failed: Project not found")
failing(t, parityClient(), jsonAnswer(500, `{"error":1}`), "g15office create task failed: unknown error")
})
t.Run("missing-task-id", func(t *testing.T) {
failing(t, parityClient(), jsonAnswer(201, `{"success":true,"data":{"number":7}}`), "g15office did not return a task id.")
failing(t, parityClient(), jsonAnswer(201, `{"data":{"id":""}}`), "g15office did not return a task id.")
})
t.Run("invalid-body", func(t *testing.T) {
failing(t, parityClient(), jsonAnswer(502, `<html>Bad gateway</html>`), "g15office create task failed: invalid response body.")
failing(t, parityClient(), jsonAnswer(200, `"ok"`), "g15office create task failed: invalid response body.")
})
t.Run("connection-error", func(t *testing.T) {
failing(t, parityClient(), roundTrip(func(*http.Request) (*http.Response, error) { return nil, errors.New("dial failed") }), "g15office create task failed: connection error.")
})
t.Run("attach-failure", func(t *testing.T) {
calls := 0
rt := roundTrip(func(r *http.Request) (*http.Response, error) {
calls++
if strings.HasSuffix(r.URL.Path, "/tasks") {
return jsonAnswer(201, `{"data":{"id":"tskX"}}`)(r)
}
return jsonAnswer(413, `{"error":true,"message":"File too large"}`)(r)
})
failing(t, parityClient(), rt, "g15office attach file failed: File too large")
if calls != 2 {
t.Fatalf("%d requests", calls)
}
})
t.Run("http-base-url-is-refused", func(t *testing.T) {
failing(t, classes.NewG15OfficeClientWith("http://office.parity.test", parityToken, "parity-project", nil), never, "g15office create task failed: connection error.")
})
t.Run("missing-submission", func(t *testing.T) {
if err := classes.SyncG15Office(fetchguard.WithTransport(t.Context(), never), classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: 999999999}); err != nil {
t.Fatal(err)
}
})
t.Run("settings-status-and-priority", func(t *testing.T) {
status, priority := "Nowe", "urgent"
if err := gdb.Model(&models.Settings{}).Where("id = ?", models.SettingsID).Updates(map[string]any{
"g15office_task_status": status, "g15office_task_priority": priority,
}).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { defaultSettings(t, gdb) })
var body map[string]any
rt := roundTrip(func(r *http.Request) (*http.Response, error) {
if strings.HasSuffix(r.URL.Path, "/tasks") {
raw, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(raw, &body); err != nil {
t.Errorf("task body %s", raw)
}
return jsonAnswer(201, `{"data":{"id":"tskY"}}`)(r)
}
return jsonAnswer(201, `{"data":{"id":"att"}}`)(r)
})
sub := recordedSubmission(t, app, gdb, bucket)
if err := classes.SyncG15Office(fetchguard.WithTransport(t.Context(), rt), classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
t.Fatal(err)
}
if body["status"] != status || body["priority"] != priority || body["type"] != "Feature" {
t.Fatalf("task body %v", body)
}
})
}
// TestTaskText pins buildTitle and buildDescription: whitespace collapsed
// as PCRE's \s (vertical tab included, no-break space not), Str::limit by
// display width (wide characters count two columns), and the optional
// metadata lines skipped for "" and "0" as PHP's truthiness does.
func TestTaskText(t *testing.T) {
limits := []struct{ in, want string }{
{"abc", "abc"},
{strings.Repeat("a", 80), strings.Repeat("a", 80)},
{strings.Repeat("a", 81), strings.Repeat("a", 80) + "..."},
{strings.Repeat("中", 41), strings.Repeat("中", 40) + "..."},
{"a" + strings.Repeat("中", 40), "a" + strings.Repeat("中", 39) + "..."},
{strings.Repeat("a", 79) + " b", strings.Repeat("a", 79) + "..."},
{strings.Repeat("ą", 81), strings.Repeat("ą", 80) + "..."},
}
for _, c := range limits {
if got := classes.StrLimit(c.in, 80, "..."); got != c.want {
t.Errorf("StrLimit(%q) = %q", c.in, got)
}
}
if got := classes.TaskTitle(" a\x0b\x0c b c \n"); got != "Feedback: a b c" {
t.Errorf("title %q", got)
}
if w := classes.StrWidth("ą中😀アA"); w != 1+2+2+1+2 {
t.Errorf("width %d", w)
}
zero, empty := "0", ""
sub := &models.Submission{Message: "Hi", PageURL: "https://example.test/", Email: &zero, UserAgent: &empty, ConsoleLog: &zero}
if got := classes.TaskDescription(sub); got != "Hi\n\n---\n\n- **Page:** https://example.test/" {
t.Errorf("description %q", got)
}
email, ua, log := "a@example.test", "UA", "line1\nline2"
sub = &models.Submission{Message: "Hi", PageURL: "/p", Email: &email, UserAgent: &ua, ConsoleLog: &log}
want := "Hi\n\n---\n\n- **Page:** /p\n- **Reporter email:** a@example.test\n- **User agent:** UA\n\n**Console log:**\n```\nline1\nline2\n```"
if got := classes.TaskDescription(sub); got != want {
t.Errorf("description %q", got)
}
long := strings.Repeat("x", 4001)
sub.ConsoleLog = &long
if got := classes.TaskDescription(sub); !strings.HasSuffix(got, strings.Repeat("x", 4000)+"… (truncated)\n```") {
t.Errorf("console log not cut at 4000 columns")
}
for typ, want := range map[string]string{"bug": "Bug", "feature": "Feature", "other": "Task", "x": "Task"} {
if got := (models.Submission{Type: typ}).G15OfficeTaskType(); got != want {
t.Errorf("type %s -> %s", typ, got)
}
}
}