package clicmd import ( "context" "io" "os" "path/filepath" "strings" "testing" "github.com/iceBear67/simplepages/internal/cliutil" ) // clearEnv makes a test independent of whatever the developer has exported. func clearEnv(t *testing.T) { t.Helper() for _, k := range []string{ "PAGES_SERVER", "PAGES_TOKEN", "PAGES_TOKEN_FILE", "PAGES_PROJECT", "PAGES_OUTPUT", "PAGES_TIMEOUT", "PAGES_CONFIG", } { t.Setenv(k, "") } } // writeConfig returns the path to a config file holding f. func writeConfig(t *testing.T, f ConfigFile) string { t.Helper() path := filepath.Join(t.TempDir(), "config.json") if err := SaveConfig(path, f); err != nil { t.Fatal(err) } return path } // newTestGlobals is what NewGlobals would give a command, with the streams // captured and the config file pinned so no real one can interfere. func newTestGlobals(config string) (*Globals, *strings.Builder, *strings.Builder) { var out, errOut strings.Builder return &Globals{In: strings.NewReader(""), Out: &out, Err: &errOut, Config: config}, &out, &errOut } // TestPrecedence is the rule stated in the help text: flag, then environment, // then config file, then default. It is easy to get subtly wrong, and wrong // here means a CI job deploying to the wrong server. func TestPrecedence(t *testing.T) { file := ConfigFile{Server: "https://file.example.com", Project: "fileproj", Output: "json"} t.Run("flag wins", func(t *testing.T) { clearEnv(t) t.Setenv("PAGES_SERVER", "https://env.example.com") t.Setenv("PAGES_PROJECT", "envproj") g, _, _ := newTestGlobals(writeConfig(t, file)) g.Server, g.Project = "https://flag.example.com", "flagproj" if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Server != "https://flag.example.com" || g.Project != "flagproj" { t.Errorf("server=%q project=%q, want the flag values", g.Server, g.Project) } }) t.Run("environment beats the config file", func(t *testing.T) { clearEnv(t) t.Setenv("PAGES_SERVER", "https://env.example.com") t.Setenv("PAGES_PROJECT", "envproj") g, _, _ := newTestGlobals(writeConfig(t, file)) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Server != "https://env.example.com" || g.Project != "envproj" { t.Errorf("server=%q project=%q, want the environment values", g.Server, g.Project) } }) t.Run("the config file is the last word before defaults", func(t *testing.T) { clearEnv(t) g, _, _ := newTestGlobals(writeConfig(t, file)) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Server != "https://file.example.com" || g.Project != "fileproj" || g.Output != "json" { t.Errorf("server=%q project=%q output=%q, want the file values", g.Server, g.Project, g.Output) } }) t.Run("defaults fill the rest", func(t *testing.T) { clearEnv(t) g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Output != cliutil.FormatTable { t.Errorf("output = %q, want %q", g.Output, cliutil.FormatTable) } if g.Timeout <= 0 { t.Errorf("timeout = %v, want a positive default", g.Timeout) } }) } // TestFlagsSurviveTheSubcommandParse covers the trap that makes this design // work: every global flag is re-registered on each subcommand's FlagSet, and // registering with a fixed default would wipe a value given before the // subcommand name. func TestFlagsSurviveTheSubcommandParse(t *testing.T) { clearEnv(t) cfg := writeConfig(t, ConfigFile{}) for _, args := range [][]string{ {"--server", "https://flag.example.com", "--config", cfg, "probe", "demo"}, {"probe", "--server", "https://flag.example.com", "--config", cfg, "demo"}, {"--server", "https://flag.example.com", "probe", "demo", "--config", cfg}, } { t.Run(strings.Join(args, " "), func(t *testing.T) { g, _, _ := newTestGlobals("") var gotProject string root := &cliutil.Command{ Name: "pages", Sub: []*cliutil.Command{{ Name: "probe", Exec: func(ctx context.Context, args []string) error { if err := g.Resolve(); err != nil { return err } if len(args) == 1 { gotProject = args[0] } return nil }, }}, } if err := cliutil.Run(context.Background(), root, args, io.Discard, g.Register); err != nil { t.Fatalf("Run: %v", err) } if g.Server != "https://flag.example.com" { t.Errorf("server = %q, want the flag to survive dispatch", g.Server) } if gotProject != "demo" { t.Errorf("positional = %q, want demo", gotProject) } }) } } func TestTokenPrecedence(t *testing.T) { const ( flagTok = "pgs_flagflagflagfl_secret" flagFileTok = "pgs_flagfileflagfi_secret" envTok = "pgs_envenvenvenven_secret" envFileTok = "pgs_envfileenvfile_secret" fileTok = "pgs_fileconfigfile_secret" ) tokenFile := func(t *testing.T, content string) string { t.Helper() p := filepath.Join(t.TempDir(), "token") if err := os.WriteFile(p, []byte(content), 0o600); err != nil { t.Fatal(err) } return p } t.Run("--token wins but warns", func(t *testing.T) { clearEnv(t) t.Setenv("PAGES_TOKEN", envTok) g, _, errOut := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok})) g.Token = flagTok g.TokenFile = tokenFile(t, flagFileTok) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Token != flagTok { t.Errorf("token = %q, want the flag", g.Token) } // argv is world-readable through /proc on a shared runner, so this // warning is the whole reason --token is documented as a last resort. if !strings.Contains(errOut.String(), "process list") { t.Errorf("stderr = %q, want a warning about the process list", errOut.String()) } if strings.Contains(errOut.String(), "secret") { t.Error("the warning printed the token") } }) t.Run("--token-file beats the environment", func(t *testing.T) { clearEnv(t) t.Setenv("PAGES_TOKEN", envTok) g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok})) g.TokenFile = tokenFile(t, flagFileTok) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Token != flagFileTok { t.Errorf("token = %q, want the one from --token-file", g.Token) } }) t.Run("PAGES_TOKEN beats PAGES_TOKEN_FILE", func(t *testing.T) { clearEnv(t) t.Setenv("PAGES_TOKEN", envTok) t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok)) g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok})) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Token != envTok { t.Errorf("token = %q, want PAGES_TOKEN", g.Token) } }) t.Run("PAGES_TOKEN_FILE beats the config file", func(t *testing.T) { clearEnv(t) t.Setenv("PAGES_TOKEN_FILE", tokenFile(t, envFileTok)) g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok})) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Token != envFileTok { t.Errorf("token = %q, want PAGES_TOKEN_FILE", g.Token) } }) t.Run("the config file is the fallback", func(t *testing.T) { clearEnv(t) g, _, _ := newTestGlobals(writeConfig(t, ConfigFile{Token: fileTok})) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Token != fileTok { t.Errorf("token = %q, want the config file's", g.Token) } }) } // TestTokenFileTrimsTrailingNewline: `pages key create ... > token` and every // text editor add one. func TestTokenFileTrimsTrailingNewline(t *testing.T) { clearEnv(t) p := filepath.Join(t.TempDir(), "token") os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret\n"), 0o600) g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) g.TokenFile = p if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Token != "pgs_abcdefghijklmnop_secret" { t.Errorf("token = %q, want the newline trimmed", g.Token) } } func TestTokenFileProblemsAreReported(t *testing.T) { t.Run("missing", func(t *testing.T) { clearEnv(t) g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) g.TokenFile = filepath.Join(t.TempDir(), "nope") if err := g.Resolve(); err == nil { t.Fatal("expected an error") } }) t.Run("empty", func(t *testing.T) { clearEnv(t) p := filepath.Join(t.TempDir(), "token") os.WriteFile(p, []byte("\n\n"), 0o600) g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) g.TokenFile = p err := g.Resolve() if err == nil || !strings.Contains(err.Error(), "empty") { t.Fatalf("err = %v, want it to say the file is empty", err) } }) t.Run("world readable warns", func(t *testing.T) { clearEnv(t) p := filepath.Join(t.TempDir(), "token") os.WriteFile(p, []byte("pgs_abcdefghijklmnop_secret"), 0o644) g, _, errOut := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) g.TokenFile = p if err := g.Resolve(); err != nil { t.Fatal(err) } if !strings.Contains(errOut.String(), "chmod 600") { t.Errorf("stderr = %q, want a mode warning", errOut.String()) } }) } func TestResolveRejectsUnknownOutputFormat(t *testing.T) { clearEnv(t) g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) g.Output = "yaml" err := g.Resolve() if err == nil || !strings.Contains(err.Error(), "table or json") { t.Fatalf("err = %v, want it to list the formats", err) } } func TestResolveParsesFriendlyTimeouts(t *testing.T) { clearEnv(t) t.Setenv("PAGES_TIMEOUT", "2m") g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) if err := g.Resolve(); err != nil { t.Fatal(err) } if g.Timeout.Minutes() != 2 { t.Errorf("timeout = %v, want 2m", g.Timeout) } clearEnv(t) t.Setenv("PAGES_TIMEOUT", "later") g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) err := g2.Resolve() if err == nil || !strings.Contains(err.Error(), "PAGES_TIMEOUT") { t.Fatalf("err = %v, want it to name the variable", err) } } func TestProjectNameExplainsHowToSetIt(t *testing.T) { clearEnv(t) g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) _, err := g.ProjectName() if err == nil || !strings.Contains(err.Error(), "PAGES_PROJECT") { t.Fatalf("err = %v, want it to name the flag and the variable", err) } } // TestClientNeedsServerAndToken: the two settings a fresh CI job forgets. func TestClientNeedsServerAndToken(t *testing.T) { clearEnv(t) g, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) if _, err := g.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_SERVER") { t.Fatalf("err = %v, want it to name PAGES_SERVER", err) } clearEnv(t) g2, _, _ := newTestGlobals(filepath.Join(t.TempDir(), "absent.json")) g2.Server = "https://p.example.com" if _, err := g2.Client(); err == nil || !strings.Contains(err.Error(), "PAGES_TOKEN") { t.Fatalf("err = %v, want it to name PAGES_TOKEN", err) } }