package appconfig import ( "encoding/json" "flag" "os" "path/filepath" "strings" "testing" ) func write(t *testing.T, dir, name, body string) string { t.Helper() p := filepath.Join(dir, name) if err := os.WriteFile(p, []byte(body), 0o600); err != nil { t.Fatal(err) } return p } func TestLoadMissingFileIsNotAnError(t *testing.T) { f, err := Load(filepath.Join(t.TempDir(), "nope.json")) if err != nil { t.Fatalf("missing file must not error, got %v", err) } if f != nil { t.Fatalf("missing file must yield nil config, got %+v", f) } } func TestLoadRejectsBadJSON(t *testing.T) { dir := t.TempDir() for name, body := range map[string]string{ "truncated.json": `{"addr": "0.0.0.0:7500"`, "trailing.json": `{"addr":"a"}{"addr":"b"}`, "garbage.json": `not json at all`, } { p := write(t, dir, name, body) if _, err := Load(p); err == nil { t.Errorf("%s: expected a loud parse error, got nil", name) } } } func TestLoadRejectsUnknownKey(t *testing.T) { // A typo like "passwd" must not silently leave the password at its default. p := write(t, t.TempDir(), "c.json", `{"passwd": "x"}`) if _, err := Load(p); err == nil { t.Fatal("unknown key must be rejected") } } func TestLoadExpandsEnv(t *testing.T) { t.Setenv("W4F_TEST_PASSWORD", "s3cret") p := write(t, t.TempDir(), "c.json", `{"password": "${W4F_TEST_PASSWORD}", "user": "u"}`) f, err := Load(p) if err != nil { t.Fatal(err) } if f.Password != "s3cret" { t.Fatalf("password not expanded: %q", f.Password) } } func TestLoadUndefinedEnvIsAnError(t *testing.T) { // Secrets are normally behind ${VAR}; an unset var must fail instead of // booting with an empty password. p := write(t, t.TempDir(), "c.json", `{"password": "${W4F_DEFINITELY_UNSET}"}`) os.Unsetenv("W4F_DEFINITELY_UNSET") _, err := Load(p) if err == nil { t.Fatal("undefined env var must error") } if !strings.Contains(err.Error(), "W4F_DEFINITELY_UNSET") { t.Fatalf("error should name the variable: %v", err) } } func TestLoadFileReference(t *testing.T) { dir := t.TempDir() secret := write(t, dir, "pw", "from-file\nignored-second-line\n") p := write(t, dir, "c.json", `{"password": "file:`+secret+`"}`) f, err := Load(p) if err != nil { t.Fatal(err) } if f.Password != "from-file" { t.Fatalf("file: reference = %q, want first line only", f.Password) } } func TestLoadMissingFileReferenceIsAnError(t *testing.T) { p := write(t, t.TempDir(), "c.json", `{"password": "file:/nonexistent/secret"}`) if _, err := Load(p); err == nil { t.Fatal("unreadable file: reference must error") } } func TestLoadJoinKeyAlias(t *testing.T) { p := write(t, t.TempDir(), "c.json", `{"joinKey": "k1"}`) f, err := Load(p) if err != nil { t.Fatal(err) } if f.JoinKey != "k1" { t.Fatalf("joinKey alias not honoured: %q", f.JoinKey) } } func TestLoadPeersExpanded(t *testing.T) { t.Setenv("W4F_TEST_HOST", "10.0.0.2") p := write(t, t.TempDir(), "c.json", `{"peers": ["${W4F_TEST_HOST}:7500", "10.0.0.3:7500"]}`) f, err := Load(p) if err != nil { t.Fatal(err) } if len(f.Peers) != 2 || f.Peers[0] != "10.0.0.2:7500" || f.Peers[1] != "10.0.0.3:7500" { t.Fatalf("peers = %v", f.Peers) } } // parseFlags parses args on a fresh FlagSet with the same defaults as main, // so ExplicitSet sees exactly what the operator typed. func parseFlags(t *testing.T, args ...string) (Values, map[string]bool) { t.Helper() fs := flag.NewFlagSet("test", flag.ContinueOnError) addr := fs.String("addr", "127.0.0.1:7500", "") user := fs.String("user", "admin", "") pass := fs.String("password", "admin", "") workdir := fs.String("workdir", "./webui-frpc", "") bin := fs.String("bin", "", "") frpc := fs.String("frpc", "", "") joinKey := fs.String("join-key", "", "") var peers multi fs.Var(&peers, "peer", "") if err := fs.Parse(args); err != nil { t.Fatal(err) } return Values{ Addr: *addr, User: *user, Password: *pass, WorkDir: *workdir, Bin: *bin, FRPC: *frpc, Peers: peers, JoinKey: *joinKey, }, ExplicitSet(fs) } type multi []string func (m *multi) String() string { return strings.Join(*m, ",") } func (m *multi) Set(v string) error { *m = append(*m, v); return nil } var defs = Defaults{Addr: "127.0.0.1:7500", User: "admin", Password: "admin", WorkDir: "./webui-frpc"} // This is THE regression test for the task's main pitfall: with no flags // passed, Go's flag defaults must not win over the config file. func TestResolveFileBeatsFlagDefaults(t *testing.T) { flags, explicit := parseFlags(t) // no args at all f := &File{Addr: "0.0.0.0:7500", User: "jianf", Password: "pw", WorkDir: "/var/lib/webui4frpc", Bin: "/opt/frpc", FRPC: "/opt/frpc2", Peers: []string{"1.2.3.4:7500"}, JoinKey: "k"} v, src := Resolve(defs, flags, explicit, f) if v.Addr != "0.0.0.0:7500" || v.User != "jianf" || v.Password != "pw" || v.WorkDir != "/var/lib/webui4frpc" { t.Fatalf("file values lost to flag defaults: %+v", v) } if v.Bin != "/opt/frpc" || v.FRPC != "/opt/frpc2" || v.JoinKey != "k" || len(v.Peers) != 1 { t.Fatalf("file optional values lost: %+v", v) } for name, got := range map[string]string{"addr": src.Addr, "user": src.User, "password": src.Password, "workdir": src.WorkDir} { if got != SourceFile { t.Errorf("%s source = %q, want file", name, got) } } } func TestResolveExplicitFlagBeatsFile(t *testing.T) { flags, explicit := parseFlags(t, "-addr", "127.0.0.1:9000", "-password", "cli-pw") f := &File{Addr: "0.0.0.0:7500", Password: "file-pw"} v, src := Resolve(defs, flags, explicit, f) if v.Addr != "127.0.0.1:9000" { t.Fatalf("explicit -addr ignored: %q", v.Addr) } if v.Password != "cli-pw" { t.Fatalf("explicit -password ignored: %q", v.Password) } if src.Addr != SourceFlag || src.Password != SourceFlag { t.Fatalf("sources = %+v", src) } } func TestResolveDefaultsWhenNoFile(t *testing.T) { flags, explicit := parseFlags(t) v, src := Resolve(defs, flags, explicit, nil) if v.Addr != "127.0.0.1:7500" || v.User != "admin" || v.Password != "admin" || v.WorkDir != "./webui-frpc" { t.Fatalf("defaults not applied: %+v", v) } if src.Password != SourceDefault { t.Fatalf("password source = %q, want default", src.Password) } } func TestResolveEmptyFileFieldFallsBackToDefault(t *testing.T) { flags, explicit := parseFlags(t) f := &File{Addr: "0.0.0.0:7500"} // no user/password/workdir v, src := Resolve(defs, flags, explicit, f) if v.User != "admin" || v.Password != "admin" || v.WorkDir != "./webui-frpc" { t.Fatalf("absent file fields must fall back to defaults: %+v", v) } if src.User != SourceDefault { t.Fatalf("user source = %q", src.User) } } func TestResolvePeerFlagReplacesFileList(t *testing.T) { flags, explicit := parseFlags(t, "-peer", "9.9.9.9:7500") f := &File{Peers: []string{"1.1.1.1:7500", "2.2.2.2:7500"}} v, _ := Resolve(defs, flags, explicit, f) if len(v.Peers) != 1 || v.Peers[0] != "9.9.9.9:7500" { t.Fatalf("explicit -peer must replace the file list, got %v", v.Peers) } } // The password and join key must never appear in anything we log. func TestSummaryRedactsSecrets(t *testing.T) { v := Values{ Addr: "0.0.0.0:7500", User: "jianf", Password: "sup3rs3cret", WorkDir: "/var/lib/webui4frpc", JoinKey: "joinkey-value", } for _, s := range []string{v.Summary(), v.String()} { if strings.Contains(s, "sup3rs3cret") { t.Fatalf("password leaked into summary: %s", s) } if strings.Contains(s, "joinkey-value") { t.Fatalf("join key leaked into summary: %s", s) } if !strings.Contains(s, "0.0.0.0:7500") || !strings.Contains(s, "jianf") { t.Fatalf("summary dropped non-secret fields: %s", s) } } // %v must go through String() as well. if strings.Contains(strings.Join([]string{v.Summary()}, ""), "sup3rs3cret") { t.Fatal("leak") } } func TestDefaultPath(t *testing.T) { if got := DefaultPath("/var/lib/webui4frpc"); got != "/var/lib/webui4frpc/config.json" { t.Fatalf("DefaultPath = %q", got) } } // Sample files carry "_comment"/"_optional" documentation keys; they must load // without weakening the unknown-key check for real fields. func TestLoadIgnoresCommentKeys(t *testing.T) { p := write(t, t.TempDir(), "c.json", `{"_comment": ["doc"], "_optional": ["more doc"], "addr": "0.0.0.0:7500"}`) f, err := Load(p) if err != nil { t.Fatalf("underscore comment keys must be ignored, got %v", err) } if f.Addr != "0.0.0.0:7500" { t.Fatalf("addr = %q", f.Addr) } } // The shipped example must actually be loadable, otherwise the documented // "copy this file" path fails at first start. It references a secrets file that // does not exist in a test environment, so only the schema is checked here by // rewriting the password to a literal. func TestShippedSampleConfigParses(t *testing.T) { raw, err := os.ReadFile("../../packaging/common/config.json") if err != nil { t.Skipf("sample not found: %v", err) } var probe map[string]any if err := json.Unmarshal(raw, &probe); err != nil { t.Fatalf("sample config is not valid JSON: %v", err) } // Remove the file: reference (no /etc secrets in tests) and reload through // the real loader so schema drift is caught. probe["password"] = "literal" dir := t.TempDir() p := filepath.Join(dir, "config.json") out, _ := json.Marshal(probe) if err := os.WriteFile(p, out, 0o600); err != nil { t.Fatal(err) } if _, err := Load(p); err != nil { t.Fatalf("shipped sample config rejected: %v", err) } } // -join-key's flag default comes from $W4F_JOIN_KEY; when the operator does not // type the flag, that environment value must still win over the config file // (it is the more specific, per-process setting). func TestResolveJoinKeyEnvBeatsFile(t *testing.T) { flags, explicit := parseFlags(t) // not passed on the command line flags.JoinKey = "env-key" v, src := Resolve(defs, flags, explicit, &File{JoinKey: "file-key"}) if v.JoinKey != "env-key" { t.Fatalf("env join key lost: %q", v.JoinKey) } if src.JoinKey != SourceEnv { t.Fatalf("source = %q, want env", src.JoinKey) } // Explicit flag still wins over both. flags, explicit = parseFlags(t, "-join-key", "cli-key") flags.JoinKey = "cli-key" v, _ = Resolve(defs, flags, explicit, &File{JoinKey: "file-key"}) if v.JoinKey != "cli-key" { t.Fatalf("explicit -join-key ignored: %q", v.JoinKey) } // No env, no flag -> the file value is used. flags, explicit = parseFlags(t) v, src = Resolve(defs, flags, explicit, &File{JoinKey: "file-key"}) if v.JoinKey != "file-key" || src.JoinKey != SourceFile { t.Fatalf("file join key not used: %q (%s)", v.JoinKey, src.JoinKey) } }