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