diff --git a/cmd/configure.go b/cmd/configure.go index 1394bbf13d..439ce4b1ba 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -279,23 +279,28 @@ func NewCmdConfig() *cobra.Command { cacheConfig.Zone = cfg.Zone } - //确保设置的Region和Zone真实存在 + //确保设置的Region和Zone真实存在。校验失败即整体放弃、不落盘: + //此前用 else 保留原值后仍照常写盘,与 add/update 的 fail-closed 口径不一致 region, zone, err := getReasonableRegionZone(cacheConfig) if err != nil { platform.HandleError(fmt.Errorf("verify region failed: %v", err)) - } else { - cacheConfig.Region = region - cacheConfig.Zone = zone + return } + cacheConfig.Region = region + cacheConfig.Zone = zone //如果用户填写的project和配置文件中该配置的project均为空,则调接口拉取默认project //如果用户填写的project不为空,则校验其是否真实存在; if cfg.ProjectID == "" { if cacheConfig.ProjectID == "" { + //此处直接调用、未经 %v 包装,sentinel 链完整:errNoDefaultProject + //属良性缺失,放行并留空 ProjectID,口径与 ucloud init 一致 id, _, err := getDefaultProjectWithConfig(cacheConfig) - if err != nil { + if err != nil && !errors.Is(err, errNoDefaultProject) { platform.HandleError(fmt.Errorf("fetch default project failed: %v", err)) - } else { + return + } + if err == nil { cacheConfig.ProjectID = id } } @@ -303,17 +308,19 @@ func NewCmdConfig() *cobra.Command { cfg.ProjectID = platform.PickResourceID(cfg.ProjectID) projects, err := fetchProjectWithConfig(cacheConfig) if err != nil { - cacheConfig.ProjectID = cfg.ProjectID - } else { - if ok := projects[cfg.ProjectID]; ok { - cacheConfig.ProjectID = cfg.ProjectID - } else { - platform.HandleError(fmt.Errorf("project %s you assigned not exists", cfg.ProjectID)) - if ok := projects[cacheConfig.ProjectID]; !ok { - platform.HandleError(fmt.Errorf("project %s not exists, assign another one please", cacheConfig.ProjectID)) - } + //远程不可达时此前直接采信用户输入并落盘,等于写入未经校验的 project; + //现与其余路径一致:拒绝 + platform.HandleError(fmt.Errorf("fetch project failed: %v", err)) + return + } + if ok := projects[cfg.ProjectID]; !ok { + platform.HandleError(fmt.Errorf("project %s you assigned not exists", cfg.ProjectID)) + if ok := projects[cacheConfig.ProjectID]; !ok { + platform.HandleError(fmt.Errorf("project %s not exists, assign another one please", cacheConfig.ProjectID)) } + return } + cacheConfig.ProjectID = cfg.ProjectID } if active != "" { @@ -382,23 +389,39 @@ func NewCmdConfigAdd() *cobra.Command { Short: "add configuration", Long: "add configuration", Run: func(c *cobra.Command, args []string) { + //本地约束先于远程校验(与主命令一致):timeout 是纯本地检查,若排在远程校验 + //之后,坏 timeout 会先被喂进校验请求(0 → 无超时、负值 → 请求瞬败),用户 + //看到的是 region 错、而非真正的 timeout 错,且白打一次网络 + if cfg.Timeout <= 0 { + platform.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) + return + } + + //远程校验失败即整体放弃,不得落盘:否则 profile 会带着被抹空的 region/zone + //建成,且 --active true 时还会把原有 active profile 顶掉 region, zone, err := getReasonableRegionZone(cfg) if err != nil { platform.HandleError(err) + return } cfg.Region = region cfg.Zone = zone - project, err := getReasonableProject(cfg) - if err != nil { - platform.HandleError(err) + //归一化 project-id:补全吐出的是 org-xxx/Name 形式(见 getProjectList), + //与主命令/update 一致剥成裸 id。否则合法的补全值会被 getReasonableProject + //判为「project does not exist」——叠加 fail-closed 会硬拦一个真实存在的 project + if cfg.ProjectID != "" { + cfg.ProjectID = platform.PickResourceID(cfg.ProjectID) } - cfg.ProjectID = project - if cfg.Timeout <= 0 { - platform.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) + //errNoDefaultProject 是良性缺失(账号有项目但未设默认),放行并留空 ProjectID, + //口径与 ucloud init 一致;其余错误一律拒绝落盘 + project, err := getReasonableProject(cfg) + if err != nil && !errors.Is(err, errNoDefaultProject) { + platform.HandleError(err) return } + cfg.ProjectID = project if active == "true" { cfg.Active = true @@ -471,29 +494,33 @@ func NewCmdConfigUpdate() *cobra.Command { return } + //GetAggConfigByProfile 返回的是 manager map 内条目本身。改动一律先落在副本上, + //校验全部通过后才交给 UpdateAggConfig 写回,任一步失败都不污染内存态、更不落盘。 + //这也堵死了 OAuth token 刷新 handler 持 manager 写回时把半成品一并 Save 的路径。 + //注意 MaxRetryTimes 是 *int,浅拷贝与原对象共享该指针:下方只做整体替换, + //不得改成 *draft.MaxRetryTimes = x 的原地写。 + draft := *cacheConfig + + //AK/SK 只写内存即可供下方远程校验使用:BuildClientRuntime 完全从传入的 + //AggConfig 构造 sdk.Config 与 credential,不读磁盘,故无需先行落盘。 if cfg.PrivateKey != "" { - cacheConfig.PrivateKey = cfg.PrivateKey + draft.PrivateKey = cfg.PrivateKey } if cfg.PublicKey != "" { - cacheConfig.PublicKey = cfg.PublicKey - } - - //如果配置了公私钥,则先更新让其生效, 为接下来拉取Region,Zone做准备 - if cfg.PrivateKey != "" || cfg.PublicKey != "" { - platform.AggConfigListIns.UpdateAggConfig(cacheConfig) + draft.PublicKey = cfg.PublicKey } //先应用连接类参数(base-url/channel-key/timeout-sec/max-retry-times),确保接下来的远程校验 //打到新网关而不是旧的(可能已不可用的)网关,避免旧base-url坏掉后无法改回的死锁 if cfg.BaseURL != "" { - cacheConfig.BaseURL = cfg.BaseURL + draft.BaseURL = cfg.BaseURL } //channel-key 同属连接类参数:专属云 profile 的远程校验请求必须带上它, //否则网关报 174 而校验失败,profile 永远配不上。 //Changed() 使 --channel-key "" 可清除(专属云切回主站的场景)。 if c.Flags().Changed("channel-key") { - cacheConfig.ChannelKey = cfg.ChannelKey + draft.ChannelKey = cfg.ChannelKey } if timeout != "" { @@ -502,11 +529,13 @@ func NewCmdConfigUpdate() *cobra.Command { platform.HandleError(fmt.Errorf("parse timeout-sec failed: %v", err)) return } - cacheConfig.Timeout = seconds + draft.Timeout = seconds } - if cacheConfig.Timeout <= 0 { - platform.HandleError(fmt.Errorf("timeout-sec must be greater than 0, accept %d", cfg.Timeout)) + //报告被检查的那个值:cfg.Timeout 是本命令从不赋值的字段(flag 绑定的是 + //局部变量 timeout),报它恒为 0,与实际被拒的值无关 + if draft.Timeout <= 0 { + platform.HandleError(fmt.Errorf("timeout-sec must be greater than 0, accept %d", draft.Timeout)) return } @@ -516,54 +545,59 @@ func NewCmdConfigUpdate() *cobra.Command { platform.HandleError(fmt.Errorf("parse max-retry-times failed: %v", err)) return } - cacheConfig.MaxRetryTimes = × + draft.MaxRetryTimes = × } - if *cacheConfig.MaxRetryTimes < 0 { - platform.HandleError(fmt.Errorf("max-retry-timesc must be greater than or equal to 0, accept %d", cfg.MaxRetryTimes)) + //同上,且更隐蔽:cfg.MaxRetryTimes 是本命令从不赋值的 *int, + //%d 对指针打印的是地址(nil 即 0),并非用户传入的值。必须解引用。 + if *draft.MaxRetryTimes < 0 { + platform.HandleError(fmt.Errorf("max-retry-times must be greater than or equal to 0, accept %d", *draft.MaxRetryTimes)) return } //如有设置Region和Zone,确保设置的Region和Zone真实存在 if cfg.Region != "" { - cacheConfig.Region = cfg.Region + draft.Region = cfg.Region } if cfg.Zone != "" { - cacheConfig.Zone = cfg.Zone + draft.Zone = cfg.Zone } - region, zone, err := getReasonableRegionZone(cacheConfig) + region, zone, err := getReasonableRegionZone(&draft) if err != nil { platform.HandleError(err) return } - cacheConfig.Region = region - cacheConfig.Zone = zone + draft.Region = region + draft.Zone = zone if cfg.ProjectID != "" { - cacheConfig.ProjectID = platform.PickResourceID(cfg.ProjectID) + draft.ProjectID = platform.PickResourceID(cfg.ProjectID) } - project, err := getReasonableProject(cacheConfig) - if err != nil { + //errNoDefaultProject 是良性缺失(账号有项目但未设默认),放行并留空 ProjectID, + //口径与 ucloud init 一致;其余错误一律拒绝落盘——此处曾漏 return 而抹空 ProjectID + project, err := getReasonableProject(&draft) + if err != nil && !errors.Is(err, errNoDefaultProject) { platform.HandleError(err) + return } - cacheConfig.ProjectID = project + draft.ProjectID = project if active == "true" { - cacheConfig.Active = true + draft.Active = true } else if active == "false" { - cacheConfig.Active = false + draft.Active = false } if upload == "true" { - cacheConfig.AgreeUploadLog = true + draft.AgreeUploadLog = true } else if upload == "false" { - cacheConfig.AgreeUploadLog = false + draft.AgreeUploadLog = false } - err = platform.AggConfigListIns.UpdateAggConfig(cacheConfig) + err = platform.AggConfigListIns.UpdateAggConfig(&draft) if err != nil { platform.HandleError(err) } @@ -573,8 +607,9 @@ func NewCmdConfigUpdate() *cobra.Command { flags := cmd.Flags() flags.SortFlags = false flags.StringVar(&cfg.Profile, "profile", "", "Required. Set name of CLI profile") - flags.StringVar(&cfg.PublicKey, "public-key", "", "Required. Set public key") - flags.StringVar(&cfg.PrivateKey, "private-key", "", "Required. Set private key") + //公私钥在 update 中为可选:仅在非空时更新(见 Run)。只有 profile 是 MarkFlagRequired + flags.StringVar(&cfg.PublicKey, "public-key", "", "Optional. Set public key") + flags.StringVar(&cfg.PrivateKey, "private-key", "", "Optional. Set private key") flags.StringVar(&cfg.Region, "region", "", "Optional. Set default region. For instance 'cn-bj2' See 'ucloud region'") flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'") flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list") diff --git a/cmd/configure_test.go b/cmd/configure_test.go index e90cd82d9b..49e5d1ca4e 100644 --- a/cmd/configure_test.go +++ b/cmd/configure_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) @@ -63,18 +65,55 @@ func TestSwitchProfileToAKSKPersistsToDisk(t *testing.T) { } } +// 假网关响应体。网关以 HTTP 200 + RetCode 表达错误,假网关须照做。 +const ( + respRegionOK = `{"RetCode":0,"Action":"GetRegionResponse","Regions":[{"Region":"cn-bj2","Zone":"cn-bj2-04","IsDefault":true}]}` + respProjectOK = `{"RetCode":0,"Action":"GetProjectListResponse","ProjectSet":[{"ProjectId":"org-123","ProjectName":"Default","IsDefault":true}]}` + respSignatureFail = `{"RetCode":171,"Message":"Signature VerifyAC Error"}` + respProjectNoDefault = `{"RetCode":0,"Action":"GetProjectListResponse","ProjectSet":[{"ProjectId":"org-123","ProjectName":"P","IsDefault":false}]}` +) + +// gatewayBehavior 指定假网关对各 action 的响应,零值即全部成功。 +// 分 action 控制使「region 校验通过但 project 校验失败」这类组合可精确构造。 +type gatewayBehavior struct { + regionResp string // 空 = respRegionOK + projectResp string // 空 = respProjectOK +} + +// poisonGateway 任何请求都判失败——用于断言某路径根本不该发起远程校验 +// (本地检查应先于远程校验拦截,不打网络)。 +func poisonGateway(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected remote call %s %s — this path must be rejected locally before any validation", r.Method, r.URL.RawQuery) + w.WriteHeader(500) + })) +} + // fakeGatewayServer 模拟业务网关:响应远程校验所需的 GetRegion/GetProjectList func fakeGatewayServer(t *testing.T) *httptest.Server { t.Helper() + return fakeGatewayServerWith(t, gatewayBehavior{}) +} + +// fakeGatewayServerWith 同 fakeGatewayServer,但可为单个 action 指定失败响应 +func fakeGatewayServerWith(t *testing.T, b gatewayBehavior) *httptest.Server { + t.Helper() + if b.regionResp == "" { + b.regionResp = respRegionOK + } + if b.projectResp == "" { + b.projectResp = respProjectOK + } return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) payload := r.URL.RawQuery + string(body) w.Header().Set("Content-Type", "application/json") switch { case strings.Contains(payload, "GetRegion"): - fmt.Fprint(w, `{"RetCode":0,"Action":"GetRegionResponse","Regions":[{"Region":"cn-bj2","Zone":"cn-bj2-04","IsDefault":true}]}`) + fmt.Fprint(w, b.regionResp) case strings.Contains(payload, "GetProjectList"): - fmt.Fprint(w, `{"RetCode":0,"Action":"GetProjectListResponse","ProjectSet":[{"ProjectId":"org-123","ProjectName":"Default","IsDefault":true}]}`) + fmt.Fprint(w, b.projectResp) default: fmt.Fprint(w, `{"RetCode":230,"Message":"unexpected action"}`) } @@ -203,3 +242,299 @@ func TestInitSaveOverwritesExistingOAuthOnlyProfile(t *testing.T) { got.AccessToken, got.RefreshToken, got.ExpiresAt) } } + +// newTestConfigFiles 写入 config/credential 到临时目录,让包级 AggConfigListIns 指向它们, +// 并在 t.Cleanup 中恢复被 GetBizClient 改写的包级全局,避免测试顺序耦合。 +func newTestConfigFiles(t *testing.T, cliJSON, credJSON string) (cfgPath, credPath string) { + t.Helper() + dir := t.TempDir() + cfgPath = filepath.Join(dir, "config.json") + credPath = filepath.Join(dir, "credential.json") + if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(credPath, []byte(credJSON), platform.LocalFileMode); err != nil { + t.Fatal(err) + } + m, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + oldM, oldCC, oldAC := platform.AggConfigListIns, platform.ClientConfig, platform.AuthCredential + platform.AggConfigListIns = m + t.Cleanup(func() { + platform.AggConfigListIns, platform.ClientConfig, platform.AuthCredential = oldM, oldCC, oldAC + }) + return cfgPath, credPath +} + +// reloadProfile 重新读盘取 profile —— 断言持久化结果,而非内存态 +func reloadProfile(t *testing.T, cfgPath, credPath, profile string) (*platform.AggConfig, bool) { + t.Helper() + m, err := platform.NewAggConfigManager(cfgPath, credPath) + if err != nil { + t.Fatal(err) + } + return m.GetAggConfigByProfile(profile) +} + +func setFlags(t *testing.T, cmd *cobra.Command, kv ...string) { + t.Helper() + for i := 0; i+1 < len(kv); i += 2 { + if err := cmd.Flags().Set(kv[i], kv[i+1]); err != nil { + t.Fatalf("set --%s=%s: %v", kv[i], kv[i+1], err) + } + } +} + +// 回归 AC1:config add 远程校验失败时不得创建 profile。 +// 现状 getReasonableRegionZone 出错后只 HandleError 不 return,随即把空 region/zone +// 赋回,照常 Append,落盘一个 region='' zone='' project_id='' 的残缺 profile。 +// +// 预置一个 active profile 而非从空配置起步:AggConfigManager.Load 规定「有 profile +// 就必须有 active」(config.go:403),否则这里落盘的 bad(active=false) 会让重新读盘 +// 直接失败,断言根本跑不到。 +func TestConfigAddRejectsProfileWhenValidationFails(t *testing.T) { + // 失败路径必调 HandleError → LogErrorTo 会碰单测中恒为 nil 的包级 logger + t.Setenv("COMP_LINE", "1") + gateway := fakeGatewayServerWith(t, gatewayBehavior{regionResp: respSignatureFail}) + t.Cleanup(gateway.Close) + + cliJSON := `[{"profile":"good","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"good"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfigAdd() + setFlags(t, cmd, + "profile", "bad", + "public-key", "FAKE", + "private-key", "FAKE", + "base-url", gateway.URL, + "region", "cn-bj2", + "zone", "cn-bj2-04", + ) + cmd.Run(cmd, nil) + + if got, ok := reloadProfile(t, cfgPath, credPath, "bad"); ok { + t.Errorf("profile must not be created when remote validation fails; got region=%q zone=%q project_id=%q", + got.Region, got.Zone, got.ProjectID) + } +} + +// 回归 AC2:失败的 config add --active true 不得夺走原有 active profile。 +// 现状坏 profile 不仅建成,Append 还会把原 active 踢下去,此后每条命令都用 +// 那个 region 为空、密钥为假的 profile,且无任何线索指向病因。 +func TestConfigAddFailureDoesNotHijackActiveProfile(t *testing.T) { + t.Setenv("COMP_LINE", "1") + gateway := fakeGatewayServerWith(t, gatewayBehavior{regionResp: respSignatureFail}) + t.Cleanup(gateway.Close) + + cliJSON := `[{"profile":"good","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"good"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfigAdd() + setFlags(t, cmd, + "profile", "bad", + "public-key", "FAKE", + "private-key", "FAKE", + "base-url", gateway.URL, + "active", "true", + ) + cmd.Run(cmd, nil) + + good, ok := reloadProfile(t, cfgPath, credPath, "good") + if !ok { + t.Fatal("profile good missing after reload") + } + if !good.Active { + t.Error("a failed 'config add --active true' must not deactivate the existing active profile") + } + if bad, ok := reloadProfile(t, cfgPath, credPath, "bad"); ok { + t.Errorf("failed 'config add' must not create the profile; got active=%v region=%q", bad.Active, bad.Region) + } +} + +// 回归 AC3:config update 远程校验失败时不得写入任何字段,含 AK/SK。 +// 现状「先更新让其生效」的提前 UpdateAggConfig 已把新 AK/SK 落盘,其后 region +// 校验失败虽 return,profile 已停在「密钥已换、region/project 未换」的半更新态。 +func TestConfigUpdateWritesNothingWhenValidationFails(t *testing.T) { + t.Setenv("COMP_LINE", "1") + gateway := fakeGatewayServerWith(t, gatewayBehavior{regionResp: respSignatureFail}) + t.Cleanup(gateway.Close) + + cliJSON := fmt.Sprintf(`[{"profile":"up","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":%q,"timeout_sec":15,"max_retry_times":3}]`, gateway.URL) + credJSON := `[{"public_key":"OLD_PUB","private_key":"OLD_PRI","profile":"up"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfigUpdate() + setFlags(t, cmd, + "profile", "up", + "public-key", "NEW_PUB", + "private-key", "NEW_PRI", + ) + cmd.Run(cmd, nil) + + got, ok := reloadProfile(t, cfgPath, credPath, "up") + if !ok { + t.Fatal("profile up missing after reload") + } + if got.PublicKey != "OLD_PUB" || got.PrivateKey != "OLD_PRI" { + t.Errorf("a failed 'config update' must write nothing; got public_key=%q private_key=%q, want OLD_PUB/OLD_PRI", + got.PublicKey, got.PrivateKey) + } +} + +// 回归 AC4:config update 的 project 校验失败不得清空 ProjectID。 +// 现状同函数内 region 记得 return、project 忘了 return,随即把 "" 赋回并落盘。 +// 网关对 GetRegion 返成功、对 GetProjectList 返失败,精确构造该组合。 +func TestConfigUpdateKeepsProjectIDWhenProjectValidationFails(t *testing.T) { + t.Setenv("COMP_LINE", "1") + gateway := fakeGatewayServerWith(t, gatewayBehavior{projectResp: respSignatureFail}) + t.Cleanup(gateway.Close) + + cliJSON := fmt.Sprintf(`[{"profile":"up","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":%q,"timeout_sec":15,"max_retry_times":3}]`, gateway.URL) + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"up"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfigUpdate() + setFlags(t, cmd, "profile", "up") + cmd.Run(cmd, nil) + + got, ok := reloadProfile(t, cfgPath, credPath, "up") + if !ok { + t.Fatal("profile up missing after reload") + } + if got.ProjectID != "org-123" { + t.Errorf("project validation failure must not clear project_id; got %q, want org-123", got.ProjectID) + } +} + +// 回归 AC6:config 主命令(非 add/update)的校验失败同样不得落盘。 +// 现状它用 else 保留原 region 后照常 UpdateAggConfig,落盘了同一条命令里的其他改动 +// (此处为 timeout-sec),与 add/update 的 fail-closed 口径不一致。 +func TestConfigMainCommandWritesNothingWhenValidationFails(t *testing.T) { + t.Setenv("COMP_LINE", "1") + gateway := fakeGatewayServerWith(t, gatewayBehavior{regionResp: respSignatureFail}) + t.Cleanup(gateway.Close) + + cliJSON := fmt.Sprintf(`[{"profile":"main","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":%q,"timeout_sec":15,"max_retry_times":3}]`, gateway.URL) + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"main"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfig() + setFlags(t, cmd, "profile", "main", "timeout-sec", "30") + cmd.Run(cmd, nil) + + got, ok := reloadProfile(t, cfgPath, credPath, "main") + if !ok { + t.Fatal("profile main missing after reload") + } + if got.Timeout != 15 { + t.Errorf("a failed 'ucloud config' must write nothing; got timeout_sec=%d, want 15 (unchanged)", got.Timeout) + } +} + +// 回归 D10:config add 的本地 timeout 检查必须先于远程校验。 +// timeout <= 0 是纯本地约束;此前它排在 getReasonableRegionZone 之后,坏 timeout +// 会先被喂进校验请求(0 → 无超时 → 照打网关;负值 → 请求瞬败),坏 timeout 反而 +// 让人看到 region 错、看不到真正的 timeout 错。用 --timeout-sec 0 + 毒网关:修复后 +// 本地检查先拦,网关零调用。 +func TestConfigAddChecksTimeoutBeforeValidation(t *testing.T) { + t.Setenv("COMP_LINE", "1") + gateway := poisonGateway(t) // 任何远程调用都判失败 + t.Cleanup(gateway.Close) + + cliJSON := `[{"profile":"good","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"good"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfigAdd() + setFlags(t, cmd, + "profile", "bt", + "public-key", "pub", + "private-key", "pri", + "base-url", gateway.URL, + "region", "cn-bj2", + "zone", "cn-bj2-04", + "timeout-sec", "0", // 非法本地值:0 会让 http.Client 变成「无超时」,照打网关 + ) + cmd.Run(cmd, nil) + + // timeout=0 非法,profile 不该建成;关键由毒网关断言:拒绝发生在任何远程调用之前 + if _, ok := reloadProfile(t, cfgPath, credPath, "bt"); ok { + t.Error("profile 'bt' must not be created with timeout_sec=0") + } +} + +// 回归 D9:config add 必须归一化补全格式的 project-id(org-xxx/Name)。 +// getProjectList 补全吐出的正是斜杠形式,而 config add 此前不做 PickResourceID +// (主命令与 update 都做)——合法的补全值会被 getReasonableProject 判为「不存在」, +// 叠加 fail-closed 后直接硬拦一个真实存在的 project。 +func TestConfigAddNormalizesSlashProjectID(t *testing.T) { + t.Setenv("COMP_LINE", "1") + // respProjectOK 的 ProjectSet 含 org-123(map 键为裸 id) + gateway := fakeGatewayServer(t) + t.Cleanup(gateway.Close) + + cliJSON := `[{"profile":"good","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"good"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfigAdd() + setFlags(t, cmd, + "profile", "probe", + "public-key", "pub", + "private-key", "pri", + "base-url", gateway.URL, + "region", "cn-bj2", + "zone", "cn-bj2-04", + "project-id", "org-123/Default", // 补全格式,project 真实存在 + ) + cmd.Run(cmd, nil) + + got, ok := reloadProfile(t, cfgPath, credPath, "probe") + if !ok { + t.Fatal("a valid completion-form project-id must not block the save; profile probe was not created") + } + if got.ProjectID != "org-123" { + t.Errorf("project-id must be normalized to the bare id; got %q, want org-123", got.ProjectID) + } +} + +// 回归 AC9:fail-closed 不得误伤「有项目但未设默认」的账号。 +// 该场景下 getDefaultProjectWithConfig 返回 errNoDefaultProject(良性),init 认得 +// 并放行;config add 必须同口径 —— 建成 profile 且 project_id 留空。 +// 本用例守护修复不越界,故在修复前即应通过。 +func TestConfigAddAllowsAccountWithoutDefaultProject(t *testing.T) { + t.Setenv("COMP_LINE", "1") + gateway := fakeGatewayServerWith(t, gatewayBehavior{projectResp: respProjectNoDefault}) + t.Cleanup(gateway.Close) + + // 同 AC1:预置 active profile,否则新建的 nodef(active=false) 会让重新读盘失败 + cliJSON := `[{"profile":"good","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` + credJSON := `[{"public_key":"pub","private_key":"pri","profile":"good"}]` + cfgPath, credPath := newTestConfigFiles(t, cliJSON, credJSON) + + cmd := NewCmdConfigAdd() + setFlags(t, cmd, + "profile", "nodef", + "public-key", "pub", + "private-key", "pri", + "base-url", gateway.URL, + "region", "cn-bj2", + "zone", "cn-bj2-04", + ) + cmd.Run(cmd, nil) + + got, ok := reloadProfile(t, cfgPath, credPath, "nodef") + if !ok { + t.Fatal("an account without a default project must still be configurable; profile nodef was not created") + } + if got.ProjectID != "" { + t.Errorf("project_id should stay empty when the account has no default project, got %q", got.ProjectID) + } + if got.Region != "cn-bj2" || got.Zone != "cn-bj2-04" { + t.Errorf("region/zone must survive; got region=%q zone=%q", got.Region, got.Zone) + } +} diff --git a/cmd/region.go b/cmd/region.go index 4755293c2b..2a138397d7 100644 --- a/cmd/region.go +++ b/cmd/region.go @@ -242,7 +242,9 @@ func getReasonableProject(cfg *platform.AggConfig) (string, error) { if cfg.ProjectID == "" { id, _, err := getDefaultProjectWithConfig(cfg) if err != nil { - return "", fmt.Errorf("fetch project failed: %v", err) + // %w 而非 %v:调用方需 errors.Is 识别 errNoDefaultProject(账号有项目但 + // 未设默认,属良性),放行而非拒绝落盘,口径与 ucloud init 一致 + return "", fmt.Errorf("fetch project failed: %w", err) } return id, nil }