The management API could only upsert a material on its title and course,
so a renamed note became a second material. PUT /materials/{id} applies the
same body to one material (422 when the new title and course belong to
another one), GET /materials/{id} shows a draft or published material with
its public files, and the upsert, update and validation share one path.
The README documents the management API.
221 lines
7.0 KiB
Go
221 lines
7.0 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
_ "git.golem15.com/golem15/sm-user-plugin"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"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/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
bm "git.golem15.com/jakub/sm-bm-plugin"
|
|
)
|
|
|
|
const (
|
|
testUserSecret = "jz-bm-test-only-user-jwt-secret-32bytes"
|
|
testAdminSecret = "jz-bm-test-only-admin-jwt-secret-32bytes"
|
|
)
|
|
|
|
// testConfig is the application config the tests boot with: the HTTP
|
|
// limits, the golem15.user JWT secret and the admin secret.
|
|
func testConfig(t *testing.T) *compass.Config {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 67108864\n"
|
|
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{
|
|
Dir: dir,
|
|
Environ: []string{
|
|
"SUMMER_ENV=testing",
|
|
"SUMMER_GOLEM15__USER__JWT__SECRET=" + testUserSecret,
|
|
"SUMMER_ADMIN__JWT__SECRET=" + testAdminSecret,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// activate boots golem15.user and jz.bm the way the generated main does.
|
|
func activate(t *testing.T, app *backpack.App) []party.Plugin {
|
|
t.Helper()
|
|
plugins, err := party.Activate(app, []string{"golem15.user", "jz.bm"})
|
|
if err != nil {
|
|
t.Fatalf("Activate: %v", err)
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
func bmPlugin(t *testing.T, plugins []party.Plugin) *bm.Plugin {
|
|
t.Helper()
|
|
for _, p := range plugins {
|
|
if b, ok := p.(*bm.Plugin); ok {
|
|
return b
|
|
}
|
|
}
|
|
t.Fatal("jz.bm was not activated")
|
|
return nil
|
|
}
|
|
|
|
func TestPluginActivates(t *testing.T) {
|
|
app := backpack.New(testConfig(t))
|
|
p := bmPlugin(t, activate(t, app))
|
|
if p.ID() != "jz.bm" || !slices.Equal(p.Requires(), []string{"golem15.user"}) {
|
|
t.Errorf("ID %q Requires %v", p.ID(), p.Requires())
|
|
}
|
|
if n := len(p.Models()); n != 10 {
|
|
t.Errorf("Models = %d, want 10", n)
|
|
}
|
|
if n := len(p.Migrations()); n != 12 {
|
|
t.Errorf("Migrations = %d, want 12", n)
|
|
}
|
|
if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) {
|
|
t.Error("bm:seed is not registered")
|
|
}
|
|
if got := app.Config.Int("jz.bm.materials_per_page"); got != 20 {
|
|
t.Errorf("jz.bm.materials_per_page = %d, want the default 20", got)
|
|
}
|
|
if got := app.Config.Int("jz.bm.dashboard_limit"); got != 5 {
|
|
t.Errorf("jz.bm.dashboard_limit = %d, want the default 5", got)
|
|
}
|
|
if got := app.Config.String("jz.bm.public_url"); got != "" {
|
|
t.Errorf("jz.bm.public_url = %q, want the default empty", got)
|
|
}
|
|
}
|
|
|
|
func TestRoutes(t *testing.T) {
|
|
app := backpack.New(testConfig(t))
|
|
plugins := activate(t, app)
|
|
router, err := surf.BuildRouter(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("BuildRouter: %v", err)
|
|
}
|
|
want := []string{
|
|
"GET /_bm/api/v1/dashboard",
|
|
"GET /_bm/api/v1/courses",
|
|
"GET /_bm/api/v1/courses/{id}",
|
|
"GET /_bm/api/v1/schedule",
|
|
"GET /_bm/api/v1/meetings",
|
|
"GET /_bm/api/v1/deadlines",
|
|
"GET /_bm/api/v1/readings",
|
|
"PUT /_bm/api/v1/readings/{id}/progress",
|
|
"DELETE /_bm/api/v1/readings/{id}/progress",
|
|
"GET /_bm/api/v1/materials",
|
|
"GET /_bm/api/v1/materials/{id}",
|
|
"GET /_bm/api/v1/calendar-feed",
|
|
"PUT /_bm/api/v1/calendar-feed",
|
|
"POST /_bm/api/v1/calendar-feed/rotate",
|
|
"DELETE /_bm/api/v1/calendar-feed",
|
|
"GET /api/v1/bm/me",
|
|
"GET /api/v1/bm/courses",
|
|
"GET /api/v1/bm/materials",
|
|
"GET /api/v1/bm/materials/{id}",
|
|
"POST /api/v1/bm/materials",
|
|
"PUT /api/v1/bm/materials/{id}",
|
|
"DELETE /api/v1/bm/materials/{id}",
|
|
"POST /api/v1/bm/seed",
|
|
}
|
|
var got []string
|
|
sawFeed := false
|
|
for _, rt := range router.Routes() {
|
|
if rt.PluginID != "jz.bm" {
|
|
continue
|
|
}
|
|
if rt.Method+" "+rt.Pattern == "GET /{path...}" {
|
|
// The student app, public by design; see static_test.go.
|
|
if len(rt.Middleware) != 0 {
|
|
t.Errorf("the student app has middleware %v", rt.Middleware)
|
|
}
|
|
continue
|
|
}
|
|
if rt.Method+" "+rt.Pattern == "GET /_bm/calendar/{token}" {
|
|
// The calendar feed: public (the token is the credential) but
|
|
// throttled per IP and per token.
|
|
want := []string{"throttle:jz.bm.calendar-ip", "throttle:jz.bm.calendar-token"}
|
|
if !slices.Equal(rt.Middleware, want) {
|
|
t.Errorf("the calendar feed has middleware %v, want %v", rt.Middleware, want)
|
|
}
|
|
sawFeed = true
|
|
continue
|
|
}
|
|
got = append(got, rt.Method+" "+rt.Pattern)
|
|
if strings.HasPrefix(rt.Pattern, "/api/v1/bm/") {
|
|
if !slices.Contains(rt.Middleware, "user.api_token") || !slices.ContainsFunc(rt.Middleware, func(name string) bool { return strings.HasPrefix(name, "user.scope:") }) {
|
|
t.Errorf("%s %s is not behind the shared token and a scope: %v", rt.Method, rt.Pattern, rt.Middleware)
|
|
}
|
|
if rt.Pattern != "/api/v1/bm/me" && !slices.ContainsFunc(rt.Middleware, func(name string) bool { return strings.HasPrefix(name, "user.permission:") }) {
|
|
t.Errorf("%s %s has no user permission check: %v", rt.Method, rt.Pattern, rt.Middleware)
|
|
}
|
|
} else if !slices.Contains(rt.Middleware, "jwt.auth") {
|
|
t.Errorf("%s %s is not behind jwt.auth: %v", rt.Method, rt.Pattern, rt.Middleware)
|
|
}
|
|
}
|
|
if !sawFeed {
|
|
t.Error("GET /_bm/calendar/{token} is not registered")
|
|
}
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("routes =\n%v\nwant\n%v", got, want)
|
|
}
|
|
for _, rt := range router.Routes() {
|
|
if rt.Pattern == "/prezentacje/{slugHash}" {
|
|
t.Errorf("GET /prezentacje/{slugHash} is registered without golem15.presentations")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdminCompiles(t *testing.T) {
|
|
app := backpack.New(testConfig(t))
|
|
plugins := activate(t, app)
|
|
p := bmPlugin(t, plugins)
|
|
routes, err := cabana.Activate(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("cabana.Activate: %v", err)
|
|
}
|
|
if routes == nil {
|
|
t.Fatal("no admin routes")
|
|
}
|
|
declared := map[string]bool{}
|
|
for _, perm := range p.Permissions() {
|
|
declared[perm.Code] = true
|
|
}
|
|
for _, ctl := range p.AdminControllers() {
|
|
perms := ctl.(pact.AdminPermissioned).RequiredPermissions()
|
|
for _, code := range perms {
|
|
if !declared[code] {
|
|
t.Errorf("%s requires undeclared %s", ctl.ID(), code)
|
|
}
|
|
}
|
|
if rec := ctl.(pact.AdminRecordSource).NewRecord(); rec == nil {
|
|
t.Errorf("%s has no record model", ctl.ID())
|
|
}
|
|
form, err := cabana.CompileForm("jz.bm", ctl, p.AdminFS())
|
|
if err != nil {
|
|
t.Errorf("%s form: %v", ctl.ID(), err)
|
|
} else if len(form.Fields) == 0 {
|
|
t.Errorf("%s form has no fields", ctl.ID())
|
|
}
|
|
if _, err := cabana.CompileList("jz.bm", ctl, p.AdminFS()); err != nil {
|
|
t.Errorf("%s list: %v", ctl.ID(), err)
|
|
}
|
|
editor := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"jz.bm.*": true}}
|
|
if !cabana.Allows(editor, perms) {
|
|
t.Errorf("jz.bm.* does not open %s", ctl.ID())
|
|
}
|
|
}
|
|
nav := p.Navigation()
|
|
if len(nav) != 1 || len(nav[0].SideMenu) != 7 {
|
|
t.Errorf("navigation = %+v, want one group with 7 side items", nav)
|
|
}
|
|
}
|