Files
summercms/internal/docsite/violations_test.go
Jakub Zych 1d38e73f03 test(11.1-06): plant one fixture per docs checker rule and run them in the gate
- testdata/clean passes every check; 63 violation cases each overlay one
  fault and a want.txt naming the single problem it must produce
- TestPlantedViolations also plants the forbidden name (split literals)
  and symlink escapes at run time; TestBuildOutputGuard covers --out
- check-phase11.1.sh --self-test runs the corpus through a go test -json
  detector that refuses FAIL, SKIP, zero tests and no tests to run
- the self-test scratch copy now carries examples/ and README.md, so its
  baseline sees the example command literals the real tree sees
2026-09-30 23:52:19 +02:00

422 lines
14 KiB
Go

package docsite
import (
"errors"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// The planted-violation corpus. testdata/clean is a small repository root
// that passes every check. Each testdata/violations/<case> directory holds
// the files that differ from the clean fixture (copied over it, same
// relative paths), an optional remove.txt listing clean files to delete,
// and a want.txt naming the one problem the plant must produce:
//
// rule: frontmatter
// file: docs/guide/config.md
// message: unknown field "colour"
//
// The file line is optional; message is a substring of the problem
// message. A case passes only when Check reports exactly that one problem,
// so a checker that stops reporting a rule, or reports it under another
// rule, turns its case red.
const (
cleanFixture = "testdata/clean"
violationsFixture = "testdata/violations"
)
// violationCommands is the command set the corpus is checked with.
var violationCommands = &Commands{Tool: []string{"docs:build", "docs:sync"}, App: []string{"serve", "migrate"}}
// copyTree copies the regular files under src into dst.
func copyTree(t *testing.T, src, dst string) {
t.Helper()
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
if err != nil {
t.Fatalf("copy %s: %v", src, err)
}
}
// cleanRoot copies the clean fixture into a fresh temp dir.
func cleanRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
copyTree(t, cleanFixture, root)
return root
}
type wantProblem struct {
rule, file, message string
}
func (w wantProblem) matches(p Problem) bool {
return p.Rule == w.rule && (w.file == "" || p.File == w.file) && strings.Contains(p.Message, w.message)
}
func parseWant(t *testing.T, path string) wantProblem {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var w wantProblem
for _, line := range strings.Split(string(raw), "\n") {
key, value, ok := strings.Cut(line, ": ")
if !ok {
continue
}
switch key {
case "rule":
w.rule = value
case "file":
w.file = value
case "message":
w.message = value
default:
t.Fatalf("%s: unknown key %q", path, key)
}
}
if w.rule == "" || w.message == "" {
t.Fatalf("%s: want.txt needs a rule and a message", path)
}
return w
}
// plantCase builds the scratch root of one violation case.
func plantCase(t *testing.T, dir string) string {
t.Helper()
root := cleanRoot(t)
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, err := filepath.Rel(dir, p)
if err != nil {
return err
}
if rel == "want.txt" || rel == "remove.txt" {
return nil
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
target := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
if err != nil {
t.Fatal(err)
}
if raw, err := os.ReadFile(filepath.Join(dir, "remove.txt")); err == nil {
for _, name := range strings.Fields(string(raw)) {
if err := os.Remove(filepath.Join(root, filepath.FromSlash(name))); err != nil {
t.Fatalf("remove.txt: %v", err)
}
}
} else if !errors.Is(err, fs.ErrNotExist) {
t.Fatal(err)
}
return root
}
// assertOneProblem requires exactly one problem, matching want.
func assertOneProblem(t *testing.T, problems []Problem, want wantProblem) {
t.Helper()
if len(problems) != 1 || !want.matches(problems[0]) {
t.Fatalf("problems =\n%s\nwant exactly one %s problem in %q containing %q",
strings.Join(problemLines(problems), "\n"), want.rule, want.file, want.message)
}
}
func TestCleanFixture(t *testing.T) {
root := cleanRoot(t)
problems, err := Check(Options{Root: root, Commands: violationCommands})
if err != nil {
t.Fatal(err)
}
if len(problems) > 0 {
t.Fatalf("clean fixture has problems:\n%s", strings.Join(problemLines(problems), "\n"))
}
out := filepath.Join(t.TempDir(), "site")
res, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands})
if err != nil || len(problems) > 0 {
t.Fatalf("Build: %v %q", err, problemLines(problems))
}
// index, two guide pages, two extras pages and the demo API page.
if res.Pages != 6 {
t.Fatalf("pages = %d, want 6", res.Pages)
}
res2, problems, err := Sync(Options{Root: root})
if err != nil || len(problems) > 0 || res2 != (SyncResult{}) {
t.Fatalf("Sync on the clean fixture = %+v %q %v, want up to date", res2, problemLines(problems), err)
}
}
func TestPlantedViolations(t *testing.T) {
entries, err := os.ReadDir(violationsFixture)
if err != nil {
t.Fatal(err)
}
var cases []string
for _, e := range entries {
if e.IsDir() {
cases = append(cases, e.Name())
}
}
if len(cases) < 30 {
t.Fatalf("%d violation cases, want at least 30", len(cases))
}
for _, name := range cases {
t.Run(name, func(t *testing.T) {
dir := filepath.Join(violationsFixture, name)
want := parseWant(t, filepath.Join(dir, "want.txt"))
root := plantCase(t, dir)
problems, err := Check(Options{Root: root, Commands: violationCommands})
if err != nil {
t.Fatal(err)
}
assertOneProblem(t, problems, want)
// Build must refuse the same tree and write nothing.
out := filepath.Join(t.TempDir(), "site")
if _, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands}); err != nil || len(problems) == 0 {
t.Fatalf("Build accepted the planted tree: %v", err)
}
if _, err := os.Stat(out); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Build wrote %s despite a problem", out)
}
})
}
// Plants that cannot be committed: the forbidden word (assembled from
// split literals, so no committed file names a consuming application)
// and a symlink that leaves the root.
word := "fono" + "teka"
t.Run("forbidden-source", func(t *testing.T) {
root := cleanRoot(t)
appendFile(t, root, "docs/extras/faq.md", "\nThe "+word+" application.\n")
problems, err := Check(Options{Root: root, Commands: violationCommands})
if err != nil {
t.Fatal(err)
}
assertOneProblem(t, problems, wantProblem{rule: "forbidden", file: "docs/extras/faq.md", message: forbiddenMessage})
if strings.Contains(strings.ToLower(problems[0].String()), word) {
t.Fatalf("problem line repeats the match: %s", problems[0])
}
})
t.Run("forbidden-accented", func(t *testing.T) {
root := cleanRoot(t)
appendFile(t, root, "docs/extras/faq.md", "\nSee "+"P"+"ł"+"ý"+"tarium.\n")
problems, err := Check(Options{Root: root, Commands: violationCommands})
if err != nil {
t.Fatal(err)
}
assertOneProblem(t, problems, wantProblem{rule: "forbidden", file: "docs/extras/faq.md", message: forbiddenMessage})
})
t.Run("forbidden-output", func(t *testing.T) {
root := cleanRoot(t)
site := filepath.Join(root, "docs/site.yaml")
raw, err := os.ReadFile(site)
if err != nil {
t.Fatal(err)
}
raw = []byte(strings.Replace(string(raw), "the clean docs checker fixture", "the "+word+" fixture", 1))
if err := os.WriteFile(site, raw, 0o644); err != nil {
t.Fatal(err)
}
problems, err := Check(Options{Root: root, Commands: violationCommands})
if err != nil {
t.Fatal(err)
}
// site.yaml is not a page, so only the rendered outputs carry it:
// every page's text outputs, llms.txt and llms-full.txt.
if len(problems) == 0 || !slices.ContainsFunc(problems, func(p Problem) bool { return p.File == "llms.txt" }) {
t.Fatalf("output scan missed llms.txt: %q", problemLines(problems))
}
for _, p := range problems {
if p.Rule != "forbidden" || p.Message != forbiddenMessage {
t.Fatalf("unexpected problem %s", p)
}
}
})
t.Run("snippet-symlink-escape", func(t *testing.T) {
root := cleanRoot(t)
outside := filepath.Join(t.TempDir(), "secret.txt")
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "config", "escape.txt")); err != nil {
t.Fatal(err)
}
appendFile(t, root, "docs/extras/faq.md", "\n```text src=config/escape.txt\nsecret\n```\n")
problems, err := Check(Options{Root: root, Commands: violationCommands})
if err != nil {
t.Fatal(err)
}
assertOneProblem(t, problems, wantProblem{rule: "snippet", file: "docs/extras/faq.md", message: "path resolves outside the repository root"})
})
t.Run("snippet-symlink-dotfile", func(t *testing.T) {
root := cleanRoot(t)
if err := os.WriteFile(filepath.Join(root, ".secret"), []byte("secret\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, ".secret"), filepath.Join(root, "config", "alias.txt")); err != nil {
t.Fatal(err)
}
appendFile(t, root, "docs/extras/faq.md", "\n```text src=config/alias.txt\nsecret\n```\n")
problems, err := Check(Options{Root: root, Commands: violationCommands})
if err != nil {
t.Fatal(err)
}
assertOneProblem(t, problems, wantProblem{rule: "snippet", file: "docs/extras/faq.md", message: "path resolves to a dotfile or .env file"})
})
}
func appendFile(t *testing.T, root, name, text string) {
t.Helper()
f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(name)), os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := f.WriteString(text); err != nil {
t.Fatal(err)
}
}
// TestBuildOutputGuard asserts that Build refuses an output directory that
// would overwrite the sources or an unrelated directory, and leaves a
// sentinel file in it untouched.
func TestBuildOutputGuard(t *testing.T) {
for _, tc := range []struct {
name string
// opts returns the Src and Out for a fixture root and the directory
// the sentinel goes into.
opts func(root string) (src, out, sentinelDir string)
want string
}{
{"out-equals-root", func(root string) (string, string, string) {
return "", root, root
}, "--out must not be inside --src or equal to the repository root"},
{"out-inside-src", func(root string) (string, string, string) {
return "", filepath.Join(root, "docs", "site"), filepath.Join(root, "docs")
}, "--out must not be inside --src or equal to the repository root"},
{"out-equals-src", func(root string) (string, string, string) {
return "", filepath.Join(root, "docs"), filepath.Join(root, "docs")
}, "--out must not be inside --src or equal to the repository root"},
{"out-contains-src", func(root string) (string, string, string) {
return filepath.Join(root, "content", "docs"), filepath.Join(root, "content"), filepath.Join(root, "content")
}, "--out must not be inside --src or equal to the repository root"},
{"out-contains-root", func(root string) (string, string, string) {
return "", filepath.Dir(root), filepath.Dir(root)
}, "--out must not be inside --src or equal to the repository root"},
{"out-unmarked", func(root string) (string, string, string) {
out := filepath.Join(t.TempDir(), "unrelated")
return "", out, out
}, "has no .summer-docs marker"},
} {
t.Run(tc.name, func(t *testing.T) {
root := cleanRoot(t)
src, out, sentinelDir := tc.opts(root)
if src != "" {
if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join(root, "docs"), src); err != nil {
t.Fatal(err)
}
}
if err := os.MkdirAll(sentinelDir, 0o755); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(sentinelDir, "sentinel.txt")
if err := os.WriteFile(sentinel, []byte("keep me\n"), 0o644); err != nil {
t.Fatal(err)
}
_, problems, err := Build(Options{Root: root, Src: src, Out: out, Commands: violationCommands})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Build = %v (problems %q), want an error containing %q", err, problemLines(problems), tc.want)
}
if got, err := os.ReadFile(sentinel); err != nil || string(got) != "keep me\n" {
t.Fatalf("sentinel changed: %q %v", got, err)
}
if _, err := os.Stat(filepath.Join(out, MarkerFile)); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Build wrote the marker into a refused --out")
}
})
}
t.Run("out-symlink-into-src", func(t *testing.T) {
root := cleanRoot(t)
link := filepath.Join(t.TempDir(), "site")
if err := os.Symlink(filepath.Join(root, "docs"), link); err != nil {
t.Fatal(err)
}
_, _, err := Build(Options{Root: root, Out: link, Commands: violationCommands})
if !errors.Is(err, errOutInside) {
t.Fatalf("Build through a symlink into docs/ = %v, want errOutInside", err)
}
if _, err := os.Stat(filepath.Join(root, "docs", "index.md")); err != nil {
t.Fatalf("docs/index.md removed: %v", err)
}
})
t.Run("out-marked-is-cleaned", func(t *testing.T) {
root := cleanRoot(t)
out := filepath.Join(t.TempDir(), "site")
if err := os.MkdirAll(filepath.Join(out, "stale"), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{MarkerFile, "stale/old.html"} {
if err := os.WriteFile(filepath.Join(out, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if _, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands}); err != nil || len(problems) > 0 {
t.Fatalf("Build into a marked directory: %v %q", err, problemLines(problems))
}
if _, err := os.Stat(filepath.Join(out, "stale")); !errors.Is(err, fs.ErrNotExist) {
t.Fatal("a stale file survived the clean")
}
if _, err := os.Stat(filepath.Join(out, "index.html")); err != nil {
t.Fatal(err)
}
})
t.Run("out-empty-dir", func(t *testing.T) {
root := cleanRoot(t)
out := t.TempDir()
if _, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands}); err != nil || len(problems) > 0 {
t.Fatalf("Build into an empty directory: %v %q", err, problemLines(problems))
}
if _, err := os.Stat(filepath.Join(out, MarkerFile)); err != nil {
t.Fatal(err)
}
})
}