Files
sm-seo-plugin/fixture_test.go

557 lines
19 KiB
Go

package seo_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
seo "git.golem15.com/golem15/sm-seo-plugin"
"git.golem15.com/golem15/summercms/modules/backpack"
"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/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/signpost"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// The fake content plugin acme.pages: pages (a tabbed admin form) behind
// the menu item type acme-page (/pages/:slug), with a content descriptor
// naming the admin controller. It knows nothing about the SEO plugin.
const (
pagesControllerID = "acme.pages.pages"
pagesPermission = "acme.pages.access"
pagesTypeKey = "acme-page"
pagesModel = "acme_pages_pages"
tagsTypeKey = "acme-tag"
notesControllerID = "acme.pages.notes"
notesTypeKey = "acme-note"
notesModel = "acme_pages_notes"
adminJWTSecret = "seo-admin-test-only-hs256-secret-key"
siteURL = "https://www.example.test"
)
// page is the fake content model.
type page struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Excerpt string `gorm:"column:excerpt"`
Published bool `gorm:"column:published"`
}
func (page) TableName() string { return pagesModel }
func (*page) Fillable() []string { return []string{"title", "slug", "excerpt", "published"} }
func (*page) Rules() map[string]string { return map[string]string{} }
func (pagesController) ID() string { return pagesControllerID }
func (pagesController) ModelName() string { return "Page" }
func (pagesController) ConfigDir() string { return "controllers/pages" }
func (pagesController) NewRecord() any { return &page{} }
func (pagesController) RequiredPermissions() []string {
return []string{pagesPermission}
}
type pagesController struct{}
// note is the model of the tab-less acme.pages.notes form.
type note struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
}
func (note) TableName() string { return notesModel }
func (*note) Fillable() []string { return []string{"title", "slug"} }
func (*note) Rules() map[string]string { return map[string]string{} }
func (notesController) ID() string { return notesControllerID }
func (notesController) ModelName() string { return "Note" }
func (notesController) ConfigDir() string { return "controllers/notes" }
func (notesController) NewRecord() any { return &note{} }
func (notesController) RequiredPermissions() []string {
return []string{pagesPermission}
}
type notesController struct{}
// pagesPlugin is acme.pages.
type pagesPlugin struct {
app *backpack.App
}
var pagesFields = `fields:
title:
label: Title
type: text
slug:
label: Slug
type: text
excerpt:
label: Excerpt
type: textarea
tab: Content
published:
label: Published
type: switch
tab: Manage
`
func (*pagesPlugin) ID() string { return "acme.pages" }
func (*pagesPlugin) Requires() []string { return nil }
func (p *pagesPlugin) Register(app *backpack.App) error {
p.app = app
// The fixture stands in for the translate plugin: en is the default
// locale and pl is enabled, so translatable admin values are locale
// maps as on a real site.
return app.Publish[cabana.TranslationWriter](&fakeTranslations{values: map[string]string{}})
}
func (*pagesPlugin) Boot(*backpack.App) error { return nil }
func (*pagesPlugin) AdminFS() fs.FS {
return fstest.MapFS{
"controllers/pages/config_form.yaml": &fstest.MapFile{Data: []byte("name: Page\nform: ~/plugins/acme/pages/models/page/fields.yaml\nmodelClass: Page\n")},
"controllers/pages/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/pages/models/page/columns.yaml\nmodelClass: Page\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
"models/page/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
"models/page/fields.yaml": &fstest.MapFile{Data: []byte(pagesFields)},
"controllers/notes/config_form.yaml": &fstest.MapFile{Data: []byte("name: Note\nform: ~/plugins/acme/pages/models/note/fields.yaml\nmodelClass: Note\n")},
"controllers/notes/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/pages/models/note/columns.yaml\nmodelClass: Note\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
"models/note/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
"models/note/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n title:\n label: Title\n type: text\n slug:\n label: Slug\n type: text\n")},
}
}
func (*pagesPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{pagesController{}, notesController{}}
}
func (*pagesPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: pagesPermission, Tab: "Pages", Label: "Manage pages"}}
}
func (*pagesPlugin) Migrations() []*gormigrate.Migration {
return []*gormigrate.Migration{{
ID: "acme_pages_create",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE acme_pages_pages (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL DEFAULT '',
excerpt TEXT NOT NULL DEFAULT '',
published BOOLEAN NOT NULL DEFAULT FALSE
)`).Error
},
}, {
ID: "acme_notes_create",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE acme_pages_notes (id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL DEFAULT '', slug TEXT NOT NULL DEFAULT '')`).Error
},
}}
}
func (*pagesPlugin) Models() []any { return []any{page{}, note{}} }
// db is the write transaction in ctx, else the application database.
func (p *pagesPlugin) db(ctx context.Context) *gorm.DB {
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return tx
}
db, _ := p.app.Lookup[*gorm.DB]()
return db.WithContext(ctx)
}
// describe answers a page in en (/pages/<slug>) and pl (/pl/pages/<slug>).
func (p *pagesPlugin) describe(row page) pact.ContentRecord {
return pact.ContentRecord{
ID: strconv.FormatUint(uint64(row.ID), 10),
Published: row.Published,
Locales: []pact.ContentLocale{
{Locale: "en", URL: "/pages/" + row.Slug, Title: row.Title, Description: row.Excerpt},
{Locale: "pl", URL: "/pl/pages/" + row.Slug, Title: row.Title, Description: row.Excerpt},
},
}
}
func (p *pagesPlugin) MenuItemTypes() []pact.MenuItemType {
return []pact.MenuItemType{p.pageType(), p.tagType(), p.noteType()}
}
// noteType is acme-note (/notes/:slug), edited in a form without tabs.
func (p *pagesPlugin) noteType() pact.MenuItemType {
return pact.MenuItemType{
Key: notesTypeKey,
Label: "Notes",
DefaultPattern: "/notes/:slug",
Placeholders: []string{"slug"},
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return nil, nil
},
Content: &pact.ContentDescriptor{
Controller: notesControllerID,
Model: notesModel,
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
var rows []note
if err := p.db(ctx).Where("id = ?", in.ID).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, err
}
return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/notes/" + rows[0].Slug, Title: rows[0].Title}}}, nil
},
},
}
}
// tagType is acme-tag (/pages/tag/:slug): a content type without an admin
// controller, so it gets per-type settings but no SEO tab.
func (p *pagesPlugin) tagType() pact.MenuItemType {
return pact.MenuItemType{
Key: tagsTypeKey,
Label: "Tags",
DefaultPattern: "/pages/tag/:slug",
Placeholders: []string{"slug"},
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return nil, nil
},
Content: &pact.ContentDescriptor{
Model: "acme_pages_tags",
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/tag/" + in.ID, Title: "Tag " + in.ID}}}, nil
},
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
values, ok := signpost.MatchPattern(in.Pattern, in.Path)
if !ok {
return nil, nil
}
return &pact.ContentMatchResult{ID: values["slug"], Locale: "en", Exact: true}, nil
},
},
}
}
// pageType is acme-page (/pages/:slug, /pl/pages/:slug).
func (p *pagesPlugin) pageType() pact.MenuItemType {
return pact.MenuItemType{
Key: pagesTypeKey,
Label: "Page",
DefaultPattern: "/pages/:slug",
Placeholders: []string{"slug"},
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return nil, nil
},
Content: &pact.ContentDescriptor{
Controller: pagesControllerID,
Model: pagesModel,
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
var rows []page
if err := p.db(ctx).Where("id = ?", in.ID).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, err
}
rec := p.describe(rows[0])
return &rec, nil
},
Records: func(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
after, _ := strconv.ParseUint(in.After, 10, 64)
var rows []page
if err := p.db(ctx).Where("id > ?", after).Order("id ASC").Limit(in.Limit).Find(&rows).Error; err != nil {
return pact.ContentPage{}, err
}
out := pact.ContentPage{}
for _, row := range rows {
out.Records = append(out.Records, p.describe(row))
}
if len(rows) == in.Limit && len(rows) > 0 {
out.Next = out.Records[len(out.Records)-1].ID
}
return out, nil
},
Match: func(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
path, locale := in.Path, "en"
if rest, ok := strings.CutPrefix(path, "/pl/"); ok {
path, locale = "/"+rest, "pl"
}
values, ok := signpost.MatchPattern(in.Pattern, path)
if !ok {
return nil, nil
}
var rows []page
if err := p.db(ctx).Where("slug = ? AND published", values["slug"]).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, err
}
return &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(rows[0].ID), 10), Locale: locale, Exact: true}, nil
},
},
}
}
var (
_ party.Plugin = (*pagesPlugin)(nil)
_ pact.HasAdminControllers = (*pagesPlugin)(nil)
_ pact.HasMenuItemTypes = (*pagesPlugin)(nil)
_ pact.HasMigrations = (*pagesPlugin)(nil)
_ pact.HasPermissions = (*pagesPlugin)(nil)
)
func init() {
party.Register(&pagesPlugin{})
}
// testEnv is a booted application with acme.pages and golem15.seo.
type testEnv struct {
h http.Handler
app *backpack.App
db *gorm.DB
sql *sql.DB
}
var seq atomic.Uint64
// adminPrefix is the admin JSON API prefix.
func adminAPI(rel string) string { return cabana.DefaultAdminPrefix + "/api/v1" + rel }
// bootEnv boots acme.pages and golem15.seo on a fresh database.
func bootEnv(t *testing.T, extraEnv ...string) *testEnv {
t.Helper()
return bootPlugins(t, []string{"acme.pages", "golem15.seo"}, extraEnv...)
}
// bootPlugins boots the plugins ids on a fresh database.
func bootPlugins(t *testing.T, ids []string, extraEnv ...string) *testEnv {
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: seo-test\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Environ: append([]string{
"SUMMER_ENV=production",
"SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret,
"SUMMER_GOLEM15__SEO__SITE_URL=" + siteURL,
}, extraEnv...),
})
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)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, ids)
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 &testEnv{h: h, app: app, db: gdb, sql: sqlDB}
}
// admin creates a backend user with the given grants (superuser when
// super is true) and returns its access token.
func (e *testEnv) admin(t *testing.T, super bool, permissions ...string) string {
t.Helper()
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, seq.Add(1))
login := "admin-" + stamp
password := "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, err := json.Marshal(grants)
if err != nil {
t.Fatal(err)
}
var roleID uint
if err := e.db.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.db.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, ?, ?, NOW(), NOW())`,
login, login+"@example.test", hash, super, roleID).Error; err != nil {
t.Fatal(err)
}
rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password})
if rec.Code != http.StatusOK {
t.Fatalf("admin login %s: %d %s", login, rec.Code, rec.Body.String())
}
var body struct {
Data struct {
AccessToken string `json:"access_token"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" {
t.Fatalf("admin login body: %s err=%v", rec.Body.String(), err)
}
return body.Data.AccessToken
}
// call runs one request against the application.
func (e *testEnv) 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, path, reader)
req.Host = "attacker.example"
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.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
}
// expect runs a request and fails unless it answers status.
func (e *testEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
rec := e.call(t, method, path, token, body)
if rec.Code != status {
t.Fatalf("%s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
}
return rec
}
// createPage creates a page through the admin and returns its id.
func (e *testEnv) createPage(t *testing.T, token string, body map[string]any) string {
t.Helper()
rec := e.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/acme/pages/pages"), token, body)
var out struct {
Data struct {
ID json.Number `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Data.ID == "" {
t.Fatalf("create body %s: %v", rec.Body.String(), err)
}
return out.Data.ID.String()
}
// resolve calls the public resolve endpoint.
func (e *testEnv) resolve(t *testing.T, target string) map[string]any {
t.Helper()
rec := e.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/resolve?url="+urlQueryEscape(target), "", nil)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
// schemaField is one field of a form schema.
type schemaField struct {
Name string `json:"name"`
Tab string `json:"tab"`
}
// formSchema returns the form schema fields of controller (vendor/plugin/ctl).
func (e *testEnv) formSchema(t *testing.T, token, controller string) []schemaField {
t.Helper()
rec := e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/"+controller+"/schema/form"), token, nil)
var out struct {
Data struct {
Fields []schemaField `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Data.Fields
}
// seoPlugin returns the activated SEO plugin.
func (e *testEnv) seoPlugin(t *testing.T) *seo.Plugin {
t.Helper()
plugins, ok := e.app.Lookup[party.Activated]()
if !ok {
t.Fatal("no activated plugins")
}
for _, p := range plugins {
if sp, ok := p.(*seo.Plugin); ok {
return sp
}
}
t.Fatal("golem15.seo is not activated")
return nil
}
// jsonContains reports whether body contains the compact JSON fragment.
func jsonContains(body, fragment string) bool {
return bytes.Contains([]byte(body), []byte(fragment))
}
// fakeTranslations is an in-memory cabana.TranslationWriter: en default, pl
// enabled.
type fakeTranslations struct {
mu sync.Mutex
values map[string]string
}
func (*fakeTranslations) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil }
func (*fakeTranslations) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
return []string{"en", "pl"}, nil
}
func (f *fakeTranslations) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.values[fmt.Sprintf("%T|%s|%s", model, field, locale)] = value
return nil
}
func (f *fakeTranslations) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.values[fmt.Sprintf("%T|%s|%s", model, field, locale)]
return v, ok, nil
}