package clicmd import ( "io" "os" "path/filepath" "strings" "testing" ) func TestSaveConfigIsOwnerOnlyAndAtomic(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested") path := filepath.Join(dir, "config.json") want := ConfigFile{Server: "https://p.example.com", Token: "pgs_abcdefghijklmnop_secret", Output: "json"} if err := SaveConfig(path, want); err != nil { t.Fatalf("SaveConfig: %v", err) } fi, err := os.Stat(path) if err != nil { t.Fatal(err) } if perm := fi.Mode().Perm(); perm != 0o600 { t.Errorf("config mode = %#o, want 0600 — it holds a token", perm) } if di, err := os.Stat(dir); err == nil { if perm := di.Mode().Perm(); perm&0o077 != 0 { t.Errorf("config directory mode = %#o, want no group or other bits", perm) } } got, err := LoadConfig(path, io.Discard) if err != nil { t.Fatalf("LoadConfig: %v", err) } if got != want { t.Errorf("round trip = %+v, want %+v", got, want) } // The temporary file is written in the destination directory; leaving one // behind would leave a mode-0600 copy of the token lying around. ents, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(ents) != 1 || ents[0].Name() != "config.json" { names := make([]string, len(ents)) for i, e := range ents { names[i] = e.Name() } t.Errorf("directory contains %q, want only config.json", names) } } func TestSaveConfigReplacesInPlace(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := SaveConfig(path, ConfigFile{Server: "https://one.example.com"}); err != nil { t.Fatal(err) } if err := SaveConfig(path, ConfigFile{Server: "https://two.example.com"}); err != nil { t.Fatal(err) } got, err := LoadConfig(path, io.Discard) if err != nil { t.Fatal(err) } if got.Server != "https://two.example.com" { t.Errorf("server = %q, want the second write", got.Server) } } func TestLoadConfig(t *testing.T) { t.Run("a missing file is the normal state on CI", func(t *testing.T) { got, err := LoadConfig(filepath.Join(t.TempDir(), "absent.json"), io.Discard) if err != nil { t.Fatalf("err = %v, want nil", err) } if got != (ConfigFile{}) { t.Errorf("got %+v, want the zero value", got) } }) t.Run("an empty path means there is no config file", func(t *testing.T) { if _, err := LoadConfig("", io.Discard); err != nil { t.Fatalf("err = %v, want nil", err) } }) t.Run("malformed JSON is reported, not ignored", func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") os.WriteFile(path, []byte("{not json"), 0o600) if _, err := LoadConfig(path, io.Discard); err == nil { t.Fatal("expected an error") } }) t.Run("a readable-by-others config warns", func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") os.WriteFile(path, []byte(`{"token":"pgs_abcdefghijklmnop_secret"}`), 0o644) var warn strings.Builder if _, err := LoadConfig(path, &warn); err != nil { t.Fatal(err) } if !strings.Contains(warn.String(), "chmod 600") { t.Errorf("warning = %q, want it to say how to fix the mode", warn.String()) } if strings.Contains(warn.String(), "secret") { t.Error("the warning printed the token it was warning about") } }) } func TestRedactToken(t *testing.T) { cases := []struct{ in, want string }{ {"pgs_abcdefghijklmnop_thesecrethalf", "pgs_abcdefghijklmnop_…"}, {"", ""}, {"garbage", "…"}, {"pgs_onlytwo", "…"}, // The secret half is base64url, so it can itself contain underscores; // SplitN with n=3 keeps them in the part that gets dropped. {"pgs_abcdefghijklmnop_a_b_c", "pgs_abcdefghijklmnop_…"}, } for _, tc := range cases { if got := redactToken(tc.in); got != tc.want { t.Errorf("redactToken(%q) = %q, want %q", tc.in, got, tc.want) } } } func TestDefaultConfigPathPrefersEnv(t *testing.T) { t.Setenv("PAGES_CONFIG", "/tmp/explicit.json") if got := DefaultConfigPath(); got != "/tmp/explicit.json" { t.Errorf("DefaultConfigPath = %q", got) } }