Files
sm-faq-plugin/faq_smoke_test.go
Jakub Zych 1585f06b3c feat(17-04): public FAQ API with output sanitizing, throttle bucket and README
- GET /_faq/api/v1/faqs/{slug}?locale=xx, anonymous, faq-public-api bucket (120/min per client IP)
- locale by enabled code or the default locale; active row, lowest id among duplicates
- description and answers passed through seaglass.Sanitize on output; 404 {"error":"FAQ not found"}
- TestFaqAPISmoke; README with API, CORS paths, import workflow and Winter adaptations
- go mod tidy records the test dependencies
2026-10-12 17:51:22 +02:00

501 lines
19 KiB
Go

package faq_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
_ "git.golem15.com/golem15/sm-faq-plugin"
"git.golem15.com/golem15/sm-faq-plugin/console"
"git.golem15.com/golem15/sm-faq-plugin/models"
_ "git.golem15.com/golem15/sm-translate-plugin"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"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/party"
"git.golem15.com/golem15/summercms/modules/surf"
"gorm.io/gorm"
)
const permFAQs = "golem15.faq.faqs"
// faqEnv is a booted application with the translate and FAQ plugins.
type faqEnv struct {
h http.Handler
app *backpack.App
gdb *gorm.DB
}
func bootFAQ(t *testing.T) *faqEnv {
t.Helper()
sqlDB := freshDB(t)
gdb, err := lagoon.Use(t.Context(), sqlDB)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: faq-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=faq-smoke-test-only-hs256-secret-key-0001"},
})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{
"http.body_limits.default_bytes": 1048576,
"http.body_limits.upload_bytes": 8388608,
"mail.driver": "log",
"mail.from": "noreply@example.test",
} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.faq"})
if err != nil {
t.Fatal(err)
}
if err := lagoon.Migrate(gdb, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
return &faqEnv{h: h, app: app, gdb: gdb}
}
// localeID returns the id of the seeded translate locale code.
func (e *faqEnv) localeID(t *testing.T, code string) uint {
t.Helper()
var id uint
if err := e.gdb.Raw(`SELECT id FROM golem15_translate_locales WHERE code = ?`, code).Scan(&id).Error; err != nil || id == 0 {
t.Fatalf("locale %s: id=%d err=%v", code, id, err)
}
return id
}
var adminSeq atomic.Uint64
// call sends an admin API request with an optional bearer token and JSON
// body.
func (e *faqEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, cabana.DefaultAdminPrefix+"/api/v1"+path, reader)
req.Host = "api.example.test"
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// admin signs in a new administrator whose role grants permissions.
func (e *faqEnv) admin(t *testing.T, permissions ...string) string {
t.Helper()
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
login, password := "admin-"+stamp, "admin-secret-"+stamp
hash, err := bouncer.HashPassword(10, password)
if err != nil {
t.Fatal(err)
}
grants := map[string]int{}
for _, code := range permissions {
grants[code] = 1
}
raw, _ := json.Marshal(grants)
var roleID uint
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("backend role: id=%d err=%v", roleID, err)
}
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
login, login+"@example.test", hash, roleID).Error; err != nil {
t.Fatal(err)
}
rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password})
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" {
t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String())
}
return body.Data.AccessToken
}
// adminRecord is a create or update answer.
type adminRecord struct {
Data map[string]any `json:"data"`
Meta struct {
Notices []cabana.FieldNotice `json:"notices"`
} `json:"meta"`
}
// TestFaqAdminSmoke creates a FAQ with two questions through the admin API.
// The second answer holds a script element and an onclick attribute: the
// stored answer has neither, a notice names content.1.answer, and the list
// shows the row with its locale's name.
func TestFaqAdminSmoke(t *testing.T) {
e := bootFAQ(t)
token := e.admin(t, permFAQs)
const base = "/golem15/faq/faqs"
en := e.localeID(t, "en")
rec := e.call(t, http.MethodGet, base+"/schema/form", token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"type":"repeater"`) || !strings.Contains(rec.Body.String(), `"type":"richeditor"`) {
t.Fatalf("form schema: %d %s", rec.Code, rec.Body.String())
}
body := map[string]any{
"name": "Shipping",
"slug": "shipping",
"locale": en,
"is_active": true,
"description": "<p>Everything about shipping.</p>",
"content": []any{
map[string]any{"question": "How long?", "answer": "<p>Two days.</p>"},
map[string]any{"question": "Where?", "answer": `<p onclick="steal()">Everywhere.</p><script>alert(1)</script>`},
},
}
rec = e.call(t, http.MethodPost, base, token, body)
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created adminRecord
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
var notice bool
for _, n := range created.Meta.Notices {
if n.Field == "content.1.answer" && n.Message != "" {
notice = true
}
if n.Field == "content.0.answer" || n.Field == "description" {
t.Errorf("clean HTML reported a notice: %+v", n)
}
}
if !notice {
t.Fatalf("no notice under content.1.answer: %+v", created.Meta.Notices)
}
var stored models.FAQ
if err := e.gdb.Where("slug = ?", "shipping").First(&stored).Error; err != nil {
t.Fatal(err)
}
rows := stored.Items()
if stored.LocaleID != en || !stored.IsActive || len(rows) != 2 {
t.Fatalf("stored = %+v rows=%v", stored, rows)
}
if rows[0]["answer"] != "<p>Two days.</p>" || rows[1]["question"] != "Where?" {
t.Fatalf("stored rows = %v", rows)
}
answer, _ := rows[1]["answer"].(string)
if strings.Contains(answer, "script") || strings.Contains(answer, "onclick") || !strings.Contains(answer, "Everywhere.") {
t.Fatalf("stored answer not sanitized: %q", answer)
}
// The list shows the row with its locale's name.
rec = e.call(t, http.MethodGet, base, token, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"shipping"`) || !strings.Contains(rec.Body.String(), `"English"`) {
t.Fatalf("list: %d %s", rec.Code, rec.Body.String())
}
// A FAQ without a locale is refused.
rec = e.call(t, http.MethodPost, base, token, map[string]any{"name": "No locale", "slug": "no-locale"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"locale":["golem15.faq::lang.errors.locale_required"]`) {
t.Fatalf("create without locale: %d %s", rec.Code, rec.Body.String())
}
var n int64
e.gdb.Model(&models.FAQ{}).Where("slug = ?", "no-locale").Count(&n)
if n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
// Without the permission the screen answers 403.
other := e.admin(t)
if rec := e.call(t, http.MethodGet, base, other, nil); rec.Code != http.StatusForbidden {
t.Fatalf("list without permission: %d %s", rec.Code, rec.Body.String())
}
}
func writeFile(t *testing.T, dir, name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func testOutput() (bonfire.Output, *bytes.Buffer) {
var buf bytes.Buffer
return bonfire.NewOutput(strings.NewReader(""), &buf, &buf), &buf
}
func (e *faqEnv) countFAQs(t *testing.T) int64 {
t.Helper()
var n int64
if err := e.gdb.Model(&models.FAQ{}).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
// TestFaqImportSmoke copies a Winter export through faq:import-winter: two
// locales by code, ids kept, the sequence moved past them, an unknown code
// aborting before any write, a second copy refused without --force and an
// empty file reporting zero.
func TestFaqImportSmoke(t *testing.T) {
e := bootFAQ(t)
ctx := t.Context()
dir := t.TempDir()
en, pl := e.localeID(t, "en"), e.localeID(t, "pl")
content, _ := json.Marshal(`[{"question":"How long?","answer":"<p>Two days.</p>"},{"question":"Where?","answer":"<p>Everywhere.</p>"}]`)
good := writeFile(t, dir, "faqs.jsonl",
`{"id": 4, "name": "Shipping", "slug": "shipping", "locale_code": "en", "is_active": 1, "description": "<p>About shipping.</p>", "content": `+string(content)+`, "created_at": "2024-03-01 10:00:00.000000", "updated_at": "2024-03-02 11:30:00.000000"}`+"\n"+
`{"id": 9, "name": "Wysyłka", "slug": "shipping", "locale_code": "pl", "is_active": 0, "description": null, "content": [{"question":"Jak długo?","answer":"<p>Dwa dni.</p>"}], "created_at": null, "updated_at": null}`+"\n\n")
unknown := writeFile(t, dir, "unknown.jsonl",
`{"id": 1, "name": "A", "slug": "a", "locale_code": "en", "is_active": 1, "content": null}`+"\n"+
`{"id": 2, "name": "B", "slug": "b", "locale_code": "de", "is_active": 1, "content": null}`+"\n")
empty := writeFile(t, dir, "empty.jsonl", "\n")
// An unknown locale code aborts before anything is written.
out, buf := testOutput()
if _, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: unknown}, out); err == nil || !strings.Contains(err.Error(), "'de'") {
t.Fatalf("unknown locale: %v\n%s", err, buf.String())
}
if n := e.countFAQs(t); n != 0 {
t.Fatalf("unknown locale wrote %d rows", n)
}
// A dry run reports and writes nothing.
out, buf = testOutput()
if report, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good, DryRun: true}, out); err != nil || report.FAQs != 2 {
t.Fatalf("dry run: %+v %v\n%s", report, err, buf.String())
}
if n := e.countFAQs(t); n != 0 || !strings.Contains(buf.String(), "Dry run complete") {
t.Fatalf("dry run wrote %d rows\n%s", n, buf.String())
}
out, buf = testOutput()
report, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good}, out)
if err != nil || report.FAQs != 2 {
t.Fatalf("import: %+v %v\n%s", report, err, buf.String())
}
var rows []models.FAQ
if err := e.gdb.Order("id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != 4 || rows[0].LocaleID != en || !rows[0].IsActive || rows[1].ID != 9 || rows[1].LocaleID != pl || rows[1].IsActive {
t.Fatalf("copied rows = %+v", rows)
}
if items := rows[0].Items(); len(items) != 2 || items[1]["question"] != "Where?" || rows[0].CreatedAt == nil || !rows[0].CreatedAt.Equal(time.Date(2024, 3, 1, 10, 0, 0, 0, time.UTC)) {
t.Fatalf("copied content = %v created %v", items, rows[0].CreatedAt)
}
if items := rows[1].Items(); len(items) != 1 || items[0]["answer"] != "<p>Dwa dni.</p>" {
t.Fatalf("copied pl content = %v", items)
}
// The sequence moved past the copied ids: an admin create gets id 10.
token := e.admin(t, permFAQs)
rec := e.call(t, http.MethodPost, "/golem15/faq/faqs", token, map[string]any{"name": "Returns", "slug": "returns", "locale": en})
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"id":10`) {
t.Fatalf("create after import: %d %s", rec.Code, rec.Body.String())
}
// A second copy is refused without --force and replaces with it.
out, buf = testOutput()
if _, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good}, out); err == nil || !strings.Contains(err.Error(), "--force") {
t.Fatalf("second import: %v\n%s", err, buf.String())
}
if n := e.countFAQs(t); n != 3 {
t.Fatalf("refused import changed the table: %d rows", n)
}
out, buf = testOutput()
if _, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: good, Force: true}, out); err != nil {
t.Fatalf("forced import: %v\n%s", err, buf.String())
}
if n := e.countFAQs(t); n != 2 {
t.Fatalf("forced import left %d rows", n)
}
// An empty file writes nothing and reports zero.
out, buf = testOutput()
if report, err := console.ImportWinterFile(ctx, e.gdb, console.WinterOptions{File: empty}, out); err != nil || report.FAQs != 0 || !strings.Contains(buf.String(), "0 FAQs") {
t.Fatalf("empty file: %+v %v\n%s", report, err, buf.String())
}
if n := e.countFAQs(t); n != 2 {
t.Fatalf("empty file changed the table: %d rows", n)
}
}
// publicGet sends an anonymous GET to a public path from remote, or from a
// fresh client address when remote is "".
func (e *faqEnv) publicGet(t *testing.T, path, remote string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "api.example.test"
if remote == "" {
remote = fmt.Sprintf("203.0.113.%d:5000", adminSeq.Add(1)%250+1)
}
req.RemoteAddr = remote
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// insertFAQ writes a row with raw SQL, past the admin save gate, and returns
// its id.
func (e *faqEnv) insertFAQ(t *testing.T, name, slug string, localeID uint, active bool, description *string, content string) uint {
t.Helper()
var id uint
if err := e.gdb.Raw(`INSERT INTO golem15_faq_faqs (name, slug, locale_id, is_active, description, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) RETURNING id`,
name, slug, localeID, active, description, content).Scan(&id).Error; err != nil || id == 0 {
t.Fatalf("insert faq %s: id=%d err=%v", slug, id, err)
}
return id
}
type faqBody struct {
Data struct {
Name string `json:"name"`
Slug string `json:"slug"`
Locale string `json:"locale"`
Description *string `json:"description"`
Content []struct {
Question string `json:"question"`
Answer string `json:"answer"`
} `json:"content"`
} `json:"data"`
}
func (e *faqEnv) getFAQ(t *testing.T, path string) faqBody {
t.Helper()
rec := e.publicGet(t, path, "")
var body faqBody
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body.String())
}
return body
}
// TestFaqAPISmoke reads FAQs through the anonymous public API: the default
// locale without ?locale, the requested locale with it, 404 JSON for an
// inactive row, an unknown or disabled locale and an unknown slug, rows
// written with raw SQL served sanitized, the lowest id among duplicate slugs
// and the per-client throttle.
func TestFaqAPISmoke(t *testing.T) {
e := bootFAQ(t)
en, pl := e.localeID(t, "en"), e.localeID(t, "pl")
var de uint
if err := e.gdb.Raw(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES ('de', 'Deutsch', FALSE, FALSE, 3) RETURNING id`).Scan(&de).Error; err != nil || de == 0 {
t.Fatalf("locale de: %d %v", de, err)
}
unsafeDesc := `<p>About shipping.</p><img src="/x.png" onerror="alert(1)">`
first := e.insertFAQ(t, "Shipping", "shipping", en, true, &unsafeDesc,
`[{"question":"How long?","answer":"<p>Two days.</p><script>alert(1)</script>"},{"question":"Where?","answer":"<p onclick=\"steal()\">Everywhere.</p>"}]`)
dup := e.insertFAQ(t, "Shipping copy", "shipping", en, true, nil, `[]`)
if dup <= first {
t.Fatalf("duplicate id %d not after %d", dup, first)
}
e.insertFAQ(t, "Wysyłka", "shipping", pl, true, nil, `[{"question":"Jak długo?","answer":"<p>Dwa dni.</p>"}]`)
e.insertFAQ(t, "Versand", "shipping", de, true, nil, `[]`)
e.insertFAQ(t, "Returns", "returns", en, false, nil, `[]`)
// Without ?locale the default locale (en) is served; duplicates serve
// the lowest id; HTML is sanitized on output.
for _, path := range []string{"/_faq/api/v1/faqs/shipping", "/_faq/api/v1/faqs/shipping?locale=en"} {
got := e.getFAQ(t, path)
d := got.Data
if d.Name != "Shipping" || d.Slug != "shipping" || d.Locale != "en" || len(d.Content) != 2 {
t.Fatalf("%s = %+v", path, d)
}
if d.Description == nil || strings.Contains(*d.Description, "onerror") || !strings.Contains(*d.Description, "About shipping.") {
t.Fatalf("%s description = %v", path, d.Description)
}
if d.Content[0].Question != "How long?" || d.Content[0].Answer != "<p>Two days.</p>" {
t.Fatalf("%s first row = %+v", path, d.Content[0])
}
if strings.Contains(d.Content[1].Answer, "onclick") || !strings.Contains(d.Content[1].Answer, "Everywhere.") {
t.Fatalf("%s second answer = %q", path, d.Content[1].Answer)
}
}
// ?locale=pl serves the pl row; a null description stays null.
got := e.getFAQ(t, "/_faq/api/v1/faqs/shipping?locale=pl")
if got.Data.Name != "Wysyłka" || got.Data.Locale != "pl" || got.Data.Description != nil || len(got.Data.Content) != 1 || got.Data.Content[0].Answer != "<p>Dwa dni.</p>" {
t.Fatalf("pl = %+v", got.Data)
}
rec := e.publicGet(t, "/_faq/api/v1/faqs/shipping?locale=pl", "")
if !strings.Contains(rec.Body.String(), `"description":null`) {
t.Fatalf("null description not encoded as null: %s", rec.Body.String())
}
// Misses are 404 JSON: an inactive row, an unknown slug, a malformed
// slug, an unknown locale and a disabled locale.
for _, path := range []string{
"/_faq/api/v1/faqs/returns",
"/_faq/api/v1/faqs/nope",
"/_faq/api/v1/faqs/Shipping",
"/_faq/api/v1/faqs/shipping?locale=xx",
"/_faq/api/v1/faqs/shipping?locale=de",
} {
rec := e.publicGet(t, path, "")
if rec.Code != http.StatusNotFound || rec.Body.String() != `{"error":"FAQ not found"}` {
t.Errorf("GET %s: %d %s", path, rec.Code, rec.Body.String())
}
}
// One client gets 120 requests a minute, then surf's 429 body.
const client = "192.0.2.77:6000"
for i := 0; i < 120; i++ {
if rec := e.publicGet(t, "/_faq/api/v1/faqs/nope", client); rec.Code != http.StatusNotFound {
t.Fatalf("request %d: %d %s", i+1, rec.Code, rec.Body.String())
}
}
rec = e.publicGet(t, "/_faq/api/v1/faqs/shipping", client)
if rec.Code != http.StatusTooManyRequests || !strings.Contains(rec.Body.String(), "Too Many Attempts.") {
t.Fatalf("121st request: %d %s", rec.Code, rec.Body.String())
}
if rec := e.publicGet(t, "/_faq/api/v1/faqs/shipping", ""); rec.Code != http.StatusOK {
t.Fatalf("another client after the limit: %d %s", rec.Code, rec.Body.String())
}
}