Files
sm-bm-plugin/realtime_access_test.go
Jakub Zych cb7ba7ec8a test: cover the realtime access guard and chat prefixes
jz.bm.realtime-access admits chat-group members and, through
golem15.presentations' owner guard, presentation owners; everyone else
gets 401, 403 or 503 with Cache-Control no-cache, private. /_chat,
/_chatchannels and /_chatbots are reserved for the APIs.
2026-10-26 15:48:07 +01:00

126 lines
4.6 KiB
Go

package bm
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"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/wire"
"gorm.io/gorm"
)
// fakePresentations stands in for golem15.presentations: its owner guard
// lets user 5 through and refuses everyone else.
type fakePresentations struct{}
func (fakePresentations) ID() string { return "golem15.presentations" }
func (fakePresentations) Requires() []string { return nil }
func (fakePresentations) Register(*backpack.App) error { return nil }
func (fakePresentations) Boot(*backpack.App) error { return nil }
func (fakePresentations) Middlewares() map[string]pact.Middleware {
return map[string]pact.Middleware{
"presentations.realtime-owner": func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, ok := bouncer.User(r.Context()); !ok || u.ID != 5 {
w.Header().Set("Cache-Control", "no-cache, private")
wire.WriteJSON(w, http.StatusForbidden, map[string]string{"error": "Forbidden"})
return
}
next.ServeHTTP(w, r)
})
},
}
}
// chatGroupMember is the injected group check: user 7 is in the chat
// group, user 13 makes the check fail.
func chatGroupMember(_ context.Context, _ *gorm.DB, userID uint, group string) (bool, error) {
if userID == 13 {
return false, errors.New("database down")
}
return group == "studenci" && userID == 7, nil
}
// accessPlugin builds jz.bm on an app with the given active plugin ids, a
// published (fake) database unless noDB, and golem15.presentations in the
// activated list when withOwnerGuard.
func accessPlugin(t *testing.T, ids []string, noDB, withOwnerGuard bool) *Plugin {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=testing"}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
app.SetPlugins(ids)
if !noDB {
if err := app.Publish[*gorm.DB](&gorm.DB{}); err != nil {
t.Fatal(err)
}
}
activated := party.Activated{}
if withOwnerGuard {
activated = append(activated, fakePresentations{})
}
if err := app.Publish(activated); err != nil {
t.Fatal(err)
}
return &Plugin{app: app, chatMember: chatGroupMember}
}
func TestRealtimeAccess(t *testing.T) {
chatOnly := []string{"golem15.user", "golem15.chat", "jz.bm"}
both := []string{"golem15.user", "golem15.chat", "jz.bm", "golem15.presentations"}
presOnly := []string{"golem15.user", "jz.bm", "golem15.presentations"}
for _, c := range []struct {
name string
ids []string
noDB, guard bool
userID uint
want int
}{
{"no user", both, false, true, 0, http.StatusUnauthorized},
{"chat member", chatOnly, false, false, 7, http.StatusOK},
{"chat member with presentations", both, false, true, 7, http.StatusOK},
{"non-member without presentations", chatOnly, false, false, 8, http.StatusForbidden},
{"presentation owner", both, false, true, 5, http.StatusOK},
{"presentation owner without chat", presOnly, false, true, 5, http.StatusOK},
{"neither member nor owner", both, false, true, 8, http.StatusForbidden},
{"presentations guard not found", both, false, false, 5, http.StatusForbidden},
{"no database", both, true, true, 7, http.StatusServiceUnavailable},
{"membership check fails", both, false, true, 13, http.StatusServiceUnavailable},
{"no consumer", []string{"golem15.user", "jz.bm"}, false, false, 7, http.StatusForbidden},
} {
p := accessPlugin(t, c.ids, c.noDB, c.guard)
mw, ok := p.Middlewares()[MiddlewareRealtimeAccess]
if !ok {
t.Fatalf("%s is not exported", MiddlewareRealtimeAccess)
}
h := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
req := httptest.NewRequest(http.MethodGet, "/api/realtime/token", nil)
if c.userID != 0 {
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: c.userID}))
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s: %d %s, want %d", c.name, rec.Code, rec.Body.String(), c.want)
continue
}
if c.want != http.StatusOK {
if got := rec.Header().Get("Cache-Control"); got != "no-cache, private" {
t.Errorf("%s: Cache-Control %q, want no-cache, private", c.name, got)
}
if body := rec.Body.String(); body == "" || body[0] != '{' {
t.Errorf("%s: body %q is not an error object", c.name, body)
}
}
}
}