This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
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)
}
}