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
This commit is contained in:
Jakub Zych
2026-10-01 16:35:09 +02:00
parent 18fe100113
commit f5f9387ac9
4 changed files with 222 additions and 11 deletions

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -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 = `<span class="wordmark-cms">CMS</span></span></a>` + "\n" + `<div class="header-spacer"></div>`
for _, tc := range []struct {
name string
opts Options
want []string
deny []string
}{
{"unset", Options{}, nil},
{"explicit label", Options{SiteURL: "/", SiteLabel: "acme.example"}, []string{`<a class="site-link" href="/"`, `<span class="site-link-label">acme.example</span>`}},
{"host label", Options{SiteURL: "https://acme.example/docs"}, []string{`<a class="site-link" href="https://acme.example/docs"`, `<span class="site-link-label">acme.example</span>`}},
{"home label", Options{SiteURL: "/"}, []string{`<a class="site-link" href="/"`, `<span class="site-link-label">Home</span>`}},
{"unset", Options{}, []string{unsetHeader}, []string{"site-link"}},
{"explicit label", Options{SiteURL: "/", SiteLabel: "acme.example"}, []string{`<a class="site-link" href="/" aria-label="acme.example">`, `<span class="site-link-label">acme.example</span>`}, []string{unsetHeader}},
{"host label", Options{SiteURL: "https://acme.example/docs"}, []string{`<a class="site-link" href="https://acme.example/docs"`, `<span class="site-link-label">acme.example</span>`}, nil},
{"host and port label", Options{SiteURL: "http://acme.example:8080/"}, []string{`<a class="site-link" href="http://acme.example:8080/"`, `<span class="site-link-label">acme.example:8080</span>`}, nil},
{"home label", Options{SiteURL: "/"}, []string{`<a class="site-link" href="/"`, `<span class="site-link-label">Home</span>`}, nil},
{"escaped label", Options{SiteURL: "/", SiteLabel: "<b>&"}, []string{`aria-label="&lt;b&gt;&amp;"`, `<span class="site-link-label">&lt;b&gt;&amp;</span>`}, []string{"<b>&"}},
} {
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)
}
}
}
}
}

View File

@@ -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