- docs/database: models, migrations, queries and pagination, relations, casts and validation, attachments and transactions (lagoon.Transaction, lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase) - docs/services: configuration, events, routing with auth groups, rate limiting, authentication, the OAuth server, mail and localization - runnable Examples for lagoon, attach, compass, surf, wire, bouncer, wristband, postcard, phrasebook and festival; lagoon TestDocs* regions run on the package's Postgres harness through DocsDB - 15 new required pages
209 lines
6.7 KiB
Go
209 lines
6.7 KiB
Go
package surf_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
)
|
|
|
|
// secret signs the example's tokens. A real application reads its JWT
|
|
// secret from configuration.
|
|
const secret = "example-secret-that-is-long-enough"
|
|
|
|
// users loads the principal behind a token subject.
|
|
type users struct{}
|
|
|
|
func (users) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
|
return &bouncer.Principal{ID: id}, nil
|
|
}
|
|
|
|
// BlogPlugin is the acme.blog plugin; only its HTTP surface is shown here.
|
|
type BlogPlugin struct {
|
|
trusted []netip.Prefix
|
|
}
|
|
|
|
func (p *BlogPlugin) ID() string { return "acme.blog" }
|
|
func (p *BlogPlugin) Requires() []string { return nil }
|
|
|
|
// Register reads http.trusted_proxies once, for the plugin's bucket keys.
|
|
func (p *BlogPlugin) Register(app *backpack.App) error {
|
|
p.trusted = surf.TrustedProxies(app.Config)
|
|
return nil
|
|
}
|
|
|
|
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
|
|
|
|
// Middlewares registers the plugin's named middleware: here, a JWT guard
|
|
// that answers 401 when the request has no valid token.
|
|
func (p *BlogPlugin) Middlewares() map[string]pact.Middleware {
|
|
guards := bouncer.NewRegistry()
|
|
guard := bouncer.NewJWTGuard(secret, users{}, bouncer.NewMemoryBlacklist())
|
|
if err := guards.Register(p.ID(), "acme.auth", guard); err != nil {
|
|
panic(err)
|
|
}
|
|
auth, err := guards.Middleware("acme.auth")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return map[string]pact.Middleware{"acme.auth": auth}
|
|
}
|
|
|
|
// Buckets declares a named rate limit, used as throttle:blog.comments.
|
|
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
|
|
return map[string]surf.Bucket{
|
|
"blog.comments": {
|
|
Max: 1,
|
|
Decay: time.Minute,
|
|
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
|
|
},
|
|
}
|
|
}
|
|
|
|
// Routes is the Go form of the plugin's routes.php.
|
|
func (p *BlogPlugin) Routes(r pact.Router) error {
|
|
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
|
|
g.Get("/posts/{id}", showPost)
|
|
g.Where("id", `[0-9]+`)
|
|
g.Get("/posts/{status}/list", listPosts)
|
|
g.WhereIn("status", "draft", "published")
|
|
|
|
// An auth group: every route inside needs a signed-in user.
|
|
g.Group("", surf.Use("acme.auth"), func(auth pact.Router) {
|
|
auth.Get("/me", showMe)
|
|
auth.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
|
|
})
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func showPost(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := surf.IntParam(r, "id")
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
|
|
}
|
|
|
|
func listPosts(w http.ResponseWriter, r *http.Request) {
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"status": r.PathValue("status"), "data": wire.Slice[string](nil)})
|
|
}
|
|
|
|
func showMe(w http.ResponseWriter, r *http.Request) {
|
|
user, _ := bouncer.User(r.Context())
|
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": user.ID})
|
|
}
|
|
|
|
func addComment(w http.ResponseWriter, r *http.Request) {
|
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{"created": true})
|
|
}
|
|
|
|
func ExampleAssemble() {
|
|
// The application passes its config; http.body_limits is required there.
|
|
app := backpack.New(nil)
|
|
plugin := &BlogPlugin{}
|
|
if err := plugin.Register(app); err != nil { // the runtime calls Register
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
h, err := surf.Assemble(app, []party.Plugin{plugin})
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
token, _, _ := bouncer.Mint(secret, "42", "http://127.0.0.1:8080/api/login", time.Hour)
|
|
|
|
do := func(method, path string, auth bool) {
|
|
req := httptest.NewRequest(method, path, strings.NewReader("{}"))
|
|
if auth {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
fmt.Println(method, path, rec.Code, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
do("GET", "/api/blog/posts/7", false)
|
|
do("GET", "/api/blog/posts/seven", false)
|
|
do("GET", "/api/blog/posts/draft/list", false)
|
|
do("GET", "/api/blog/posts/deleted/list", false)
|
|
do("GET", "/api/blog/me", false)
|
|
do("GET", "/api/blog/me", true)
|
|
do("POST", "/api/blog/posts/7/comments", true)
|
|
do("POST", "/api/blog/posts/7/comments", true)
|
|
// Output:
|
|
// GET /api/blog/posts/7 200 {"id":7}
|
|
// GET /api/blog/posts/seven 404 404 page not found
|
|
// GET /api/blog/posts/draft/list 200 {"data":[],"status":"draft"}
|
|
// GET /api/blog/posts/deleted/list 404 404 page not found
|
|
// GET /api/blog/me 401 {"error":true,"message":"Token not provided"}
|
|
// GET /api/blog/me 200 {"id":42}
|
|
// POST /api/blog/posts/7/comments 201 {"created":true}
|
|
// POST /api/blog/posts/7/comments 429 {"message":"Too Many Attempts."}
|
|
}
|
|
|
|
func ExampleBuildRouter() {
|
|
r, err := surf.BuildRouter(backpack.New(nil), []party.Plugin{&BlogPlugin{}})
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
for _, rt := range r.Routes() {
|
|
fmt.Println(rt.Method, rt.Pattern, rt.PluginID, rt.Middleware)
|
|
}
|
|
// Output:
|
|
// GET /api/blog/posts/{id} acme.blog [throttle:60,1]
|
|
// GET /api/blog/posts/{status}/list acme.blog [throttle:60,1]
|
|
// GET /api/blog/me acme.blog [throttle:60,1 acme.auth]
|
|
// POST /api/blog/posts/{id}/comments acme.blog [throttle:60,1 acme.auth throttle:blog.comments body.limit:65536]
|
|
}
|
|
|
|
func ExampleClientIP() {
|
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
|
|
trusted := surf.TrustedProxies(cfg)
|
|
|
|
// Through the load balancer at 10.0.0.5: the forwarded client is used.
|
|
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
|
viaProxy.RemoteAddr = "10.0.0.5:4711"
|
|
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
|
|
fmt.Println(surf.ClientIP(viaProxy, trusted))
|
|
|
|
// Straight from the internet: a forged header is ignored.
|
|
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
|
direct.RemoteAddr = "203.0.113.7:5000"
|
|
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
|
|
fmt.Println(surf.ClientIP(direct, trusted))
|
|
// Output:
|
|
// 198.51.100.23
|
|
// 203.0.113.7
|
|
}
|
|
|
|
// TestDocsDeclarations checks the plugin declarations the Routing and Rate
|
|
// limiting pages show.
|
|
func TestDocsDeclarations(t *testing.T) {
|
|
p := &BlogPlugin{}
|
|
if _, ok := p.Middlewares()["acme.auth"]; !ok {
|
|
t.Fatal("Middlewares() has no acme.auth")
|
|
}
|
|
if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute {
|
|
t.Fatalf("Buckets() = %+v", b)
|
|
}
|
|
}
|