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 ¬e{} } 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/) and pl (/pl/pages/). 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 }