cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / config / config_test.go
config_test.go 11.8K
1package config23import (4 "flag"5 "os"6 "path/filepath"7 "slices"8 "strings"9 "testing"10)1112func write(t *testing.T, path, text string) string {13 t.Helper()14 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {15 t.Fatal(err)16 }17 if err := os.WriteFile(path, []byte(text), 0o600); err != nil {18 t.Fatal(err)19 }20 return path21}2223func noEnv(string) string { return "" }2425func warnings(c *Config) string {26 var out []string27 for _, w := range c.Warnings {28 out = append(out, w.String())29 }30 return strings.Join(out, "\n")31}3233func TestParseFile(t *testing.T) {34 dir := t.TempDir()35 path := write(t, filepath.Join(dir, "config"), `# a comment36theme = Dracula37 # indented comment3839chart-images=false40jev-model = " spaced "41bogus = 142not a setting43log-level = loud44`)45 c := Load(path, noEnv, nil)46 if got := c.String("theme"); got != "Dracula" {47 t.Errorf("theme %q", got)48 }49 if c.Bool("chart-images") {50 t.Error("chart-images should be false")51 }52 if got := c.String("jev-model"); got != " spaced " {53 t.Errorf("quoted value %q", got)54 }55 if v := c.Get("log-level"); v.Raw != "info" || v.Src.Kind != FromDefault {56 t.Errorf("an invalid value keeps the default: %+v", v)57 }58 w := warnings(c)59 for _, want := range []string{`config:7: unknown key "bogus"`, `config:8: expected key = value`, `config:9: log-level: "loud" isn't one of`} {60 if !strings.Contains(w, want) {61 t.Errorf("warnings lack %q:\n%s", want, w)62 }63 }64 if v := c.Get("theme"); v.Src.Kind != FromFile || v.Src.Line != 2 {65 t.Errorf("theme source %+v", v.Src)66 }67}6869func TestMissingFileIsFine(t *testing.T) {70 c := Load(filepath.Join(t.TempDir(), "config"), noEnv, nil)71 if len(c.Warnings) != 0 || c.String("theme") != "terminal" || !c.Bool("chart-images") {72 t.Errorf("defaults: %v %q", c.Warnings, c.String("theme"))73 }74}7576func TestIncludes(t *testing.T) {77 dir := t.TempDir()78 write(t, filepath.Join(dir, "themes.conf"), "theme = Nord\n")79 write(t, filepath.Join(dir, "sub", "more"), "jev-model = included\nconfig-file = ../config\n")80 path := write(t, filepath.Join(dir, "config"), `config-file = themes.conf81theme = before-include-wins-not82config-file = ?optional-missing83config-file = required-missing84config-file = sub/more85`)86 c := Load(path, noEnv, nil)87 if got := c.String("theme"); got != "Nord" {88 t.Errorf("includes are read after the file, so they win: %q", got)89 }90 if got := c.String("jev-model"); got != "included" {91 t.Errorf("nested include: %q", got)92 }93 w := warnings(c)94 if strings.Contains(w, "optional-missing") {95 t.Errorf("an optional include warned:\n%s", w)96 }97 if !strings.Contains(w, "required-missing doesn't exist") {98 t.Errorf("no warning for a missing include:\n%s", w)99 }100 if !strings.Contains(w, "includes itself") {101 t.Errorf("no warning for the cycle:\n%s", w)102 }103 if n := len(c.List("config-file")); n != 5 {104 t.Errorf("config-file is repeatable: %d values", n)105 }106}107108func TestSelfInclude(t *testing.T) {109 dir := t.TempDir()110 path := write(t, filepath.Join(dir, "config"), "config-file = config\ntheme = x\n")111 c := Load(path, noEnv, nil)112 if c.String("theme") != "x" || !strings.Contains(warnings(c), "includes itself") {113 t.Errorf("self include: %q %v", c.String("theme"), c.Warnings)114 }115}116117func TestPrecedence(t *testing.T) {118 path := write(t, filepath.Join(t.TempDir(), "config"), "log-file = from-file.jsonl\njev-model = file-model\ntheme = Nord\n")119 env := map[string]string{"O12_LOG": "from-env.jsonl", "TYPESAFE_DEFAULT_MODEL": "env-model"}120 c := Load(path, func(k string) string { return env[k] }, map[string]string{"log-file": "from-flag.jsonl"})121 if got := c.Get("log-file"); got.Raw != "from-flag.jsonl" || got.Src.String() != "--log" {122 t.Errorf("flags win: %+v", got)123 }124 if got := c.Get("jev-model"); got.Raw != "env-model" || got.Src.String() != "$TYPESAFE_DEFAULT_MODEL" {125 t.Errorf("environment beats the file: %+v", got)126 }127 if got := c.Get("theme"); got.Raw != "Nord" {128 t.Errorf("file beats the default: %+v", got)129 }130 if got := c.Get("log-level"); got.Raw != "info" || got.Src.String() != "default" {131 t.Errorf("default: %+v", got)132 }133}134135func TestEmptyValueResets(t *testing.T) {136 path := write(t, filepath.Join(t.TempDir(), "config"), "theme = Nord\ntheme =\n")137 if got := Load(path, noEnv, nil).String("theme"); got != "terminal" {138 t.Errorf("empty value should reset: %q", got)139 }140}141142func TestParseFlags(t *testing.T) {143 flags, rest, err := ParseFlags([]string{"--log", "a.jsonl", "--otlp=http://localhost:4318", "b.csv", "--theme", "Nord"})144 if err != nil || flags["log-file"] != "a.jsonl" || flags["otlp-endpoint"] != "http://localhost:4318" ||145 flags["theme"] != "Nord" || !slices.Equal(rest, []string{"b.csv"}) {146 t.Errorf("got %v %v %v", flags, rest, err)147 }148 if _, _, err := ParseFlags([]string{"--otlp"}); err == nil {149 t.Error("--otlp without a value")150 }151}152153func TestBaseURL(t *testing.T) {154 for v, ok := range map[string]bool{155 "https://api.example.com": true,156 "http://127.0.0.1:8799": true,157 "http://localhost:1234/v1": true,158 "http://[::1]:80": true,159 "http://api.example.com": false,160 "http://127.evil.com": false,161 "ftp://localhost": false,162 "not a url": false,163 "https://": false,164 "http://localhost.evil.com": false,165 } {166 if err := CheckBaseURL(v); (err == nil) != ok {167 t.Errorf("%s: %v", v, err)168 }169 }170 path := write(t, filepath.Join(t.TempDir(), "config"), "jev-base-url = http://attacker.example\n")171 c := Load(path, noEnv, nil)172 if c.String("jev-base-url") != "" || !strings.Contains(warnings(c), "must be https") {173 t.Errorf("an http base URL was accepted: %q %v", c.String("jev-base-url"), c.Warnings)174 }175 env := func(k string) string {176 if k == "TYPESAFE_BASE_URL" {177 return "http://attacker.example"178 }179 return ""180 }181 if c := Load("", env, nil); c.String("jev-base-url") != "" {182 t.Error("an http base URL from the environment was accepted")183 }184}185186func TestThemeChoice(t *testing.T) {187 for v, want := range map[string]ThemeChoice{188 "": {"terminal", "terminal"},189 "Nord": {"Nord", "Nord"},190 "light:Nord Light,dark:Nord": {"Nord Light", "Nord"},191 "dark: B , light: A": {"A", "B"},192 } {193 got, err := ParseThemeChoice(v)194 if err != nil || got != want {195 t.Errorf("%q: %+v %v", v, got, err)196 }197 }198 for _, bad := range []string{"light:A", "light:A,dim:B", "light:,dark:B"} {199 if _, err := ParseThemeChoice(bad); err == nil {200 t.Errorf("%q accepted", bad)201 }202 }203}204205func TestSplitCommand(t *testing.T) {206 for in, want := range map[string][]string{207 `op read op://Private/TypeSafe/credential`: {"op", "read", "op://Private/TypeSafe/credential"},208 `pass show "api keys/typesafe"`: {"pass", "show", "api keys/typesafe"},209 `sh -c 'pass show x | head -1'`: {"sh", "-c", "pass show x | head -1"},210 `echo a\ b $HOME`: {"echo", "a b", "$HOME"},211 `printf ''`: {"printf", ""},212 } {213 got, err := SplitCommand(in)214 if err != nil || !slices.Equal(got, want) {215 t.Errorf("%s: %q %v", in, got, err)216 }217 }218 for _, bad := range []string{`echo 'open`, ``, ` `} {219 if _, err := SplitCommand(bad); err == nil {220 t.Errorf("%q accepted", bad)221 }222 }223}224225func TestShowRedacts(t *testing.T) {226 path := write(t, filepath.Join(t.TempDir(), "config"), "otlp-endpoint = https://user:hunter2@collector.example/?token=abc\n")227 out := Load(path, noEnv, nil).Show()228 if strings.Contains(out, "hunter2") || strings.Contains(out, "token=abc") {229 t.Errorf("secret shown:\n%s", out)230 }231 if !strings.Contains(out, "otlp-endpoint = https://xxxxx@collector.example/?xxxxx") {232 t.Errorf("redacted URL missing:\n%s", out)233 }234}235236func TestDefaultFileParses(t *testing.T) {237 path := write(t, filepath.Join(t.TempDir(), "config"), DefaultFile())238 c := Load(path, noEnv, nil)239 if len(c.Warnings) != 0 {240 t.Errorf("the default file warns: %v", c.Warnings)241 }242 for _, o := range Options {243 if !strings.Contains(DefaultFile(), "# "+o.Name+" = ") {244 t.Errorf("default file lacks %s", o.Name)245 }246 }247}248249func TestSetInFile(t *testing.T) {250 path := filepath.Join(t.TempDir(), "sub", "config")251 if err := SetInFile(path, "theme", "Nord"); err != nil {252 t.Fatal(err)253 }254 write(t, path, "# mine\ntheme = A\nchart-images = false\ntheme = B # later wins\n")255 if err := SetInFile(path, "theme", "Dracula"); err != nil {256 t.Fatal(err)257 }258 data, _ := os.ReadFile(path)259 if string(data) != "# mine\ntheme = A\nchart-images = false\ntheme = Dracula\n" {260 t.Errorf("got %q", data)261 }262 if err := SetInFile(path, "nope", "x"); err == nil {263 t.Error("unknown key accepted")264 }265}266267func TestRegistry(t *testing.T) {268 seen := map[string]bool{}269 for _, o := range Options {270 if seen[o.Name] {271 t.Errorf("%s registered twice", o.Name)272 }273 seen[o.Name] = true274 if o.Desc == "" || !slices.Contains(Groups, o.Group) {275 t.Errorf("%s: needs a description and a known group", o.Name)276 }277 if err := o.validate(o.Default); err != nil {278 t.Errorf("%s: default %q invalid: %v", o.Name, o.Default, err)279 }280 }281}282283func TestServeOptions(t *testing.T) {284 path := write(t, filepath.Join(t.TempDir(), "config"), "serve-max-sessions = 3\nserve-idle-timeout = 1h\nserve-listen = nope\nserve-max-sessions = 0\n")285 c := Load(path, noEnv, nil)286 if c.Int("serve-max-sessions") != 3 || c.Duration("serve-idle-timeout").Minutes() != 60 || c.String("serve-listen") != "127.0.0.1:2312" {287 t.Errorf("serve: %d %v %q", c.Int("serve-max-sessions"), c.Duration("serve-idle-timeout"), c.String("serve-listen"))288 }289 if w := warnings(c); !strings.Contains(w, "isn't host:port") || !strings.Contains(w, "at least 1") {290 t.Errorf("warnings:\n%s", w)291 }292 if home, _ := os.UserHomeDir(); c.String("serve-authorized-keys") != filepath.Join(home, ".ssh", "authorized_keys") {293 t.Errorf("~ not expanded: %q", c.String("serve-authorized-keys"))294 }295}296297func TestDir(t *testing.T) {298 x := t.TempDir()299 t.Setenv("XDG_CONFIG_HOME", x)300 if d, _ := Dir(); d != filepath.Join(x, "012") {301 t.Errorf("XDG_CONFIG_HOME ignored: %s", d)302 }303 if p, _ := DefaultPath(); p != filepath.Join(x, "012", "config") {304 t.Errorf("path %s", p)305 }306}307308var updateDocs = flag.Bool("update-docs", false, "rewrite the reference in docs/reference/config.md")309310// TestConfigDoc keeps docs/reference/config.md's reference in step with the311// registry: run `go test ./internal/config -update-docs` after changing312// an option.313func TestConfigDoc(t *testing.T) {314 const path = "../../docs/reference/config.md"315 data, err := os.ReadFile(path)316 if err != nil {317 t.Fatal(err)318 }319 intro, _, ok := strings.Cut(string(data), DocsMarker)320 if !ok {321 t.Fatalf("%s lacks the generated-reference marker", path)322 }323 want := intro + DocsMarker + "\n" + Reference()324 if *updateDocs {325 if err := os.WriteFile(path, []byte(want), 0o644); err != nil {326 t.Fatal(err)327 }328 return329 }330 if string(data) != want {331 t.Errorf("%s is stale: run go test ./internal/config -update-docs", path)332 }333}334335// TestLocaleFallsBackOnPOSIX reads the locale from LC_ALL, LC_NUMERIC or336// LANG only when nothing else sets it, and passes over POSIX locales it337// doesn't have without a warning.338func TestLocaleFallsBackOnPOSIX(t *testing.T) {339 dir := t.TempDir()340 set := write(t, filepath.Join(dir, "set"), "locale = fr-FR\n")341 unset := filepath.Join(dir, "none")342 cases := []struct {343 path string344 env map[string]string345 want string346 }{347 {unset, map[string]string{"LANG": "de_DE.UTF-8"}, "de-DE"},348 {unset, map[string]string{"LANG": "de_DE.UTF-8", "LC_ALL": "pt_BR.UTF-8"}, "pt-BR"},349 {unset, map[string]string{"LANG": "C.UTF-8"}, "en-US"},350 {unset, map[string]string{"LANG": "xx_YY.UTF-8", "LC_NUMERIC": "sv_SE"}, "sv-SE"},351 {set, map[string]string{"LANG": "de_DE.UTF-8"}, "fr-FR"},352 {unset, map[string]string{"LANG": "de_DE.UTF-8", "O12_LOCALE": "ja-JP"}, "ja-JP"},353 }354 for _, c := range cases {355 cfg := Load(c.path, func(k string) string { return c.env[k] }, nil)356 if got := cfg.String("locale"); got != c.want || len(cfg.Warnings) > 0 {357 t.Errorf("%v: locale %q, want %q; warnings %s", c.env, got, c.want, warnings(cfg))358 }359 }360}