feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links

- docs/backend: admin controllers, forms, lists and filters, relation
  manager, users and permissions, settings, partials and widgets, admin SPA
- docs/services: storage, outbound HTTP, realtime, Web Push, search, parity
  testing and the Frontend and AJAX (not provided) page
- Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and
  its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse
  and beachcomber TestDocs* regions run on their Postgres harnesses
- concept map rows link their guide pages and the not-provided rows the
  Frontend and AJAX page; index lists Backend, Database and Services
- TestDocsRequiredPages asserts the D-08 section order
This commit is contained in:
Jakub Zych
2026-09-30 23:18:35 +02:00
parent f7dfe68707
commit 44bd1446f5
40 changed files with 2882 additions and 30 deletions

View File

@@ -0,0 +1,258 @@
package beachcomber_test
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// Post is the acme.blog post model. It implements beachcomber.Searchable
// without importing beachcomber.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
BlogID uint `gorm:"column:blog_id"`
Title string `gorm:"column:title"`
Published bool `gorm:"column:published"`
}
func (Post) TableName() string { return "acme_blog_posts" }
// SearchableAs is the index name, before search.prefix.
func (Post) SearchableAs() string { return "acme_blog_posts" }
// ShouldBeSearchable keeps drafts out of the index.
func (p *Post) ShouldBeSearchable() bool { return p.Published }
// ToSearchableArray builds the document from the committed row.
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
return map[string]any{
"id": strconv.FormatUint(uint64(p.ID), 10),
"blog_id": int64(p.BlogID),
"title": p.Title,
}, nil
}
// memoryEngine is a toy engine: it matches Q against the title and ignores
// FilterBy, so its answers are loose candidates, as a stale index's are.
type memoryEngine struct {
mu sync.Mutex
docs map[string]map[string]map[string]any // index -> id -> document
}
func (e *memoryEngine) Name() string { return "acme-memory" }
func (e *memoryEngine) Configured() bool { return true }
func (e *memoryEngine) Upsert(ctx context.Context, index string, schema map[string]any, docs []map[string]any) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.docs[index] == nil {
e.docs[index] = map[string]map[string]any{}
}
for _, d := range docs {
e.docs[index][fmt.Sprint(d["id"])] = d
}
return nil
}
func (e *memoryEngine) Delete(ctx context.Context, index string, ids []string) error {
e.mu.Lock()
defer e.mu.Unlock()
for _, id := range ids {
delete(e.docs[index], id)
}
return nil
}
func (e *memoryEngine) Flush(ctx context.Context, index string) error {
e.mu.Lock()
defer e.mu.Unlock()
delete(e.docs, index)
return nil
}
func (e *memoryEngine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
e.mu.Lock()
defer e.mu.Unlock()
ids := []string{}
for id, d := range e.docs[index] {
if strings.Contains(strings.ToLower(fmt.Sprint(d["title"])), strings.ToLower(q.Q)) {
ids = append(ids, id)
}
}
slices.Sort(ids)
return ids, nil
}
var memory = &memoryEngine{docs: map[string]map[string]map[string]any{}}
func ExampleFrom() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("search.prefix", "staging_")
svc, err := beachcomber.From(backpack.New(cfg)) // search.driver defaults to null
if err != nil {
fmt.Println(err)
return
}
fmt.Println(svc.Engine().Name(), svc.Engine().Configured(), svc.IndexName(&Post{}))
ids, err := svc.Engine().SearchIDs(context.Background(), svc.IndexName(&Post{}), beachcomber.Query{Q: "go"})
fmt.Println(ids, err)
_ = cfg.Set("search.driver", "elastic")
_, err = beachcomber.From(backpack.New(cfg))
fmt.Println(err != nil)
// Output:
// null false staging_acme_blog_posts
// [] <nil>
// true
}
// installGate turns search sync on only while the blog's search setting is
// on; a failed read counts as off.
func installGate(svc *beachcomber.Service) {
// docs:start gate
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
var enabled bool
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
return err == nil && enabled
}))
// docs:end gate
}
// searchPosts answers a search in one blog.
func searchPosts(ctx context.Context, svc *beachcomber.Service, db *gorm.DB, blogID uint, term string) ([]Post, error) {
// docs:start search
ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&Post{}), beachcomber.Query{
Q: term,
QueryBy: []string{"title"},
FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10),
})
if err != nil {
return nil, err
}
// The ids are candidates from an index that may be stale or loosely
// filtered: re-check every one in SQL before exposing a row.
var posts []Post
err = db.WithContext(ctx).
Where("id IN ? AND blog_id = ? AND published", ids, blogID).
Order("id").
Find(&posts).Error
return posts, err
// docs:end search
}
// TestDocsSearch runs the gate and search regions of the Search page on the
// package's Postgres harness.
func TestDocsSearch(t *testing.T) {
app, db := beachcomber.DocsApp(t, nil)
for _, stmt := range []string{
`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE)`,
`CREATE TABLE acme_blog_settings (id INTEGER PRIMARY KEY, search_enabled BOOLEAN NOT NULL)`,
`INSERT INTO acme_blog_settings VALUES (1, TRUE)`,
} {
if err := db.Exec(stmt).Error; err != nil {
t.Fatal(err)
}
}
svc, err := beachcomber.From(app)
if err != nil {
t.Fatal(err)
}
// An application selects a registered engine with search.driver; the
// test installs the toy engine directly, so the global engine list the
// package's own tests check stays unchanged.
beachcomber.DocsUseEngine(svc, memory)
installGate(svc)
ctx := t.Context()
indexed := func() int {
memory.mu.Lock()
defer memory.mu.Unlock()
return len(memory.docs[svc.IndexName(&Post{})])
}
// A committed write is indexed after commit; a rolled-back one never is.
create := func(p *Post, fail bool) error {
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Create(p).Error; err != nil {
return err
}
if fail {
return fmt.Errorf("rolled back")
}
return nil
})
}
hello := &Post{BlogID: 7, Title: "Hello Go", Published: true}
if err := create(hello, false); err != nil {
t.Fatal(err)
}
if err := create(&Post{BlogID: 7, Title: "Go rolled back", Published: true}, true); err == nil {
t.Fatal("rolled-back create succeeded")
}
if err := create(&Post{BlogID: 8, Title: "Go elsewhere", Published: true}, false); err != nil {
t.Fatal(err)
}
if n := indexed(); n != 2 {
t.Fatalf("indexed %d documents, want 2", n)
}
// The engine answers both blogs; SQL keeps blog 7's post only.
posts, err := searchPosts(ctx, svc, db, 7, "go")
if err != nil {
t.Fatal(err)
}
if len(posts) != 1 || posts[0].ID != hello.ID {
t.Fatalf("search = %+v", posts)
}
// A write in a plain GORM transaction is not synced, so the index keeps
// the stale document; the SQL re-check still hides the draft.
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Model(hello).Update("published", false).Error
}); err != nil {
t.Fatal(err)
}
if n := indexed(); n != 2 {
t.Fatalf("indexed %d documents after an unsynced write, want 2", n)
}
if posts, err = searchPosts(ctx, svc, db, 7, "go"); err != nil || len(posts) != 0 {
t.Fatalf("search after unpublish = %+v, %v", posts, err)
}
// With the gate off nothing is sent.
if err := db.Exec(`UPDATE acme_blog_settings SET search_enabled = FALSE`).Error; err != nil {
t.Fatal(err)
}
if err := create(&Post{BlogID: 7, Title: "Go quietly", Published: true}, false); err != nil {
t.Fatal(err)
}
if n := indexed(); n != 2 {
t.Fatalf("indexed %d documents with the gate off, want 2", n)
}
}
// TestDocsDeclarations checks the Searchable methods the Search page shows.
func TestDocsDeclarations(t *testing.T) {
var _ beachcomber.Searchable = &Post{}
p := &Post{ID: 5, BlogID: 7, Title: "Hello", Published: true}
if p.SearchableAs() != "acme_blog_posts" || !p.ShouldBeSearchable() {
t.Fatalf("SearchableAs %q, ShouldBeSearchable %v", p.SearchableAs(), p.ShouldBeSearchable())
}
doc, err := p.ToSearchableArray(t.Context(), nil)
if err != nil || doc["id"] != "5" || doc["blog_id"] != int64(7) {
t.Fatalf("ToSearchableArray = %v, %v", doc, err)
}
}

View File

@@ -0,0 +1,47 @@
package beachcomber
import (
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// DocsApp returns an app with the given config settings on a fresh migrated
// database of this package's Postgres harness, with the database published,
// for the database-backed docs examples in example_test.go. It skips under
// -short and fails when the harness has no database, like the package's
// other database tests.
func DocsApp(t *testing.T, settings map[string]any) (*backpack.App, *gorm.DB) {
t.Helper()
db, dsn := migratedDB(t)
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if err := cfg.Set("database.dsn", dsn); err != nil {
t.Fatal(err)
}
for k, v := range settings {
if err := cfg.Set(k, v); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
gdb, err := lagoon.Use(t.Context(), db)
if err != nil {
t.Fatal(err)
}
if err := lagoon.Publish(app, db, gdb); err != nil {
t.Fatal(err)
}
return app, gdb
}
// DocsUseEngine replaces the engine of svc, for docs examples whose toy
// engine must not join the global engine registry.
func DocsUseEngine(svc *Service, e Engine) {
svc.engine = e
}

View File

@@ -0,0 +1,49 @@
package typesense_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"time"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/beachcomber/typesense"
)
func ExampleEngine_SearchIDs() {
// A stand-in Typesense node.
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fmt.Println(r.Method, r.URL.Path, "key sent:", r.Header.Get("X-TYPESENSE-API-KEY") != "")
fmt.Println("q", q.Get("q"), "query_by", q.Get("query_by"), "filter_by", q.Get("filter_by"))
fmt.Fprint(w, `{"hits":[{"document":{"id":"3"}},{"document":{"id":"1"}}]}`)
}))
defer node.Close()
u, _ := url.Parse(node.URL)
port, _ := strconv.Atoi(u.Port())
engine := typesense.New(typesense.Config{
APIKey: "test-only-key", // SUMMER_SEARCH__TYPESENSE__API_KEY
Host: u.Hostname(),
Port: port,
Protocol: "http",
ConnectionTimeout: 2 * time.Second,
})
ids, err := engine.SearchIDs(context.Background(), "acme_blog_posts", beachcomber.Query{
Q: "go",
QueryBy: []string{"title"},
FilterBy: "blog_id:=7",
})
fmt.Println(ids, err)
// Without an API key the engine is not configured and nothing is sent.
fmt.Println(typesense.New(typesense.Config{}).Configured())
// Output:
// GET /collections/acme_blog_posts/documents/search key sent: true
// q go query_by title filter_by blog_id:=7
// [3 1] <nil>
// false
}

View File

@@ -0,0 +1,133 @@
package cabana_test
import (
"io/fs"
"os"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Post is the model behind the acme.blog posts controller.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Status string `gorm:"column:status"`
Published bool `gorm:"column:published"`
PublishedAt *time.Time `gorm:"column:published_at"`
Content string `gorm:"column:content"`
}
func (Post) TableName() string { return "acme_blog_posts" }
// Fillable lists the columns the admin form may write.
func (Post) Fillable() []string {
return []string{"title", "slug", "status", "published", "content"}
}
// PostsController is the admin controller for posts: the Go form of a
// WinterCMS controller with the List and Form behaviours.
type PostsController struct{}
var (
_ pact.AdminController = PostsController{}
_ pact.AdminRecordSource = PostsController{}
_ pact.AdminPermissioned = PostsController{}
_ pact.HasAdminControllers = (*BlogPlugin)(nil)
)
// ID is the controller ID; its admin API path is /acme/blog/posts.
func (PostsController) ID() string { return "acme.blog.posts" }
// ModelName must equal modelClass in the YAML.
func (PostsController) ModelName() string { return "Post" }
// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
func (PostsController) ConfigDir() string { return "controllers/posts" }
// NewRecord returns the model the generic admin handlers query.
func (PostsController) NewRecord() any { return &Post{} }
// RequiredPermissions are checked before any schema or query.
func (PostsController) RequiredPermissions() []string {
return []string{"acme.blog.access_posts"}
}
// BlogSettings is the singleton row behind the plugin's settings page.
type BlogSettings struct {
ID uint `gorm:"column:id;primaryKey"`
PostsPerPage int `gorm:"column:posts_per_page"`
CommentsEnabled bool `gorm:"column:comments_enabled"`
}
func (BlogSettings) TableName() string { return "acme_blog_settings" }
func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }
// Rules validates a settings save, as a WinterCMS settings model's $rules.
func (BlogSettings) Rules() map[string]string {
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
}
// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
type BlogPlugin struct{}
var (
_ pact.AdminAssets = (*BlogPlugin)(nil)
_ pact.HasPermissions = (*BlogPlugin)(nil)
_ pact.HasNavigation = (*BlogPlugin)(nil)
_ pact.HasSettings = (*BlogPlugin)(nil)
)
func (p *BlogPlugin) ID() string { return "acme.blog" }
func (p *BlogPlugin) Requires() []string { return nil }
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// AdminControllers registers the plugin's admin controllers.
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{PostsController{}}
}
// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
// plugin returns an embed.FS; the example reads the same files from testdata.
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }
// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
}
}
// Navigation replaces registerNavigation().
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "blog",
Label: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_posts"},
Controller: "acme.blog.posts",
SideMenu: []pact.NavigationItem{
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
},
}}
}
// Settings replaces registerSettings().
func (p *BlogPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "blog",
Label: "acme.blog::lang.settings.label",
Description: "acme.blog::lang.settings.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Model: "BlogSettings",
Permissions: []string{"acme.blog.access_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &BlogSettings{} },
}}
}

View File

@@ -0,0 +1,103 @@
package cabana_test
import (
"fmt"
"os"
"testing"
"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/party"
)
func ExampleCompileList() {
// The plugin embeds its controllers/ and models/ trees; the example reads
// the same files from testdata.
fsys := os.DirFS("testdata/docs")
list, err := cabana.CompileList("acme.blog", PostsController{}, fsys)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(list.Title, list.RecordsPerPage, list.PerPageOptions, list.ToolbarButtons)
for _, c := range list.Columns {
fmt.Printf("column %s type=%q searchable=%v sortable=%v\n", c.Key, c.Type, c.Searchable, c.Sortable)
}
for _, f := range list.Filters {
fmt.Printf("filter %s type=%s column=%s\n", f.Name, f.Type, f.Column)
}
// Output:
// acme.blog::lang.posts.title 20 [20 50 100] [create delete]
// column title type="" searchable=true sortable=true
// column published type="switch" searchable=false sortable=true
// column published_at type="datetime" searchable=false sortable=true
// filter published type=switch column=published
// filter published_at type=daterange column=published_at
}
func ExampleCompileForm() {
fsys := os.DirFS("testdata/docs")
form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys)
if err != nil {
fmt.Println(err)
return
}
for _, f := range form.Fields {
fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options))
}
// Output:
// title text span="left" tab="" required=true options=0
// slug text span="right" tab="" required=false options=0
// status dropdown span="left" tab="" required=false options=2
// published switch span="right" tab="" required=false options=0
// content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0
}
func ExampleActivate() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
_ = cfg.Set("backend.uri", "/admin")
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(routes.Prefix)
editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
// Output:
// /admin
// true
// false
}
// TestDocsDeclarations checks the plugin declarations the Backend pages show.
func TestDocsDeclarations(t *testing.T) {
p := &BlogPlugin{}
if n := len(p.Permissions()); n != 2 {
t.Fatalf("Permissions() = %d entries", n)
}
if nav := p.Navigation(); len(nav) != 1 || nav[0].Controller != "acme.blog.posts" {
t.Fatalf("Navigation() = %+v", nav)
}
settings := p.Settings()
if len(settings) != 1 {
t.Fatalf("Settings() = %+v", settings)
}
if _, ok := settings[0].NewModel().(*BlogSettings); !ok {
t.Fatal("settings model is not *BlogSettings")
}
if rule := (BlogSettings{}).Rules()["posts_per_page"]; rule == "" {
t.Fatal("BlogSettings has no posts_per_page rule")
}
}

View File

@@ -0,0 +1,9 @@
scopes:
published:
label: acme.blog::lang.posts.published
type: switch
column: published
published_at:
label: acme.blog::lang.posts.published_at
type: daterange
column: published_at

View File

@@ -0,0 +1,10 @@
name: acme.blog::lang.posts.form
form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts
create:
redirect: acme/blog/posts/update/:id
redirectClose: acme/blog/posts
update:
redirect: acme/blog/posts
redirectClose: acme/blog/posts

View File

@@ -0,0 +1,15 @@
list: ~/plugins/acme/blog/models/post/columns.yaml
modelClass: Post
title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id
recordsPerPage: 20
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: published_at
direction: desc
filter: config_filter.yaml
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

View File

@@ -0,0 +1,10 @@
columns:
title:
label: acme.blog::lang.posts.title_column
searchable: true
published:
label: acme.blog::lang.posts.published
type: switch
published_at:
label: acme.blog::lang.posts.published_at
type: datetime

View File

@@ -0,0 +1,28 @@
fields:
title:
label: acme.blog::lang.posts.title_column
type: text
span: left
required: true
slug:
label: acme.blog::lang.posts.slug
type: text
span: right
context: update
comment: acme.blog::lang.posts.slug_comment
status:
label: acme.blog::lang.posts.status
type: dropdown
span: left
options:
draft: acme.blog::lang.posts.draft
published: acme.blog::lang.posts.published
published:
label: acme.blog::lang.posts.published
type: switch
span: right
content:
label: acme.blog::lang.posts.content
type: textarea
size: large
tab: acme.blog::lang.posts.tab_content

View File

@@ -0,0 +1,10 @@
fields:
posts_per_page:
label: acme.blog::lang.settings.posts_per_page
type: number
span: left
default: 10
comments_enabled:
label: acme.blog::lang.settings.comments_enabled
type: switch
span: right

View File

@@ -0,0 +1,52 @@
package fetchguard_test
import (
"context"
"errors"
"fmt"
"time"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
func ExampleFetch() {
ctx := context.Background()
// Only the application's image host, at most 5 MiB within 5 seconds.
images := fetchguard.Policy{
Mode: fetchguard.AllowHostsMode,
AllowHosts: []string{"images.example.com"},
MaxBytes: 5 << 20,
Timeout: 5 * time.Second,
}
// Any public host, for a URL a user pasted.
public := fetchguard.Policy{Mode: fetchguard.PublicOnlyMode}
for _, c := range []struct {
url string
policy fetchguard.Policy
}{
{"http://images.example.com/cover.jpg", images},
{"https://cdn.attacker.example/cover.jpg", images},
{"https://127.0.0.1/admin", public},
{"https://169.254.169.254/latest/meta-data/", public},
{"https://[::ffff:10.0.0.1]/", public},
{"https://%zz", public},
} {
// The last argument is the application's config (app.Config), for
// limits the policy leaves at zero; nil uses the framework defaults.
_, err := fetchguard.Fetch(ctx, c.url, c.policy, nil)
var fe *fetchguard.Error
if errors.As(err, &fe) {
fmt.Println(fe.Reason, c.url)
}
}
fmt.Println(fetchguard.Defaults())
// Output:
// scheme http://images.example.com/cover.jpg
// invalid_url https://cdn.attacker.example/cover.jpg
// private_ip https://127.0.0.1/admin
// private_ip https://169.254.169.254/latest/meta-data/
// private_ip https://[::ffff:10.0.0.1]/
// invalid_url https://%zz
// 10485760 10s
}

View File

@@ -0,0 +1,112 @@
package flare_test
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/flare"
)
// browserSubscription stands in for the PushSubscription a browser posts to
// the application: an endpoint and the subscriber's p256dh and auth keys.
func browserSubscription(endpoint string) (flare.Subscription, error) {
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return flare.Subscription{}, err
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
return flare.Subscription{}, err
}
enc := base64.RawURLEncoding
return flare.Subscription{
Endpoint: endpoint,
P256dh: enc.EncodeToString(key.PublicKey().Bytes()),
Auth: enc.EncodeToString(auth),
}, nil
}
func ExampleVAPIDPusher_Send() {
// A stand-in push service: 201 for a live subscription, 410 for one the
// browser dropped.
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/gone" {
w.WriteHeader(http.StatusGone)
return
}
fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency"))
w.WriteHeader(http.StatusCreated)
}))
defer push.Close()
keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys
if err != nil {
fmt.Println(err)
return
}
cfg := flare.Config{
Enabled: true,
PublicKey: keys.PublicKey,
PrivateKey: keys.PrivateKey,
Subject: "mailto:admin@example.com",
TTL: time.Hour,
AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services
}
// The test server's client trusts its certificate.
pusher := flare.NewVAPIDPusher(cfg, push.Client())
ctx := context.Background()
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
for _, endpoint := range []string{
push.URL + "/live",
push.URL + "/gone",
"https://push.attacker.example/steal",
"http://127.0.0.1/plain",
} {
sub, err := browserSubscription(endpoint)
if err != nil {
fmt.Println(err)
return
}
err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"})
switch {
case err == nil:
fmt.Println("sent")
case errors.Is(err, flare.ErrSubscriptionGone):
fmt.Println("gone: delete the subscription")
case errors.Is(err, flare.ErrEndpointNotAllowed):
fmt.Println("refused before connecting")
default:
fmt.Println("error:", err)
}
}
// Formatting the keys never prints the private key.
fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey))
// Output:
// push service got aes128gcm 3600 normal
// sent
// gone: delete the subscription
// refused before connecting
// refused before connecting
// false
}
func ExampleHostAllowed() {
allowed := []string{"fcm.googleapis.com", "*.push.apple.com"}
for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} {
fmt.Println(host, flare.HostAllowed(host, allowed))
}
// Output:
// fcm.googleapis.com true
// api.push.apple.com true
// push.apple.com false
// evil.example false
}

View File

@@ -0,0 +1,102 @@
package centrifugo_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
"git.golem15.com/golem15/summercms/modules/surf"
)
// newApp returns an app whose config selects a realtime driver.
func newApp(settings map[string]any) (*backpack.App, error) {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
return nil, err
}
for k, v := range settings {
if err := cfg.Set(k, v); err != nil {
return nil, err
}
}
return backpack.New(cfg), nil
}
func ExampleProxyHandler() {
svc, err := lighthouse.From(backpack.New(nil))
if err != nil {
fmt.Println(err)
return
}
// Members of blog 7 may subscribe to its channels.
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
if userID == 42 && lighthouse.ChannelID(channel) == 7 {
return lighthouse.Allowed(nil)
}
return lighthouse.Denied("not a member of the blog")
}))
if err != nil {
fmt.Println(err)
return
}
// realtime.centrifugo.proxy_secret; set it through the environment.
proxy := centrifugo.ProxyHandler(svc, centrifugo.Config{ProxySecret: "test-only-proxy-secret"})
// What Centrifugo posts to the subscribe proxy.
subscribe := func(secret, user, channel string) {
body := fmt.Sprintf(`{"client":"c1","user":%q,"channel":%q}`, user, channel)
req := httptest.NewRequest(http.MethodPost, "/api/realtime/subscribe", strings.NewReader(body))
req.Header.Set("X-Centrifugo-Secret", secret)
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
fmt.Println(rec.Code, strings.TrimSpace(rec.Body.String()))
}
subscribe("test-only-proxy-secret", "42", "blog:7")
subscribe("test-only-proxy-secret", "5", "blog:7")
subscribe("wrong-secret", "42", "blog:7")
// Output:
// 200 {"result":{"info":[]}}
// 200 {"error":{"code":403,"message":"Access denied"}}
// 200 {"error":{"code":403,"message":"Access denied"}}
}
func ExampleDriver_Routes() {
app, err := newApp(map[string]any{"realtime.driver": "centrifugo"})
if err != nil {
fmt.Println(err)
return
}
svc, err := lighthouse.From(app)
if err != nil {
fmt.Println(err)
return
}
// In a plugin's Routes method, r is the router the plugin receives.
r := surf.New(nil)
err = lighthouse.Mount(r, svc.Driver(), lighthouse.Surfaces{
UserAuth: surf.Use("acme.auth"),
Middleware: surf.Use("throttle:60,1"),
})
if err != nil {
fmt.Println(err)
return
}
for _, rt := range r.Routes() {
fmt.Println(rt.Method, rt.Pattern, rt.Middleware, "raw:", rt.Raw)
}
// A user route without a guard is refused.
err = lighthouse.Mount(surf.New(nil), svc.Driver(), lighthouse.Surfaces{})
fmt.Println(err != nil)
// Output:
// GET /api/realtime/token [acme.auth throttle:60,1] raw: false
// POST /api/realtime/subscribe [throttle:60,1] raw: true
// true
}

View File

@@ -0,0 +1,196 @@
package lighthouse_test
import (
"context"
"fmt"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"gorm.io/gorm"
)
// newApp returns an app whose config selects a realtime driver.
func newApp(settings map[string]any) (*backpack.App, error) {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
return nil, err
}
for k, v := range settings {
if err := cfg.Set(k, v); err != nil {
return nil, err
}
}
return backpack.New(cfg), nil
}
// isMember stands in for the application's own membership query.
func isMember(ctx context.Context, userID uint, blogID int64) bool {
return userID == 42 && blogID == 7
}
func ExampleRegistry_Register() {
app, err := newApp(map[string]any{"realtime.driver": "memory"})
if err != nil {
fmt.Println(err)
return
}
svc, err := lighthouse.From(app)
if err != nil {
fmt.Println(err)
return
}
// blog:{entity}:{id} channels are open to members of the blog only. The
// authorizer runs on every subscribe; nothing is cached.
err = svc.Registry().Register("blog", lighthouse.AuthorizerFunc(
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
if isMember(ctx, userID, lighthouse.ChannelID(channel)) {
return lighthouse.Allowed(nil)
}
return lighthouse.Denied("not a member of the blog")
}))
if err != nil {
fmt.Println(err)
return
}
for _, sub := range []struct {
user uint
channel string
}{{42, "blog:7"}, {42, "blog:8"}, {42, "presence:blog:7"}, {42, "shop:7"}} {
ns, presence := lighthouse.ParseChannel(sub.channel)
auth, ok := svc.Registry().Get(ns)
if !ok {
fmt.Println(sub.channel, "no authorizer")
continue
}
res := auth.Authorize(context.Background(), sub.user, sub.channel)
fmt.Printf("%d %s namespace=%s presence=%v allowed=%v reason=%q\n", sub.user, sub.channel, ns, presence, res.Allowed, res.Reason())
}
fmt.Println(lighthouse.ChannelID("blog:12abc"), lighthouse.FormatChannels("acme", []string{"Blog:7"}))
// Output:
// 42 blog:7 namespace=blog presence=false allowed=true reason=""
// 42 blog:8 namespace=blog presence=false allowed=false reason="not a member of the blog"
// 42 presence:blog:7 namespace=blog presence=true allowed=false reason="not a member of the blog"
// shop:7 no authorizer
// 12 [acme:blog:7]
}
// Post is the acme.blog post model. It knows nothing about realtime.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
BlogID uint `gorm:"column:blog_id"`
Title string `gorm:"column:title"`
}
func (Post) TableName() string { return "acme_blog_posts" }
// bindPosts registers the broadcast contract of Post from the plugin's Boot.
func bindPosts(svc *lighthouse.Service) error {
// docs:start bind
return lighthouse.Bind[Post](svc, lighthouse.Binding[Post]{
Alias: "blog.post",
Channels: func(ctx context.Context, tx *gorm.DB, p *Post) ([]string, error) {
return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil
},
})
// docs:end bind
}
// publishPost creates a post; its broadcast is published after commit.
func publishPost(ctx context.Context, db *gorm.DB, fail bool) error {
// docs:start write
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Create(&Post{BlogID: 7, Title: "Hello"}).Error; err != nil {
return err
}
// The broadcast job is now queued in this transaction. It is
// published only if the transaction commits.
if fail {
return fmt.Errorf("rolled back")
}
return nil
})
// docs:end write
}
// importPosts writes many posts and publishes one summary event.
func importPosts(ctx context.Context, svc *lighthouse.Service, db *gorm.DB, titles []string) error {
// docs:start bulk
return lighthouse.WithoutBroadcasting[Post](ctx, func(ctx context.Context) error {
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
for _, title := range titles {
if err := tx.Create(&Post{BlogID: 7, Title: title}).Error; err != nil {
return err
}
}
return svc.Emit(ctx, tx, lighthouse.Broadcast{
Channels: []string{"blog:7"},
Event: "blog.posts_imported",
Payload: struct {
Count int `json:"count"`
}{len(titles)},
})
})
})
// docs:end bulk
}
// waitFor waits until the memory driver has recorded n publications.
func waitFor(t *testing.T, mem *lighthouse.MemoryDriver, n int) []lighthouse.Publication {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for len(mem.Publications()) < n {
if time.Now().After(deadline) {
t.Fatalf("got %d publications, want %d", len(mem.Publications()), n)
}
time.Sleep(20 * time.Millisecond)
}
time.Sleep(300 * time.Millisecond) // catch extra publications
pubs := mem.Publications()
if len(pubs) != n {
t.Fatalf("got %d publications, want exactly %d: %+v", len(pubs), n, pubs)
}
return pubs
}
// TestDocsBroadcast runs the bind, write and bulk regions of the Realtime
// page on the package's Postgres harness with a job worker.
func TestDocsBroadcast(t *testing.T) {
_, svc, db := lighthouse.DocsEnv(t)
if err := db.Exec(`CREATE TABLE acme_blog_posts (id SERIAL PRIMARY KEY, blog_id INTEGER NOT NULL, title TEXT NOT NULL)`).Error; err != nil {
t.Fatal(err)
}
if err := bindPosts(svc); err != nil {
t.Fatal(err)
}
mem, ok := svc.Driver().(*lighthouse.MemoryDriver)
if !ok {
t.Fatalf("driver is %T", svc.Driver())
}
ctx := t.Context()
if err := publishPost(ctx, db, true); err == nil {
t.Fatal("rolled-back publish succeeded")
}
if err := publishPost(ctx, db, false); err != nil {
t.Fatal(err)
}
pubs := waitFor(t, mem, 1)
if pubs[0].Event != "created.blog.post" || len(pubs[0].Channels) != 1 || pubs[0].Channels[0] != "blog:7" {
t.Fatalf("publication = %+v", pubs[0])
}
if err := importPosts(ctx, svc, db, []string{"A", "B", "C"}); err != nil {
t.Fatal(err)
}
pubs = waitFor(t, mem, 2)
if pubs[1].Event != "blog.posts_imported" || string(pubs[1].Payload) != `{"count":3}` {
t.Fatalf("summary = %+v %s", pubs[1], pubs[1].Payload)
}
}

View File

@@ -0,0 +1,19 @@
package lighthouse
import (
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"gorm.io/gorm"
)
// DocsEnv returns an app with the memory driver on a fresh migrated
// database of this package's Postgres harness, with a job worker running,
// for the database-backed docs examples in example_test.go. It skips under
// -short and fails when the harness has no database, like the package's
// other database tests.
func DocsEnv(t *testing.T) (*backpack.App, *Service, *gorm.DB) {
t.Helper()
env := newLHEnv(t, nil, nil)
return env.app, env.svc, env.gdb
}

View File

@@ -0,0 +1,69 @@
package tide_test
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"git.golem15.com/golem15/summercms/modules/tide"
)
// backend stands in for one implementation of GET /api/blog/posts.
func backend(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
}
func ExampleReplayFlow() {
ctx := context.Background()
// The reference (PHP) backend and two Go ports. IDs and *_at timestamps
// legitimately differ; the second port changed a title.
reference := backend(`{"data":[{"id":12,"title":"Hello","created_at":"2026-09-30T10:00:00+00:00"}]}`)
defer reference.Close()
port := backend(`{"data":[{"id":3,"title":"Hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
defer port.Close()
broken := backend(`{"data":[{"id":3,"title":"hello","created_at":"2026-10-01T08:30:00+00:00"}]}`)
defer broken.Close()
raw, err := os.ReadFile("testdata/docs/posts-spec.yaml")
if err != nil {
fmt.Println(err)
return
}
spec, err := tide.ParseFlow(raw)
if err != nil {
fmt.Println(err)
return
}
// Record the reference once; the flow is what testdata/parity keeps.
flow, err := tide.RecordFlow(ctx, spec, tide.RecordConfig{Target: reference.URL})
if err != nil {
fmt.Println(err)
return
}
for _, target := range []string{port.URL, broken.URL} {
res, err := tide.ReplayFlow(ctx, flow, tide.ReplayConfig{Target: target})
var mismatch *tide.MismatchError
if errors.As(err, &mismatch) {
res = mismatch.Result // a difference is an error carrying the result
} else if err != nil {
fmt.Println(err)
return
}
fmt.Println("ok:", res.OK)
for _, step := range res.Steps {
for _, d := range step.Diffs {
fmt.Printf(" %s %s: want %s, got %s\n", step.ID, d.Path, d.Expected, d.Actual)
}
}
}
// Output:
// ok: true
// ok: false
// list-posts $.data[0].title: want "Hello", got "hello"
}

View File

@@ -0,0 +1,9 @@
version: 1
name: blog-posts
description: List the posts of one blog
steps:
- id: list-posts
route_id: GET /api/blog/posts
request:
method: GET
path: /api/blog/posts?page=1