| package config |
|
|
| import ( |
| "encoding/base64" |
| "errors" |
| "os" |
| "strings" |
| "testing" |
| ) |
|
|
| func TestAccountIdentifierRequiresEmailOrMobile(t *testing.T) { |
| acc := Account{Token: "example-token-value"} |
| id := acc.Identifier() |
| if id != "" { |
| t.Fatalf("expected empty identifier when only token is present, got %q", id) |
| } |
| } |
|
|
| func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "keys":["k1"], |
| "accounts":[{"email":"u@example.com","password":"p","token":"token-only-account"}] |
| }`) |
|
|
| store := LoadStore() |
| accounts := store.Accounts() |
| if len(accounts) != 1 { |
| t.Fatalf("expected 1 account, got %d", len(accounts)) |
| } |
| if accounts[0].Token != "" { |
| t.Fatalf("expected token to be cleared after loading, got %q", accounts[0].Token) |
| } |
| } |
|
|
| func TestLoadStorePreservesProxiesAndAccountProxyAssignment(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "proxies":[ |
| { |
| "id":"proxy-sh-1", |
| "name":"Shanghai Exit", |
| "type":"socks5h", |
| "host":"127.0.0.1", |
| "port":1080, |
| "username":"demo", |
| "password":"secret" |
| } |
| ], |
| "accounts":[ |
| { |
| "email":"u@example.com", |
| "password":"p", |
| "proxy_id":"proxy-sh-1" |
| } |
| ] |
| }`) |
|
|
| store := LoadStore() |
| snap := store.Snapshot() |
| if len(snap.Proxies) != 1 { |
| t.Fatalf("expected 1 proxy, got %d", len(snap.Proxies)) |
| } |
| if snap.Proxies[0].ID != "proxy-sh-1" { |
| t.Fatalf("unexpected proxy id: %#v", snap.Proxies[0]) |
| } |
| if snap.Proxies[0].Type != "socks5h" { |
| t.Fatalf("unexpected proxy type: %#v", snap.Proxies[0]) |
| } |
| if len(snap.Accounts) != 1 { |
| t.Fatalf("expected 1 account, got %d", len(snap.Accounts)) |
| } |
| if snap.Accounts[0].ProxyID != "proxy-sh-1" { |
| t.Fatalf("expected account proxy assignment preserved, got %#v", snap.Accounts[0]) |
| } |
| } |
|
|
| func TestLoadStoreDropsLegacyTokenOnlyAccounts(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "accounts":[ |
| {"token":"legacy-token-only"}, |
| {"email":"u@example.com","password":"p","token":"runtime-token"} |
| ] |
| }`) |
|
|
| store := LoadStore() |
| accounts := store.Accounts() |
| if len(accounts) != 1 { |
| t.Fatalf("expected token-only account to be dropped, got %d accounts", len(accounts)) |
| } |
| if accounts[0].Identifier() != "u@example.com" { |
| t.Fatalf("unexpected remaining account: %#v", accounts[0]) |
| } |
| if accounts[0].Token != "" { |
| t.Fatalf("expected persisted token to be cleared, got %q", accounts[0].Token) |
| } |
| } |
|
|
| func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) { |
| tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") |
| if err != nil { |
| t.Fatalf("create temp config: %v", err) |
| } |
| defer func() { _ = tmp.Close() }() |
| if _, err := tmp.WriteString(`{ |
| "accounts":[{"email":"u@example.com","password":"p","token":"persisted-token"}] |
| }`); err != nil { |
| t.Fatalf("write temp config: %v", err) |
| } |
|
|
| t.Setenv("DS2API_CONFIG_JSON", "") |
| t.Setenv("DS2API_CONFIG_PATH", tmp.Name()) |
|
|
| store := LoadStore() |
| accounts := store.Accounts() |
| if len(accounts) != 1 { |
| t.Fatalf("expected 1 account, got %d", len(accounts)) |
| } |
| if accounts[0].Token != "persisted-token" { |
| t.Fatalf("expected file-backed token preserved for runtime use, got %q", accounts[0].Token) |
| } |
| } |
|
|
| func TestLoadStoreIgnoresLegacyConfigJSONEnv(t *testing.T) { |
| tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") |
| if err != nil { |
| t.Fatalf("create temp config: %v", err) |
| } |
| path := tmp.Name() |
| _ = tmp.Close() |
| _ = os.Remove(path) |
|
|
| t.Setenv("DS2API_CONFIG_JSON", "") |
| t.Setenv("CONFIG_JSON", `{"keys":["legacy-key"],"accounts":[{"email":"legacy@example.com","password":"p"}]}`) |
| t.Setenv("DS2API_CONFIG_PATH", path) |
|
|
| store := LoadStore() |
| if store.HasEnvConfigSource() { |
| t.Fatal("expected legacy CONFIG_JSON to be ignored") |
| } |
| if store.IsEnvBacked() { |
| t.Fatal("expected store to remain file-backed/empty when only CONFIG_JSON is set") |
| } |
| if len(store.Keys()) != 0 || len(store.Accounts()) != 0 { |
| t.Fatalf("expected ignored legacy env to leave store empty, got keys=%d accounts=%d", len(store.Keys()), len(store.Accounts())) |
| } |
| } |
|
|
| func TestExplicitMissingConfigPathBootstrapsEmptyFileBackedStore(t *testing.T) { |
| path := t.TempDir() + "/config.json" |
|
|
| t.Setenv("DS2API_CONFIG_JSON", "") |
| t.Setenv("DS2API_CONFIG_PATH", path) |
|
|
| store, err := LoadStoreWithError() |
| if err != nil { |
| t.Fatalf("expected missing explicit config path to bootstrap, got: %v", err) |
| } |
| if store.IsEnvBacked() { |
| t.Fatal("expected bootstrap store to be file-backed") |
| } |
| if store.ConfigPath() != path { |
| t.Fatalf("ConfigPath() = %q, want %q", store.ConfigPath(), path) |
| } |
| if len(store.Keys()) != 0 || len(store.Accounts()) != 0 { |
| t.Fatalf("expected empty bootstrap config, got keys=%d accounts=%d", len(store.Keys()), len(store.Accounts())) |
| } |
| if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { |
| t.Fatalf("expected bootstrap not to create config until first save, stat err=%v", statErr) |
| } |
|
|
| if err := store.Update(func(c *Config) error { |
| c.Keys = []string{"first-key"} |
| return nil |
| }); err != nil { |
| t.Fatalf("update should persist bootstrap config: %v", err) |
| } |
| content, err := os.ReadFile(path) |
| if err != nil { |
| t.Fatalf("expected first update to write config: %v", err) |
| } |
| if !strings.Contains(string(content), "first-key") { |
| t.Fatalf("expected saved config to contain first key, got: %s", content) |
| } |
| } |
|
|
| func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) { |
| tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") |
| if err != nil { |
| t.Fatalf("create temp config: %v", err) |
| } |
| path := tmp.Name() |
| _ = tmp.Close() |
| _ = os.Remove(path) |
|
|
| t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"seed@example.com","password":"p"}]}`) |
| t.Setenv("DS2API_CONFIG_PATH", path) |
| t.Setenv("DS2API_ENV_WRITEBACK", "1") |
|
|
| store := LoadStore() |
| if store.IsEnvBacked() { |
| t.Fatalf("expected writeback bootstrap to become file-backed immediately") |
| } |
| if err := store.Update(func(c *Config) error { |
| c.Accounts = append(c.Accounts, Account{Email: "new@example.com", Password: "p2"}) |
| return nil |
| }); err != nil { |
| t.Fatalf("update failed: %v", err) |
| } |
| content, err := os.ReadFile(path) |
| if err != nil { |
| t.Fatalf("read written config: %v", err) |
| } |
| if !strings.Contains(string(content), "seed@example.com") { |
| t.Fatalf("expected bootstrapped config to contain seed account, got: %s", content) |
| } |
| if !strings.Contains(string(content), "new@example.com") { |
| t.Fatalf("expected persisted config to contain added account, got: %s", content) |
| } |
|
|
| reloaded := LoadStore() |
| if reloaded.IsEnvBacked() { |
| t.Fatalf("expected reloaded store to prefer persisted config file") |
| } |
| accounts := reloaded.Accounts() |
| if len(accounts) != 2 { |
| t.Fatalf("expected 2 accounts after reload, got %d", len(accounts)) |
| } |
| } |
|
|
| func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidEnvJSON(t *testing.T) { |
| tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") |
| if err != nil { |
| t.Fatalf("create temp config: %v", err) |
| } |
| path := tmp.Name() |
| _ = tmp.Close() |
| _ = os.Remove(path) |
|
|
| t.Setenv("DS2API_CONFIG_JSON", "{invalid-json") |
| t.Setenv("DS2API_CONFIG_PATH", path) |
| t.Setenv("DS2API_ENV_WRITEBACK", "1") |
|
|
| cfg, fromEnv, loadErr := loadConfig() |
| if loadErr == nil { |
| t.Fatalf("expected loadConfig error for invalid env json") |
| } |
| if !fromEnv { |
| t.Fatalf("expected fromEnv=true when parsing env config fails") |
| } |
| if len(cfg.Keys) != 0 || len(cfg.Accounts) != 0 { |
| t.Fatalf("expected empty config on parse failure, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts)) |
| } |
| if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { |
| t.Fatalf("expected no bootstrapped config file, stat err=%v", statErr) |
| } |
| } |
|
|
| func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidSemanticConfig(t *testing.T) { |
| tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") |
| if err != nil { |
| t.Fatalf("create temp config: %v", err) |
| } |
| path := tmp.Name() |
| _ = tmp.Close() |
| _ = os.Remove(path) |
|
|
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "keys":["k1"], |
| "accounts":[{"email":"seed@example.com","password":"p"}], |
| "runtime":{"account_max_inflight":300} |
| }`) |
| t.Setenv("DS2API_CONFIG_PATH", path) |
| t.Setenv("DS2API_ENV_WRITEBACK", "1") |
|
|
| cfg, fromEnv, loadErr := loadConfig() |
| if loadErr == nil { |
| t.Fatalf("expected loadConfig error for invalid runtime config") |
| } |
| if !fromEnv { |
| t.Fatalf("expected fromEnv=true when env config is the source") |
| } |
| if !strings.Contains(loadErr.Error(), "runtime.account_max_inflight") { |
| t.Fatalf("expected runtime validation error, got %v", loadErr) |
| } |
| if len(cfg.Keys) != 1 || len(cfg.Accounts) != 1 { |
| t.Fatalf("expected env config to be parsed before validation, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts)) |
| } |
| if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { |
| t.Fatalf("expected invalid config not to be bootstrapped, stat err=%v", statErr) |
| } |
| } |
|
|
| func TestLoadStoreWithErrorRejectsInvalidRuntimeConfig(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "keys":["k1"], |
| "accounts":[{"email":"u@example.com","password":"p"}], |
| "runtime":{"account_max_inflight":300} |
| }`) |
| t.Setenv("DS2API_ENV_WRITEBACK", "0") |
|
|
| if _, err := LoadStoreWithError(); err == nil { |
| t.Fatal("expected LoadStoreWithError to reject invalid runtime config") |
| } else if !strings.Contains(err.Error(), "runtime.account_max_inflight") { |
| t.Fatalf("expected runtime validation error, got %v", err) |
| } |
| } |
|
|
| func TestEnvBackedStoreWritebackFallsBackToPersistedFileOnInvalidEnvJSON(t *testing.T) { |
| tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") |
| if err != nil { |
| t.Fatalf("create temp config: %v", err) |
| } |
| path := tmp.Name() |
| if _, err := tmp.WriteString(`{"keys":["file-key"],"accounts":[{"email":"persisted@example.com","password":"p"}]}`); err != nil { |
| t.Fatalf("write temp config: %v", err) |
| } |
| _ = tmp.Close() |
|
|
| t.Setenv("DS2API_CONFIG_JSON", "{invalid-json") |
| t.Setenv("DS2API_CONFIG_PATH", path) |
| t.Setenv("DS2API_ENV_WRITEBACK", "1") |
|
|
| cfg, fromEnv, loadErr := loadConfig() |
| if loadErr != nil { |
| t.Fatalf("expected fallback to persisted file, got error: %v", loadErr) |
| } |
| if fromEnv { |
| t.Fatalf("expected fallback to file-backed mode") |
| } |
| if len(cfg.Keys) != 1 || cfg.Keys[0] != "file-key" { |
| t.Fatalf("unexpected keys after fallback: %#v", cfg.Keys) |
| } |
| if len(cfg.Accounts) != 1 || cfg.Accounts[0].Email != "persisted@example.com" { |
| t.Fatalf("unexpected accounts after fallback: %#v", cfg.Accounts) |
| } |
| } |
|
|
| func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "keys":["k1"], |
| "accounts":[{"email":"u@example.com","password":"p"}] |
| }`) |
|
|
| store := LoadStore() |
| if got := store.RuntimeTokenRefreshIntervalHours(); got != 6 { |
| t.Fatalf("expected default refresh interval 6, got %d", got) |
| } |
| } |
|
|
| func TestRuntimeTokenRefreshIntervalHoursUsesConfigValue(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "keys":["k1"], |
| "accounts":[{"email":"u@example.com","password":"p"}], |
| "runtime":{"token_refresh_interval_hours":9} |
| }`) |
|
|
| store := LoadStore() |
| if got := store.RuntimeTokenRefreshIntervalHours(); got != 9 { |
| t.Fatalf("expected configured refresh interval 9, got %d", got) |
| } |
| } |
|
|
| func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{ |
| "accounts":[{"email":"user@example.com","password":"p"}] |
| }`) |
|
|
| store := LoadStore() |
| before := store.Accounts() |
| if len(before) != 1 { |
| t.Fatalf("expected 1 account, got %d", len(before)) |
| } |
| oldID := before[0].Identifier() |
| if err := store.UpdateAccountToken(oldID, "new-token"); err != nil { |
| t.Fatalf("update token failed: %v", err) |
| } |
|
|
| if got, ok := store.FindAccount(oldID); !ok || got.Token != "new-token" { |
| t.Fatalf("expected find by stable account identifier") |
| } |
| } |
|
|
| func TestLoadStoreRejectsInvalidFieldType(t *testing.T) { |
| t.Setenv("DS2API_CONFIG_JSON", `{"keys":"not-array","accounts":[]}`) |
| store := LoadStore() |
| if len(store.Keys()) != 0 || len(store.Accounts()) != 0 { |
| t.Fatalf("expected empty store when config type is invalid") |
| } |
| } |
|
|
| func TestParseConfigStringSupportsQuotedBase64Prefix(t *testing.T) { |
| rawJSON := `{"keys":["k1"],"accounts":[{"email":"u@example.com","password":"p"}]}` |
| b64 := base64.StdEncoding.EncodeToString([]byte(rawJSON)) |
| cfg, err := parseConfigString(`"base64:` + b64 + `"`) |
| if err != nil { |
| t.Fatalf("unexpected parse error: %v", err) |
| } |
| if len(cfg.Keys) != 1 || cfg.Keys[0] != "k1" { |
| t.Fatalf("unexpected keys: %#v", cfg.Keys) |
| } |
| } |
|
|
| func TestParseConfigStringSupportsRawURLBase64(t *testing.T) { |
| rawJSON := `{"keys":["k-url"],"accounts":[]}` |
| b64 := base64.RawURLEncoding.EncodeToString([]byte(rawJSON)) |
| cfg, err := parseConfigString(b64) |
| if err != nil { |
| t.Fatalf("unexpected parse error: %v", err) |
| } |
| if len(cfg.Keys) != 1 || cfg.Keys[0] != "k-url" { |
| t.Fatalf("unexpected keys: %#v", cfg.Keys) |
| } |
| } |
|
|
| func TestLoadConfigOnVercelWithoutConfigFileFallsBackToMemory(t *testing.T) { |
| t.Setenv("VERCEL", "1") |
| t.Setenv("DS2API_CONFIG_JSON", "") |
| t.Setenv("DS2API_CONFIG_PATH", "testdata/does-not-exist.json") |
|
|
| cfg, fromEnv, err := loadConfig() |
| if err != nil { |
| t.Fatalf("unexpected error: %v", err) |
| } |
| if !fromEnv { |
| t.Fatalf("expected fromEnv=true for vercel fallback") |
| } |
| if len(cfg.Keys) != 0 || len(cfg.Accounts) != 0 { |
| t.Fatalf("expected empty bootstrap config, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts)) |
| } |
| } |
|
|
| func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) { |
| tmp, err := os.CreateTemp(t.TempDir(), "config-*.json") |
| if err != nil { |
| t.Fatalf("create temp config: %v", err) |
| } |
| defer func() { _ = tmp.Close() }() |
| if _, err := tmp.WriteString(`{ |
| "accounts":[{"email":"u@example.com","password":"p","test_status":"ok"}] |
| }`); err != nil { |
| t.Fatalf("write temp config: %v", err) |
| } |
|
|
| t.Setenv("DS2API_CONFIG_JSON", "") |
| t.Setenv("DS2API_CONFIG_PATH", tmp.Name()) |
|
|
| store := LoadStore() |
| if got, ok := store.AccountTestStatus("u@example.com"); ok || got != "" { |
| t.Fatalf("expected no runtime status loaded from config, got %q", got) |
| } |
| if err := store.UpdateAccountTestStatus("u@example.com", "ok"); err != nil { |
| t.Fatalf("update test status: %v", err) |
| } |
| if got, ok := store.AccountTestStatus("u@example.com"); !ok || got != "ok" { |
| t.Fatalf("expected runtime status to be available, got %q (ok=%v)", got, ok) |
| } |
|
|
| content, err := os.ReadFile(tmp.Name()) |
| if err != nil { |
| t.Fatalf("read config: %v", err) |
| } |
| if strings.Contains(string(content), "test_status") { |
| t.Fatalf("expected test_status to stay out of persisted config, got: %s", content) |
| } |
| } |
|
|