Download
247 lines 9.9 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
package config

import (
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"strings"
	"testing"
)

// paths are the settings every config must have.
const paths = "git_dir: /srv/git\nhtdocs: /srv/www\nspool_dir: /srv/spool\nstate_dir: /srv/state\n"

func TestDefaultsAndParse(t *testing.T) {
	cfg, err := Parse([]byte("host: code.example.com\n" + paths))
	if err != nil {
		t.Fatal(err)
	}
	if cfg.GitDir != "/srv/git" || cfg.Htdocs != "/srv/www" || cfg.ListingTruncate != 5 || cfg.Site.Unlisted != "private" ||
		cfg.Log != "" || cfg.SSHKey != "" || len(cfg.LexerDirs) != 0 || cfg.Git != "" {
		t.Errorf("defaults: %+v", cfg)
	}
	for _, key := range []string{"git_dir", "htdocs", "spool_dir", "state_dir"} {
		if _, err := Parse([]byte("host: h\n")); err == nil || !strings.Contains(err.Error(), key+" is required") {
			t.Errorf("no %s: %v", key, err)
		}
	}
	if got := cfg.CloneURL("foo"); got != "https://code.example.com/git/foo.git" {
		t.Errorf("clone url: %s", got)
	}

	full := `
host: code.example.com
git_dir: /srv/git
htdocs: /srv/www/code
spool_dir: /srv/spool
state_dir: /srv/state
lexer_dirs: [/srv/repo-render/lexers]

site:
  title: Bruce's code
  clone_url: "git://code.example.com/{repo}"
  unlisted: hide

sections:
  - title: Tools
    repos: [bp, bb]
  - title: "[Tomo](https://tomo.example.com)"
    repos: [tomo]

lexers:
  tomo:
    globs: ["*.tm"]
    aliases: [tomo, tm]

repos:
  bp:
    tags: [c, tool]
`
	cfg, err = Parse([]byte(full))
	if err != nil {
		t.Fatal(err)
	}
	if cfg.CloneURL("bp") != "git://code.example.com/bp" || cfg.SectionOf("tomo") != 1 || cfg.SectionOf("zzz") != -1 {
		t.Errorf("parsed: %+v", cfg)
	}
	if cfg.Private("zzz") {
		t.Error("unlisted: hide made an unlisted repo private")
	}
	cfg.Site.Unlisted = "private"
	if !cfg.Private("zzz") || cfg.Private("tomo") {
		t.Error("unlisted: private should make exactly the repos in no section private")
	}
	if len(cfg.Repo("bp").Tags) != 2 || cfg.LexerNames()[0] != "tomo" || cfg.Htdocs != "/srv/www/code" || cfg.LexerDirs[0] != "/srv/repo-render/lexers" {
		t.Errorf("parsed: %+v", cfg)
	}
}

func TestValidation(t *testing.T) {
	cases := map[string]string{
		"":                                   "host is required",
		"# just a comment\n":                 "host is required",
		"workers: 1":                         "host is required",
		"host: h\nbogus: 1":                  `unknown field "bogus"`,
		"host: h\nsite:\n  titel: x":         `unknown field "titel"`,
		"host: h\nhost: h2":                  `"host" already defined`,
		"host: h\nsite:\n  unlisted: maybe":  "site.unlisted",
		"host: h\nsections:\n  - repos: [a]": "title is required",
		"host: h\nsections:\n  - {title: a, repos: [x]}\n  - {title: b, repos: [x]}":                 "listed in both",
		"host: h\nsections:\n  - {title: a, repos: [../x]}":                                          "invalid repo name",
		"host: h\n" + paths + "private_htdocs: /srv/www/private":                                     "must be outside htdocs",
		"host: h\n" + paths + "private_htdocs: /srv":                                                 "must be outside htdocs",
		"host: h\ngit_dir: /g\nspool_dir: /s\nstate_dir: /a\nhtdocs: /srv/a\nprivate_htdocs: /srv/a": "must be outside htdocs",
		"host: h\nspool_dir: /var/spool/x\nstate_dir: /var/spool/x":                                  "must be different directories",
		"host: h\ngit_dir: \"\"":                                                                     "git_dir is required",
		"host: h\nlisten: \"*:8080\"":                                                                "listen: the renderer no longer configures the web server",
		"host: h\naliases: [x]":                                                                      "aliases: the renderer no longer configures the web server",
		"host: h\nhtpasswd: /x":                                                                      "htpasswd: the renderer no longer configures the web server",
		"host: h\nsearch_exclude: [\"[\"]":                                                           "bad glob",
		"host: ../x":                                                                                 "host must be a hostname",
		"host: a b":                                                                                  "host must be a hostname",
		"host: h\nraw_max_bytes: -1":                                                                 "raw_max_bytes must be >= 0",
		"host: h\nrepo_max_files: -1":                                                                "repo_max_files must be >= 0",
		"host: h\nrepos:\n  r:\n    github: git@github.com:me/r.git":                                 `unknown field "github"`,
		"host: h\nrepos:\n  r:\n    mirrors: [x:r.git]":                                              "config --add repo-render.mirror URL",
		"host: h\nssh_key: /k":                                                                       "ssh_key: the renderer no longer mirrors",
		"host: h\ngit_dir: /var/git\nrepos:\n  r:\n    description: Mine":                            "move it to /var/git/r.git/description",
	}
	for src, want := range cases {
		_, err := Parse([]byte(src))
		if err == nil || !strings.Contains(err.Error(), want) {
			t.Errorf("%q: got %v, want %q", src, err, want)
		}
	}
	for _, ok := range []string{"a", "my-repo", "x.y_z", "A1"} {
		if !ValidRepoName(ok) {
			t.Errorf("ValidRepoName(%q) = false", ok)
		}
	}
	for _, bad := range []string{"", ".", "..", ".hidden", "a/b", "a b", "repo.git", "-x"} {
		if ValidRepoName(bad) {
			t.Errorf("ValidRepoName(%q) = true", bad)
		}
	}
}

func TestBranchAllowlist(t *testing.T) {
	good := []string{"dev", "my-name/feature-with-kebabs", "release/1.2", "v2_x"}
	for _, b := range good {
		if !ValidBranchName(b) {
			t.Errorf("%q rejected", b)
		}
	}
	bad := []string{"", "/x", "x/", "a//b", "a/../b", ".hidden", "a/.b", "x.lock", "a b", "a:b", "a~b", "feat*", "a@{b}", "a..b", "é", "HEAD"}
	for _, b := range bad {
		if ValidBranchName(b) {
			t.Errorf("%q accepted", b)
		}
	}
	if k := ViewKey("tomo", "my-name/feature-with-kebabs"); k != "tomo@my-name:feature-with-kebabs" {
		t.Errorf("ViewKey = %q", k)
	}
	if ViewKey("tomo", "") != "tomo" {
		t.Error("default view key must be the repo name")
	}
	if ViewKey("a", "x/y") == ViewKey("a", "x-y") {
		t.Error("slugs of different branches collide")
	}
	for _, src := range []string{
		"host: h\nrepos:\n  r:\n    branches: [a b]\n",
		"host: h\nrepos:\n  r:\n    branches: [dev, dev]\n",
		"host: h\nrepos:\n  r:\n    branch: ../../x\n",
		"host: h\nrepos:\n  r:\n    branch: \"a\\nb\"\n",
		"host: h\nrepos:\n  r:\n    tags: [lua, lua]\n",
		"host: h\nrepos:\n  r:\n    tags: [\"\"]\n",
		"host: h\nrepos:\n  r:\n    tags: [\" lua\"]\n",
	} {
		if _, err := Parse([]byte(src)); err == nil {
			t.Errorf("accepted:\n%s", src)
		}
	}
	cfg, err := Parse([]byte("host: h\n" + paths + "repos:\n  r:\n    branch: trunk/main\n    branches: [dev, a/b]\n"))
	if err != nil || len(cfg.Repo("r").Branches) != 2 || cfg.Repo("r").Branch != "trunk/main" {
		t.Errorf("good branches: %v", err)
	}
	cfg, err = Parse([]byte("host: h\n" + paths + "repos:\n  r:\n    tags: [lua, vibe coding]\n"))
	if err != nil || !slices.Equal(cfg.Repo("r").Tags, []string{"lua", "vibe coding"}) {
		t.Errorf("good tags: %v", err)
	}
}

// TestLoadRelativePaths checks that relative paths in a config file are
// relative to the file, so one directory can hold a whole site.
func TestLoadRelativePaths(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "repo-render.yaml")
	src := "host: h\ngit_dir: git\nhtdocs: htdocs\nspool_dir: spool\nstate_dir: /abs/state\nlog: \"\"\nlexer_dirs: [lexers]\nsite:\n  favicon: icon.svg\n"
	if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
		t.Fatal(err)
	}
	cfg, err := Load(p)
	if err != nil {
		t.Fatal(err)
	}
	for got, want := range map[string]string{
		cfg.GitDir:       filepath.Join(dir, "git"),
		cfg.Htdocs:       filepath.Join(dir, "htdocs"),
		cfg.SpoolDir:     filepath.Join(dir, "spool"),
		cfg.StateDir:     "/abs/state",
		cfg.LexerDirs[0]: filepath.Join(dir, "lexers"),
		cfg.Site.Favicon: filepath.Join(dir, "icon.svg"),
		cfg.Path:         p,
	} {
		if got != want {
			t.Errorf("got %q, want %q", got, want)
		}
	}
	if cfg.Log != "" {
		t.Errorf("log = %q, want empty (stderr only)", cfg.Log)
	}
}

func TestSiteURL(t *testing.T) {
	cfg, err := Parse([]byte("host: localhost\n" + paths + "site:\n  url: http://localhost:8080/\n"))
	if err != nil {
		t.Fatal(err)
	}
	if got := cfg.BaseURL(); got != "http://localhost:8080" {
		t.Errorf("base url: %s", got)
	}
	if got := cfg.CloneURL("foo"); got != "http://localhost:8080/git/foo.git" {
		t.Errorf("clone url: %s", got)
	}
	if _, err := Parse([]byte("host: localhost\nsite:\n  url: localhost:8080\n")); err == nil {
		t.Error("site.url without a scheme accepted")
	}
}

// TestExampleConfig keeps examples/repo-render.yaml valid as shipped, and
// with every commented-out setting uncommented, so that the defaults and
// examples it shows are ones the renderer takes. A commented-out setting
// is a "#" followed by no space or by two or more; prose has one.
func TestExampleConfig(t *testing.T) {
	src, err := os.ReadFile(filepath.Join("..", "..", "examples", "repo-render.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Parse(src); err != nil {
		t.Errorf("as shipped: %v", err)
	}
	setting := regexp.MustCompile(`(?m)^(\s*)#(\S|  )`)
	all := setting.ReplaceAll(src, []byte("$1$2"))
	cfg, err := Parse(all)
	if err != nil {
		t.Fatalf("uncommented: %v\n%s", err, all)
	}
	if cfg.PrivateHtdocs == "" || len(cfg.Sections) != 2 || len(cfg.Lexers) != 3 || len(cfg.Repo("tomo").Branches) != 1 || cfg.Site.Footer == "" {
		t.Errorf("uncommented, settings are missing: %+v", cfg)
	}
	d := Defaults()
	if cfg.Workers != d.Workers || cfg.RepoMaxBytes != d.RepoMaxBytes || cfg.ArchiveRetention != d.ArchiveRetention ||
		cfg.Style != d.Style || cfg.Site.Title != d.Site.Title || cfg.Site.Unlisted != d.Site.Unlisted ||
		!slices.Equal(cfg.SearchExclude, d.SearchExclude) || cfg.ListingTruncate != d.ListingTruncate {
		t.Error("a default the example shows is not the default")
	}
}