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
This commit is contained in:
Jakub Zych
2026-10-04 00:22:08 +02:00
parent 92e1b4e09c
commit af5d77c997
19 changed files with 2285 additions and 6 deletions

View File

@@ -3,9 +3,13 @@ package feedback
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"image"
"image/png"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -18,12 +22,14 @@ import (
"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"
@@ -38,6 +44,10 @@ import (
// 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"
@@ -54,6 +64,7 @@ func testConfig(t testing.TB) *compass.Config {
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)
@@ -782,3 +793,162 @@ func TestFeedbackOptionsPreflight(t *testing.T) {
}
}
}
// 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)
}
}