package cabana import ( "crypto/sha256" "encoding/hex" "io/fs" "net/http" "net/http/httptest" "os" "regexp" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/boardwalk" "git.golem15.com/golem15/summercms/modules/pact" ) // extAssetRouter mounts the plugin asset route and the SPA shell the way // service.mount does under the default prefix, with the real embedded SPA // handler behind the fall-through. func extAssetRouter(t *testing.T, reg *Registry) http.Handler { t.Helper() spa, err := boardwalk.Handler(DefaultAdminPrefix, http.HandlerFunc(writeNotFound)) if err != nil { t.Fatal(err) } svc := &service{reg: reg, spa: spa} mux := http.NewServeMux() mux.HandleFunc("GET "+DefaultAdminPrefix+"/assets/{vendor}/{plugin}/{file...}", svc.pluginAsset) mux.HandleFunc("GET "+DefaultAdminPrefix+"/{path...}", svc.serveSPA) return mux } func serve(h http.Handler, method, target string, header map[string]string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, target, nil) for key, value := range header { req.Header.Set(key, value) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // TestPhase101Assets covers the plugin asset route (D-13, D-15, D-16; // T-10.1-01, T-10.1-02, T-10.1-03): exact-key hits with explicit types and // the admin security headers, revalidation, fall-through for everything else, // the boot checks on declared paths, and the ?v= schema URLs. func TestPhase101Assets(t *testing.T) { reg, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir)) h := extAssetRouter(t, reg) base := DefaultAdminPrefix + "/assets/acme/demo/" for _, tc := range []struct{ file, url, contentType string }{ {extJS, base + "js/lookup.js", "text/javascript; charset=utf-8"}, {extCSS, base + "css/gadgets.css", "text/css; charset=utf-8"}, } { t.Run("hit "+tc.file, func(t *testing.T) { body, err := os.ReadFile(extDir + "/" + tc.file) if err != nil { t.Fatal(err) } sum := sha256.Sum256(body) etag := `"` + hex.EncodeToString(sum[:]) + `"` rec := serve(h, http.MethodGet, tc.url+"?v=ignored", nil) hdr := rec.Header() if rec.Code != http.StatusOK || rec.Body.String() != string(body) { t.Fatalf("status=%d body=%.80q", rec.Code, rec.Body.String()) } for key, want := range map[string]string{ "Content-Type": tc.contentType, "X-Content-Type-Options": "nosniff", "Cross-Origin-Resource-Policy": "same-origin", "Cache-Control": "no-cache", "ETag": etag, "X-Frame-Options": "DENY", "Referrer-Policy": "same-origin", } { if got := hdr.Get(key); got != want { t.Fatalf("%s=%q want %q", key, got, want) } } if !strings.Contains(hdr.Get("Content-Security-Policy"), "script-src 'self'") { t.Fatalf("CSP=%q", hdr.Get("Content-Security-Policy")) } if strings.Contains(hdr.Get("Cache-Control"), "immutable") { t.Fatal("plugin files must be revalidated, never immutable") } notModified := serve(h, http.MethodGet, tc.url, map[string]string{"If-None-Match": etag}) if notModified.Code != http.StatusNotModified || notModified.Body.Len() != 0 { t.Fatalf("If-None-Match status=%d body=%d", notModified.Code, notModified.Body.Len()) } stale := serve(h, http.MethodGet, tc.url, map[string]string{"If-None-Match": `"stale"`}) if stale.Code != http.StatusOK { t.Fatalf("stale ETag status=%d", stale.Code) } head := serve(h, http.MethodHead, tc.url, nil) if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Type") != tc.contentType { t.Fatalf("HEAD status=%d body=%d type=%q", head.Code, head.Body.Len(), head.Header().Get("Content-Type")) } }) } t.Run("everything else falls through to the SPA", func(t *testing.T) { for _, target := range []string{ base + "controllers/gadgets/config_list.yaml", base + "controllers/gadgets/_stats.htm", base + "models/gadget/fields.yaml", base + "js/other.js", base + "assets/js/lookup.js", base + "js/lookup.js/", base + "JS/lookup.js", DefaultAdminPrefix + "/assets/acme/other/js/lookup.js", base + "..%2Fcontrollers%2Fgadgets%2Fconfig_list.yaml", base + "js/..%2F..%2Fcontrollers/gadgets/_stats.htm", base + "%2e%2e/controllers/gadgets/_stats.htm", base + "js%2Flookup.js%00.yaml", } { rec := serve(h, http.MethodGet, target, nil) body := rec.Body.String() if rec.Code == http.StatusOK || strings.Contains(body, "modelClass") || strings.Contains(body, "summer-stat") || strings.Contains(body, "customElements") || strings.Contains(body, "fields:") { t.Fatalf("%s status=%d body=%.120q", target, rec.Code, body) } } }) t.Run("dist assets still come from the SPA handler", func(t *testing.T) { dist, err := boardwalk.Dist() if err != nil { t.Fatal(err) } matches, err := fs.Glob(dist, "assets/index-*.js") if err != nil || len(matches) == 0 { t.Fatalf("no dist entry script: %v", err) } rec := serve(h, http.MethodGet, DefaultAdminPrefix+"/"+matches[0], nil) if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" || rec.Header().Get("Content-Type") != "text/javascript; charset=utf-8" { t.Fatalf("dist %s status=%d headers=%v", matches[0], rec.Code, rec.Header()) } }) t.Run("schema URLs carry a content version", func(t *testing.T) { svc := &service{reg: reg} assets := svc.controllerAssets(cc) version := regexp.MustCompile(`\?v=([0-9a-f]{12})$`) for _, tc := range []struct{ url, file string }{{assets.Scripts[0], extJS}, {assets.Styles[0], extCSS}} { match := version.FindStringSubmatch(tc.url) if match == nil || !strings.HasPrefix(tc.url, base+strings.TrimPrefix(tc.file, "assets/")+"?v=") { t.Fatalf("asset url %q", tc.url) } body, _ := os.ReadFile(extDir + "/" + tc.file) sum := sha256.Sum256(body) if match[1] != hex.EncodeToString(sum[:])[:12] { t.Fatalf("version %s does not hash %s", match[1], tc.file) } } if len(assets.Scripts) != 1 || len(assets.Styles) != 1 { t.Fatalf("assets = %+v", assets) } if empty := svc.controllerAssets(nil); empty.Scripts == nil || empty.Styles == nil || len(empty.Scripts)+len(empty.Styles) != 0 { t.Fatalf("nil controller assets = %#v", empty) } prefixed := (&service{reg: reg, prefix: "/acme-admin"}).controllerAssets(cc) if !strings.HasPrefix(prefixed.Scripts[0], "/acme-admin/assets/acme/demo/js/lookup.js?v=") { t.Fatalf("prefixed url %q", prefixed.Scripts[0]) } }) t.Run("two controllers of one plugin share one entry", func(t *testing.T) { spares := newExtController() spares.id = "acme.demo.spares" fsys := os.DirFS(extDir) shared, err := compileRegistry([]controllerRef{ {plugin: extPlugin{fsys: fsys}, ctl: newExtController()}, {plugin: extPlugin{fsys: fsys}, ctl: spares}, }) if err != nil { t.Fatal(err) } first, _ := shared.Get(extID) second, _ := shared.Get("acme.demo.spares") if len(shared.assets) != 2 || shared.assets["acme/demo/js/lookup.js"] != first.scripts[0] || first.scripts[0].key != second.scripts[0].key { t.Fatalf("assets=%v first=%v second=%v", shared.assets, first.scripts, second.scripts) } }) assetCase := func(name string, js, css []string, want string) bootCase { return bootCase{name: name, ctl: extController{extAssets{extBase: extBase{actions: extActions()}, js: js, css: css}}, want: []string{extPluginID, extID, want}} } runBootCases(t, []bootCase{ assetCase("path outside assets/", []string{"js/lookup.js"}, nil, "asset path must be a clean path under assets/"), assetCase("leading slash", []string{"/assets/js/lookup.js"}, nil, "asset path must be a clean path under assets/"), assetCase("dot-dot segment", []string{"assets/../assets/js/lookup.js"}, nil, "asset path must be a clean path under assets/"), assetCase("escaping dot-dot", []string{"assets/../controllers/gadgets/_stats.js"}, nil, "asset path must be a clean path under assets/"), assetCase("bare assets directory", []string{"assets/"}, nil, "asset path must be a clean path under assets/"), assetCase("text file", []string{"assets/js/notes.txt"}, nil, "asset must end in .js or .mjs"), assetCase("stylesheet declared as JS", []string{extCSS}, nil, "asset must end in .js or .mjs"), assetCase("script declared as CSS", []string{extJS}, []string{extJS}, "asset must end in .css"), assetCase("missing file", []string{"assets/js/missing.js"}, nil, "asset is not in the plugin's embedded files"), assetCase("duplicate path", []string{extJS, extJS}, nil, "asset declared twice"), }) t.Run("three-segment plugin ID", func(t *testing.T) { ctl := newExtController() ctl.id = "acme.demo.extra.gadgets" err := compileClientAssets("acme.demo.extra", &CompiledController{Controller: ctl}, os.DirFS(extDir)) if err == nil || !strings.Contains(err.Error(), "plugin ID must be vendor.plugin") { t.Fatalf("err = %v", err) } for _, id := range []string{"acme", "acme.de mo", "ac/me.demo"} { if err := compileClientAssets(id, &CompiledController{Controller: ctl}, fstest.MapFS{}); err == nil { t.Fatalf("plugin ID %q served assets", id) } } if err := compileClientAssets(extPluginID, &CompiledController{Controller: extBase{}}, fstest.MapFS{}); err != nil { t.Fatalf("controller without assets: %v", err) } }) } var _ pact.AdminClientAssets = extAssets{}