diff --git a/realtime_access_test.go b/realtime_access_test.go new file mode 100644 index 0000000..ad3bf2b --- /dev/null +++ b/realtime_access_test.go @@ -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) + } + } + } +} diff --git a/static_test.go b/static_test.go index 6c3fffc..4ee15e3 100644 --- a/static_test.go +++ b/static_test.go @@ -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) }