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.
This commit is contained in:
125
realtime_access_test.go
Normal file
125
realtime_access_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,8 @@ func TestStaticReservedPaths(t *testing.T) {
|
||||
"/bmadmin", "/bmadmin/", "/bmadmin/login", "/storage/app/uploads/public/x.pdf",
|
||||
"/storage", "/_user/../_user/x", "//_bm/api",
|
||||
"/oauth", "/oauth/microsoft", "/oauth/microsoft/callback",
|
||||
"/_chat", "/_chat/x", "/_chat/api/v1/conversations/1/messages",
|
||||
"/_chatchannels", "/_chatchannels/x", "/_chatbots", "/_chatbots/x",
|
||||
} {
|
||||
rec := get(h, http.MethodGet, p, nil)
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "app") {
|
||||
@@ -124,7 +126,7 @@ func TestStaticReservedPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Names that merely start like a reserved prefix are client routes.
|
||||
for _, p := range []string{"/bmadmins", "/storage-info", "/_users", "/oauthy"} {
|
||||
for _, p := range []string{"/bmadmins", "/storage-info", "/_users", "/oauthy", "/_chatty"} {
|
||||
if rec := get(h, http.MethodGet, p, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want the fallback", p, rec.Code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user