package config import ( "strings" "testing" "time" ) const minimal = ` [[repo]] name = "demo" src = "https://git.example.com/demo.git" dst = "git@github.com:me/demo.git" ` func mustParse(t *testing.T, doc string) *Config { t.Helper() cfg, err := Parse([]byte(doc)) if err != nil { t.Fatalf("parse: %v", err) } return cfg } func TestDefaultsAreApplied(t *testing.T) { cfg := mustParse(t, minimal) if cfg.WorkDir != DefaultWorkDir { t.Errorf("work_dir = %q, want %q", cfg.WorkDir, DefaultWorkDir) } if cfg.LogLevel != "info" || cfg.LogFormat != "text" { t.Errorf("log defaults = %q/%q", cfg.LogLevel, cfg.LogFormat) } if cfg.Concurrency < 1 { t.Errorf("concurrency = %d, want >= 1", cfg.Concurrency) } j := cfg.Jobs[0] if j.Interval != DefaultInterval || j.Timeout != DefaultTimeout { t.Errorf("interval/timeout = %s/%s", j.Interval, j.Timeout) } if !j.Prune || !j.Force { t.Error("prune and force should default to true for a mirror") } if j.Atomic || j.AllowEmpty { t.Error("atomic and allow_empty should default to false") } if strings.Join(j.Refs, ",") != strings.Join(DefaultRefs, ",") { t.Errorf("refs = %v, want %v", j.Refs, DefaultRefs) } if !strings.HasSuffix(j.Dir, "mirrors/demo.git") { t.Errorf("mirror dir = %q", j.Dir) } } func TestGlobalDefaultsCascadeAndRepoOverrides(t *testing.T) { cfg := mustParse(t, ` [global] work_dir = "/data" interval = "10m" prune = false ssh_key = "/keys/shared" [[repo]] name = "inherits" src = "https://example.com/a.git" dst = "git@github.com:me/a.git" [[repo]] name = "overrides" src = "https://example.com/b.git" dst = "git@github.com:me/b.git" interval = "30s" prune = true ssh_key = "/keys/b" `) byName := map[string]Job{} for _, j := range cfg.Jobs { byName[j.Name] = j } a := byName["inherits"] if a.Interval != 10*time.Minute { t.Errorf("inherited interval = %s, want 10m", a.Interval) } if a.Prune { t.Error("inherited prune should be false") } if a.Dst.SSHKey != "/keys/shared" { t.Errorf("inherited ssh_key = %q", a.Dst.SSHKey) } b := byName["overrides"] if b.Interval != 30*time.Second { t.Errorf("overridden interval = %s, want 30s", b.Interval) } if !b.Prune { t.Error("overridden prune should be true") } if b.Dst.SSHKey != "/keys/b" { t.Errorf("overridden ssh_key = %q", b.Dst.SSHKey) } } func TestEndpointAcceptsStringOrTable(t *testing.T) { cfg := mustParse(t, ` [[repo]] name = "demo" src = "https://git.example.com/demo.git" [repo.dst] url = "git@github.com:me/demo.git" ssh_key = "/keys/demo" known_hosts = "/etc/syncbot/known_hosts" strict_host_key = "yes" `) j := cfg.Jobs[0] if j.Src.URL != "https://git.example.com/demo.git" { t.Errorf("src url = %q", j.Src.URL) } if j.Dst.URL != "git@github.com:me/demo.git" { t.Errorf("dst url = %q", j.Dst.URL) } if j.Dst.SSHKey != "/keys/demo" || j.Dst.KnownHosts != "/etc/syncbot/known_hosts" { t.Errorf("dst ssh settings = %+v", j.Dst) } if j.Dst.StrictHostKey != "yes" { t.Errorf("strict_host_key = %q", j.Dst.StrictHostKey) } // src has no key of its own and none was inherited. if j.Src.SSHKey != "" { t.Errorf("src ssh_key = %q, want empty", j.Src.SSHKey) } } func TestStrictHostKeyDefaultFollowsKnownHosts(t *testing.T) { cfg := mustParse(t, minimal) if got := cfg.Jobs[0].Dst.StrictHostKey; got != "accept-new" { t.Errorf("without known_hosts: %q, want accept-new", got) } cfg = mustParse(t, minimal+` [global] known_hosts = "/etc/syncbot/known_hosts" `) if got := cfg.Jobs[0].Dst.StrictHostKey; got != "yes" { t.Errorf("with known_hosts: %q, want yes", got) } } func TestEnvExpansion(t *testing.T) { t.Setenv("SYNCBOT_TEST_TOKEN", "s3cr#t$") cfg := mustParse(t, ` [[repo]] name = "demo" src = "https://x-access-token:${SYNCBOT_TEST_TOKEN}@github.com/me/demo.git" dst = "git@github.com:me/mirror.git" `) want := "https://x-access-token:s3cr#t$@github.com/me/demo.git" if got := cfg.Jobs[0].Src.URL; got != want { t.Errorf("expanded src = %q, want %q", got, want) } } func TestUndefinedEnvVarIsAnError(t *testing.T) { _, err := Parse([]byte(` [[repo]] name = "demo" src = "https://${SYNCBOT_DEFINITELY_UNSET}@example.com/a.git" dst = "git@github.com:me/a.git" `)) if err == nil || !strings.Contains(err.Error(), "SYNCBOT_DEFINITELY_UNSET") { t.Fatalf("want an error naming the missing variable, got %v", err) } } func TestDisabledRepoIsSkipped(t *testing.T) { cfg := mustParse(t, ` [[repo]] name = "on" src = "https://example.com/a.git" dst = "git@github.com:me/a.git" [[repo]] name = "off" enabled = false src = "https://example.com/b.git" dst = "git@github.com:me/b.git" `) if len(cfg.Jobs) != 1 || cfg.Jobs[0].Name != "on" { t.Fatalf("want only the enabled repo, got %d: %+v", len(cfg.Jobs), cfg.Jobs) } } func TestValidationErrors(t *testing.T) { cases := []struct{ name, doc, want string }{ {"no repos", `[global] work_dir = "/data"`, "nothing to sync"}, {"missing name", `[[repo]] src = "a" dst = "b"`, "name must match"}, {"bad name", `[[repo]] name = "../escape" src = "a" dst = "b"`, "name must match"}, {"duplicate name", `[[repo]] name = "x" src = "a" dst = "b" [[repo]] name = "x" src = "c" dst = "d"`, "duplicate name"}, {"missing dst", `[[repo]] name = "x" src = "a"`, "dst is required"}, {"same src and dst", `[[repo]] name = "x" src = "a" dst = "a"`, "same repository"}, {"unknown key", `[[repo]] name = "x" src = "a" dst = "b" intervall = "5m"`, "unknown config key"}, {"bad duration", `[[repo]] name = "x" src = "a" dst = "b" interval = "5 minutes"`, "invalid duration"}, {"relative ssh key", `[[repo]] name = "x" src = "a" dst = "b" ssh_key = "keys/x"`, "absolute path"}, {"ref without prefix", `[[repo]] name = "x" src = "a" dst = "b" refs = ["heads/*"]`, `must start with "refs/"`}, {"two globs", `[[repo]] name = "x" src = "a" dst = "b" refs = ["refs/*/*"]`, `at most one`}, {"bad log level", `[global] log_level = "verbose" [[repo]] name = "x" src = "a" dst = "b"`, "log_level"}, {"bad endpoint key", `[[repo]] name = "x" src = "a" [repo.dst] url = "b" sshkey = "/k"`, "unknown key"}, {"endpoint table without url", `[[repo]] name = "x" src = "a" [repo.dst] ssh_key = "/k"`, "url"}, {"bad git_config", `[[repo]] name = "x" src = "a" dst = "b" git_config = ["pack.threads"]`, "key=value"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := Parse([]byte(tc.doc)) if err == nil { t.Fatalf("want an error mentioning %q", tc.want) } if !strings.Contains(err.Error(), tc.want) { t.Errorf("error = %q, want it to mention %q", err, tc.want) } }) } } func TestMaxBackoffNeverBelowInterval(t *testing.T) { cfg := mustParse(t, ` [[repo]] name = "x" src = "a" dst = "b" interval = "10m" max_backoff = "1m" `) if got := cfg.Jobs[0].MaxBackoff; got != 10*time.Minute { t.Errorf("max_backoff = %s, want it raised to the interval (10m)", got) } } func TestJobsAreSortedForStableDiffs(t *testing.T) { cfg := mustParse(t, ` [[repo]] name = "zulu" src = "a" dst = "b" [[repo]] name = "alpha" src = "c" dst = "d" `) if cfg.Jobs[0].Name != "alpha" || cfg.Jobs[1].Name != "zulu" { t.Errorf("jobs not sorted: %s, %s", cfg.Jobs[0].Name, cfg.Jobs[1].Name) } }