From f5f9387ac9444aed8da5f9b9864e10cb3a3bf125 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 1 Oct 2026 16:35:09 +0200 Subject: [PATCH] test(11.2): cover site_url and site_label and gate the framework, app and site stages - TestCheckSiteURL (21 accepted and rejected values), TestSiteLabel and TestSiteURLPrecedence (option over site.yaml, label-without-URL and invalid option errors); new TestParseSite rows for blank and two-line labels - TestSiteLink: escaped label, section page and 404 page, exact unset header bytes - TestDocsSiteFlagsInHelp and the --site-label-without-URL error - check-phase11.2.sh --framework, --app and --site --- cmd/summer/docs_test.go | 32 ++++++++++ internal/docsite/load_test.go | 112 +++++++++++++++++++++++++++++++++ internal/docsite/theme_test.go | 31 +++++---- scripts/check-phase11.2.sh | 58 +++++++++++++++++ 4 files changed, 222 insertions(+), 11 deletions(-) diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index ce30976..5225c53 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -104,6 +104,38 @@ func TestDocsBuildSiteFlags(t *testing.T) { if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--site-url") { t.Fatalf("docs:build --site-url javascript:alert(1): err = %v, want a --site-url error", err) } + + // The framework's own site.yaml sets no site_url, so a label alone fails. + buf.Reset() + root, err = bonfire.NewRoot("summer", toolCommands(), &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"docs:build", "--root", repoRoot, "--out", filepath.Join(t.TempDir(), "nolabel"), "--site-label", "x"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--site-label needs --site-url") { + t.Fatalf("docs:build --site-label x: err = %v, want a --site-label needs --site-url error", err) + } +} + +// TestDocsSiteFlagsInHelp checks that docs:build and docs:serve list the +// --site-url and --site-label flags in their help. +func TestDocsSiteFlagsInHelp(t *testing.T) { + for _, cmd := range []string{"docs:build", "docs:serve"} { + var buf bytes.Buffer + root, err := bonfire.NewRoot("summer", toolCommands(), &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{cmd, "--help"}) + if err := root.Execute(); err != nil { + t.Fatalf("%s --help: %v", cmd, err) + } + for _, want := range []string{"--site-url", "--site-label", "overrides site.yaml site_url", "overrides site.yaml site_label"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("%s help lacks %q:\n%s", cmd, want, buf.String()) + } + } + } } // requiredPages lists the guide pages the docs must keep. Each content plan diff --git a/internal/docsite/load_test.go b/internal/docsite/load_test.go index 062cd5e..a58e62e 100644 --- a/internal/docsite/load_test.go +++ b/internal/docsite/load_test.go @@ -1,6 +1,7 @@ package docsite import ( + "errors" "os" "path/filepath" "slices" @@ -30,6 +31,9 @@ func TestParseSite(t *testing.T) { {"site_url javascript", valid + "site_url: javascript:alert(1)\n", "site_url must be an http(s) URL with a host or a path starting with a single /"}, {"site_url protocol-relative", valid + "site_url: //acme.example\n", "site_url must be an http(s) URL with a host or a path starting with a single /"}, {"site_label without site_url", valid + "site_label: Acme\n", "site_label needs site_url"}, + {"blank site_label", valid + "site_url: /\nsite_label: \" \"\n", "site_label must be one non-empty line"}, + {"two-line site_label", valid + "site_url: /\nsite_label: \"a\\nb\"\n", "site_label must be one non-empty line"}, + {"site_url with a newline", valid + "site_url: \"/a\\nb\"\n", "site_url must be an http(s) URL"}, } { if _, err := ParseSite([]byte(tc.raw)); err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: err = %v, want %q", tc.name, err, tc.want) @@ -41,6 +45,9 @@ func TestParseSite(t *testing.T) { t.Errorf("site_url %q: ParseSite = %+v, %v", u, got, err) } } + if got, err := ParseSite([]byte(valid + "site_url: /\nsite_label: Acme\n")); err != nil || got.SiteURL != "/" || got.SiteLabel != "Acme" { + t.Errorf("site_url with site_label: ParseSite = %+v, %v", got, err) + } if strings.Contains(func() string { _, err := ParseSite([]byte(valid + "colour: red\n")); return err.Error() }(), "\n") { t.Error("a decode error must be reported on one line") } @@ -319,3 +326,108 @@ func TestNormalizeDefaults(t *testing.T) { t.Fatalf("normalize(relative) = %+v", o) } } + +// TestCheckSiteURL pins the site_url policy (T-11.2-09): an http(s) URL +// with a host and no user info, or a path starting with exactly one /. +func TestCheckSiteURL(t *testing.T) { + for _, tc := range []struct { + name, raw string + ok bool + }{ + {"https host", "https://acme.example", true}, + {"https host slash", "https://acme.example/", true}, + {"http port path", "http://acme.example:8080/x", true}, + {"root path", "/", true}, + {"path", "/home", true}, + {"empty", "", false}, + {"javascript", "javascript:alert(1)", false}, + {"javascript mixed case", "JavaScript:alert(1)", false}, + {"data", "data:text/html,x", false}, + {"protocol-relative", "//acme.example", false}, + {"bare host", "acme.example", false}, + {"relative path", "docs/x", false}, + {"scheme without host", "https://", false}, + {"user info", "https://user:pw@acme.example", false}, + {"ftp", "ftp://acme.example", false}, + {"space in path", "/ x", false}, + {"newline", "https://acme.example/\nx", false}, + {"leading tab", "\t/home", false}, + {"backslash host", "/\\acme.example", false}, + {"mailto opaque", "mailto:docs@acme.example", false}, + {"control character", "/home\x00", false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := checkSiteURL(tc.raw) + if tc.ok && err != nil { + t.Fatalf("checkSiteURL(%q) = %v, want accepted", tc.raw, err) + } + if !tc.ok && !errors.Is(err, errSiteURL) { + t.Fatalf("checkSiteURL(%q) = %v, want errSiteURL", tc.raw, err) + } + }) + } +} + +func TestSiteLabel(t *testing.T) { + for _, tc := range []struct{ url, label, want string }{ + {"/", " acme.example ", "acme.example"}, + {"https://acme.example/docs", "Acme", "Acme"}, + {"https://acme.example/docs", "", "acme.example"}, + {"https://acme.example/docs", " ", "acme.example"}, + {"http://acme.example:8080/", "", "acme.example:8080"}, + {"/", "", "Home"}, + {"/home", "", "Home"}, + } { + if got := siteLabel(tc.url, tc.label); got != tc.want { + t.Errorf("siteLabel(%q, %q) = %q, want %q", tc.url, tc.label, got, tc.want) + } + } +} + +// TestSiteURLPrecedence checks that the --site-url and --site-label options +// override site.yaml the way --base-url overrides base_url. +func TestSiteURLPrecedence(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + opts Options + wantURL string + wantLabel string + wantErr string + }{ + {name: "nothing set"}, + {name: "yaml url, derived label", yaml: "site_url: https://acme.example:8443/x\n", wantURL: "https://acme.example:8443/x", wantLabel: "acme.example:8443"}, + {name: "yaml url and label", yaml: "site_url: /\nsite_label: Yaml\n", wantURL: "/", wantLabel: "Yaml"}, + {name: "option url wins", yaml: "site_url: https://yaml.example\n", opts: Options{SiteURL: "/"}, wantURL: "/", wantLabel: "Home"}, + {name: "option label wins", yaml: "site_url: /\nsite_label: Yaml\n", opts: Options{SiteLabel: "Opt"}, wantURL: "/", wantLabel: "Opt"}, + {name: "option url keeps yaml label", yaml: "site_url: /\nsite_label: Yaml\n", opts: Options{SiteURL: "https://opt.example"}, wantURL: "https://opt.example", wantLabel: "Yaml"}, + {name: "options only", opts: Options{SiteURL: "https://opt.example/", SiteLabel: "Opt"}, wantURL: "https://opt.example/", wantLabel: "Opt"}, + {name: "option label without url", opts: Options{SiteLabel: "x"}, wantErr: "docsite: --site-label needs --site-url or site.yaml site_url"}, + {name: "invalid option url", yaml: "site_url: /\n", opts: Options{SiteURL: "javascript:alert(1)"}, wantErr: "docsite: --site-url: must be an http(s) URL"}, + {name: "protocol-relative option url", opts: Options{SiteURL: "//acme.example"}, wantErr: "docsite: --site-url: "}, + {name: "two-line option label", opts: Options{SiteURL: "/", SiteLabel: "a\nb"}, wantErr: "docsite: --site-label: must be one non-empty line"}, + {name: "blank option label", opts: Options{SiteURL: "/", SiteLabel: " "}, wantErr: "docsite: --site-label: must be one non-empty line"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := themeTree(t) + if err := os.WriteFile(filepath.Join(root, "docs", "site.yaml"), []byte(fixtureSite+tc.yaml), 0o644); err != nil { + t.Fatal(err) + } + opts := tc.opts + opts.Root, opts.Commands, opts.Out = root, fixtureCommands, filepath.Join(t.TempDir(), "site") + s, problems, err := load(opts) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("load error = %v, want %q", err, tc.wantErr) + } + return + } + if err != nil || len(problems) > 0 { + t.Fatalf("load: %v %q", err, problemLines(problems)) + } + if s.siteURL != tc.wantURL || s.siteLabel != tc.wantLabel { + t.Fatalf("site link = %q %q, want %q %q", s.siteURL, s.siteLabel, tc.wantURL, tc.wantLabel) + } + }) + } +} diff --git a/internal/docsite/theme_test.go b/internal/docsite/theme_test.go index ea6ef0c..daded7a 100644 --- a/internal/docsite/theme_test.go +++ b/internal/docsite/theme_test.go @@ -77,35 +77,44 @@ func assertCSPSafe(t *testing.T, name, html string) { } } -// TestSiteLink checks the optional link back to the main site on a page -// and on the 404 page: absent without a site URL, labelled explicitly, by -// the URL host, or Home. +// TestSiteLink checks the optional link back to the main site on the home +// page, a section page and the 404 page: absent without a site URL (the +// header bytes unchanged), labelled explicitly, by the URL host, or Home, +// and always HTML-escaped. func TestSiteLink(t *testing.T) { + // unsetHeader is the wordmark's closing tag followed directly by the + // spacer: the header bytes from before site_url existed. + const unsetHeader = `CMS` + "\n" + `
` for _, tc := range []struct { name string opts Options want []string + deny []string }{ - {"unset", Options{}, nil}, - {"explicit label", Options{SiteURL: "/", SiteLabel: "acme.example"}, []string{`acme.example`}}, - {"host label", Options{SiteURL: "https://acme.example/docs"}, []string{`acme.example`}}, - {"home label", Options{SiteURL: "/"}, []string{`Home`}}, + {"unset", Options{}, []string{unsetHeader}, []string{"site-link"}}, + {"explicit label", Options{SiteURL: "/", SiteLabel: "acme.example"}, []string{``, `acme.example`}, []string{unsetHeader}}, + {"host label", Options{SiteURL: "https://acme.example/docs"}, []string{`acme.example`}, nil}, + {"host and port label", Options{SiteURL: "http://acme.example:8080/"}, []string{`acme.example:8080`}, nil}, + {"home label", Options{SiteURL: "/"}, []string{`Home`}, nil}, + {"escaped label", Options{SiteURL: "/", SiteLabel: "&"}, []string{`aria-label="<b>&"`, `<b>&`}, []string{"&"}}, } { opts := tc.opts opts.Root, opts.Commands, opts.Out = themeTree(t), fixtureCommands, filepath.Join(t.TempDir(), "site") if _, problems, err := Build(opts); err != nil || len(problems) > 0 { t.Fatalf("%s: Build: %v %q", tc.name, err, problemLines(problems)) } - for _, name := range []string{"index.html", "404.html"} { + for _, name := range []string{"index.html", "setup/start.html", "404.html"} { html := readOut(t, opts.Out, name) - if tc.want == nil && strings.Contains(html, "site-link") { - t.Errorf("%s: %s has a site link", tc.name, name) - } for _, w := range tc.want { if !strings.Contains(html, w) { t.Errorf("%s: %s lacks %s", tc.name, name, w) } } + for _, d := range tc.deny { + if strings.Contains(html, d) { + t.Errorf("%s: %s contains %s", tc.name, name, d) + } + } } } } diff --git a/scripts/check-phase11.2.sh b/scripts/check-phase11.2.sh index 0d89f5a..ee2685c 100755 --- a/scripts/check-phase11.2.sh +++ b/scripts/check-phase11.2.sh @@ -20,13 +20,18 @@ ROOT="${PHASE11_2_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" APP="${PHASE11_2_APP:-$ROOT/../sm-summercmsio-app}" PLUG="$APP/plugins/golem15/summercms" SITE="$APP/vue-summercmsio-app" +APP_PKG="git.golem15.com/golem15/sm-summercmsio-app" # Statement coverage floors (SC5). PLUGIN_COVERAGE_MIN=90.0 +DOCSITE_COVERAGE_MIN=85.0 usage() { cat >&2 <<'EOF' usage: + check-phase11.2.sh --framework check-phase11.2.sh --plugin + check-phase11.2.sh --app + check-phase11.2.sh --site EOF exit 2 } @@ -125,6 +130,20 @@ coverage_at_least() { awk -v t="$total" -v m="$min" 'BEGIN { exit !(t + 0 >= m + 0) }' || refuse "coverage $total% is below $min% in $dir" } +# Framework tests of the D-41/D-46 site link, plus the docs checker. +DOCSITE_TESTS=(TestParseSite TestCheckSiteURL TestSiteLabel TestSiteURLPrecedence TestSiteLink) +SUMMER_TESTS=(TestDocsTree TestDocsBuildRealTree TestDocsBuildSiteFlags TestDocsSiteFlagsInHelp) + +run_framework() { + (cd "$ROOT" && go vet ./...) + named_tests "$ROOT" ./internal/docsite "${DOCSITE_TESTS[@]}" || refuse "framework: internal/docsite named tests" + named_tests "$ROOT" ./cmd/summer "${SUMMER_TESTS[@]}" || refuse "framework: cmd/summer named tests" + coverage_at_least "$ROOT" "$DOCSITE_COVERAGE_MIN" ./internal/docsite + "$ROOT/scripts/check-phase11.1.sh" --docs + "$ROOT/scripts/check-phase11.1.sh" --forbidden + echo "phase11.2 framework passed" +} + PLUGIN_TESTS=(TestStaticSmoke TestStaticSite TestStaticDocs TestStaticConditionalAndRange TestStaticNoBlockingHeaders TestStaticRedirectLocations TestStaticMissing404Page TestNewHandlersMissingIndex TestContentType TestSiteImmutable TestRoutesAssemble TestRoutesFailClosed TestRoutesCoexistWithAdminPatterns TestPluginIdentity TestPluginEmbeddedTree @@ -138,7 +157,46 @@ run_plugin() { echo "phase11.2 plugin passed" } +# The D-40 terminal-check helpers (the gated TestTerminalCommands runs in +# the terminal stage). +APP_TESTS=(TestLoadTerminal TestTerminalScript TestTerminalEnv) + +run_app() { + local pkgs + need_dir "$APP" + go -C "$APP" vet ./... + pkgs="$(go -C "$APP" list ./...)" + [ "$pkgs" = "$APP_PKG" ] || refuse "app: go list ./... printed '$pkgs', want only $APP_PKG" + (unset SUMMERCMS_TERMINAL_CHECK && named_tests "$APP" . "${APP_TESTS[@]}") || refuse "app: named tests" + echo "phase11.2 app passed" +} + +# run_site installs from the lockfile, generates the static site and runs +# the node:test suites (output, terminal and scroll-spy tests). +run_site() { + local log + need_dir "$SITE" + command -v pnpm >/dev/null || refuse "site: pnpm not found" + pnpm -C "$SITE" install --frozen-lockfile + pnpm -C "$SITE" run generate + log="$(mktemp)" + SCRATCH+=("$log") + pnpm -C "$SITE" test >"$log" 2>&1 || { + cat "$log" >&2 + refuse "site: pnpm test failed" + } + grep -E '^# (tests|pass|fail|skipped|todo|cancelled) ' "$log" + grep -qx '# fail 0' "$log" || refuse "site: no '# fail 0' line" + grep -qE '^# pass [1-9][0-9]*$' "$log" || refuse "site: no passing tests" + grep -qx '# skipped 0' "$log" || refuse "site: skipped tests" + grep -qx '# todo 0' "$log" || refuse "site: todo tests" + echo "phase11.2 site passed" +} + case "${1:-}" in +--framework) run_framework ;; --plugin) run_plugin ;; +--app) run_app ;; +--site) run_site ;; *) usage ;; esac