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": "
Everything about shipping.
", "content": []any{ map[string]any{"question": "How long?", "answer": "Two days.
"}, map[string]any{"question": "Where?", "answer": `Everywhere.
`}, }, } 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"] != "Two days.
" || 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":"Two days.
"},{"question":"Where?","answer":"Everywhere.
"}]`) good := writeFile(t, dir, "faqs.jsonl", `{"id": 4, "name": "Shipping", "slug": "shipping", "locale_code": "en", "is_active": 1, "description": "About shipping.
", "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":"Dwa dni.
"}], "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"] != "Dwa dni.
" { 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 := `About shipping.
`
first := e.insertFAQ(t, "Shipping", "shipping", en, true, &unsafeDesc,
`[{"question":"How long?","answer":"Two days.
"},{"question":"Where?","answer":"Everywhere.
"}]`) 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":"Dwa dni.
"}]`) 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 != "Two days.
" { 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 != "Dwa dni.
" { 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()) } }