A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.
GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"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"
|
|
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
|
|
)
|
|
|
|
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 != 14 {
|
|
t.Errorf("Models = %d, want 14", n)
|
|
}
|
|
if n := len(p.Migrations()); n != 19 {
|
|
t.Errorf("Migrations = %d, want 19", 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",
|
|
"PUT /_bm/api/v1/readings/{id}/owned",
|
|
"DELETE /_bm/api/v1/readings/{id}/owned",
|
|
"GET /_bm/api/v1/materials",
|
|
"GET /_bm/api/v1/materials/{id}",
|
|
"GET /_bm/api/v1/checklists",
|
|
"POST /_bm/api/v1/checklists",
|
|
"GET /_bm/api/v1/checklists/{id}",
|
|
"PATCH /_bm/api/v1/checklists/{id}",
|
|
"DELETE /_bm/api/v1/checklists/{id}",
|
|
"POST /_bm/api/v1/checklists/{id}/items",
|
|
"PATCH /_bm/api/v1/checklist-items/{id}",
|
|
"DELETE /_bm/api/v1/checklist-items/{id}",
|
|
"PUT /_bm/api/v1/checklist-items/{id}/done",
|
|
"DELETE /_bm/api/v1/checklist-items/{id}/done",
|
|
"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 /_bm/api/v1/materials/{id}/access",
|
|
"PUT /_bm/api/v1/materials/{id}/access",
|
|
"GET /_bm/api/v1/token/dashboard",
|
|
"GET /_bm/api/v1/token/courses",
|
|
"GET /_bm/api/v1/token/courses/{id}",
|
|
"GET /_bm/api/v1/token/schedule",
|
|
"GET /_bm/api/v1/token/meetings",
|
|
"GET /_bm/api/v1/token/deadlines",
|
|
"GET /_bm/api/v1/token/readings",
|
|
"PUT /_bm/api/v1/token/readings/{id}/progress",
|
|
"DELETE /_bm/api/v1/token/readings/{id}/progress",
|
|
"PUT /_bm/api/v1/token/readings/{id}/owned",
|
|
"DELETE /_bm/api/v1/token/readings/{id}/owned",
|
|
"GET /_bm/api/v1/token/materials",
|
|
"GET /_bm/api/v1/token/materials/{id}",
|
|
"GET /_bm/api/v1/token/checklists",
|
|
"POST /_bm/api/v1/token/checklists",
|
|
"GET /_bm/api/v1/token/checklists/{id}",
|
|
"PATCH /_bm/api/v1/token/checklists/{id}",
|
|
"DELETE /_bm/api/v1/token/checklists/{id}",
|
|
"POST /_bm/api/v1/token/checklists/{id}/items",
|
|
"PATCH /_bm/api/v1/token/checklist-items/{id}",
|
|
"DELETE /_bm/api/v1/token/checklist-items/{id}",
|
|
"PUT /_bm/api/v1/token/checklist-items/{id}/done",
|
|
"DELETE /_bm/api/v1/token/checklist-items/{id}/done",
|
|
"GET /api/v1/bm/me",
|
|
"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/materials/{id}/files",
|
|
"POST /api/v1/bm/materials/{id}/files/inline",
|
|
"POST /api/v1/bm/materials/{id}/files/upload-url",
|
|
"DELETE /api/v1/bm/materials/{id}/files/{file}",
|
|
"GET /api/v1/bm/courses",
|
|
"GET /api/v1/bm/courses/{id}",
|
|
"POST /api/v1/bm/courses",
|
|
"PATCH /api/v1/bm/courses/{id}",
|
|
"GET /api/v1/bm/meetings",
|
|
"GET /api/v1/bm/meetings/{id}",
|
|
"POST /api/v1/bm/meetings",
|
|
"PATCH /api/v1/bm/meetings/{id}",
|
|
"GET /api/v1/bm/class-sessions",
|
|
"GET /api/v1/bm/class-sessions/{id}",
|
|
"POST /api/v1/bm/class-sessions",
|
|
"PATCH /api/v1/bm/class-sessions/{id}",
|
|
"DELETE /api/v1/bm/class-sessions/{id}",
|
|
"GET /api/v1/bm/readings",
|
|
"GET /api/v1/bm/readings/{id}",
|
|
"POST /api/v1/bm/readings",
|
|
"PATCH /api/v1/bm/readings/{id}",
|
|
"DELETE /api/v1/bm/readings/{id}",
|
|
"GET /api/v1/bm/deadlines",
|
|
"GET /api/v1/bm/deadlines/{id}",
|
|
"POST /api/v1/bm/deadlines",
|
|
"PATCH /api/v1/bm/deadlines/{id}",
|
|
"DELETE /api/v1/bm/deadlines/{id}",
|
|
"POST /api/v1/bm/seed",
|
|
}
|
|
var got []string
|
|
sawFeed, sawUploads := false, 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
|
|
}
|
|
if rt.Method+" "+rt.Pattern == "POST /_bm/api/v1/uploads/{ticket}" {
|
|
// The signed material upload: no bearer (the ticket is the
|
|
// credential), throttled per IP and per ticket, body capped.
|
|
want := []string{"throttle:jz.bm.upload-ip", "throttle:jz.bm.upload-ticket", "body.limit:" + strconv.Itoa(api.MaterialFilesBodyLimit)}
|
|
if !slices.Equal(rt.Middleware, want) {
|
|
t.Errorf("the signed upload has middleware %v, want %v", rt.Middleware, want)
|
|
}
|
|
sawUploads = true
|
|
continue
|
|
}
|
|
got = append(got, rt.Method+" "+rt.Pattern)
|
|
if strings.HasPrefix(rt.Pattern, bm.TokenPrefix+"/") {
|
|
// The token twins of the student API: the user API token guard
|
|
// and a scope instead of jwt.auth.
|
|
if !slices.Contains(rt.Middleware, "user.api_token") || !slices.ContainsFunc(rt.Middleware, func(name string) bool { return strings.HasPrefix(name, "user.scope:") }) || slices.Contains(rt.Middleware, "jwt.auth") {
|
|
t.Errorf("%s %s is not behind the user API token and a scope: %v", rt.Method, rt.Pattern, rt.Middleware)
|
|
}
|
|
} else 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 !sawUploads {
|
|
t.Error("POST /_bm/api/v1/uploads/{ticket} is not registered")
|
|
}
|
|
if api.UploadPath != bm.APIPrefix+"/uploads/" {
|
|
t.Errorf("api.UploadPath = %q, want %q", api.UploadPath, bm.APIPrefix+"/uploads/")
|
|
}
|
|
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)
|
|
}
|
|
}
|