Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions cmd/internal/platform/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler
return req, err
}
if credConfig.AuthMode == AuthModeOAuth {
// 仅对 form-urlencoded body 剥离(JSON 编码器虽当前不可达,但
// url.ParseQuery 对 JSON 往往"成功",重编码会悄悄毁掉 body)
if req.GetHeaderMap()[http.HeaderNameContentType] == http.MimeFormURLEncoded {
// 按 Content-Type 分派剥离:编码器决定 body 形态,剥离方式必须与之配对。
// 不能只按一种形态盲剥 —— url.ParseQuery 对 JSON 往往"成功",重编码会
// 悄悄毁掉 body;不认识的 Content-Type 一律不碰 body。
switch req.GetHeaderMap()[http.HeaderNameContentType] {
case http.MimeFormURLEncoded:
vals, err := url.ParseQuery(string(req.GetRequestBody()))
if err != nil {
// 剥不掉就明确失败:客户端报错优于网关 171
Expand All @@ -42,6 +44,22 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler
if err := req.SetRequestBody([]byte(vals.Encode())); err != nil {
return req, err
}
case http.MimeJSON:
// JSONEncoder 把 cred.Apply 附加的 Signature/PublicKey 放在 body 顶层
// (SDK ucloud/request/encoder_json.go),与 form 分支同构剥离。
var payload map[string]interface{}
if err := json.Unmarshal(req.GetRequestBody(), &payload); err != nil {
return req, fmt.Errorf("strip signature params from oauth json request failed: %w", err)
}
delete(payload, "Signature")
delete(payload, "PublicKey")
bs, err := json.Marshal(payload)
if err != nil {
return req, fmt.Errorf("re-encode oauth json request failed: %w", err)
}
if err := req.SetRequestBody(bs); err != nil {
return req, err
}
}
if credConfig.AccessToken != "" {
if err := req.SetHeader("Authorization", "Bearer "+credConfig.AccessToken); err != nil {
Expand Down Expand Up @@ -161,6 +179,43 @@ func BuildCredentialFrom(credConfig *CredentialConfig) *auth.Credential {
return buildCredential(credConfig)
}

// normalizeProjectID 把 project-id 从补全带出的 "id/name" 形态还原成纯 id
// (补全候选由 getProjectList 生成,形如 "org-xxx/ProjectName")。
//
// typed 请求 SetProjectId 写进 CommonBase 即可。GenericRequest 要多一步:SDK 的
// BaseGenericRequest 把 GetProjectId() override 成 payload 优先,却没有 override
// SetProjectId —— SetProjectId 写的是 CommonBase,而 GetPayload() 末尾用 payload
// 覆盖 CommonBase(SDK ucloud/request/generic.go),于是归一化被 payload 里的原值
// 吃掉,"org-x/Name" 原样上行,网关报 RetCode 292 Project [org-x/Name] not exists
// (2026-07-14 对 api.ucloud.cn 实测)。凡是把 ProjectId 放进 payload map 的产品
// 都会中招,故在此一并同步 payload。
//
// 未发生归一化时(已是纯 id 或为空,即绝大多数调用)直接返回,payload 一字不动 ——
// 行为与历史逐字节一致。
func normalizeProjectID(req request.Common) (request.Common, error) {
raw := req.GetProjectId()
normalized := PickResourceID(raw)
if err := req.SetProjectId(normalized); err != nil {
return req, err
}
if raw == normalized {
return req, nil
}
gr, ok := req.(request.GenericRequest)
if !ok {
return req, nil
}
payload := gr.GetPayload()
if _, exists := payload["ProjectId"]; !exists {
return req, nil
}
payload["ProjectId"] = normalized
if err := gr.SetPayload(payload); err != nil {
return req, err
}
return req, nil
}

// attachHandlers 把三个平台 handler 挂到 service client 上:
// project-id 归一化、凭据头注入、oauth 反应式重试。
// credConfig 与 ac 显式传入:NewClient 借此传它自己的构造来源 profile(ac),
Expand All @@ -172,8 +227,7 @@ func attachHandlersWithManager(sc sdk.ServiceClient, credConfig *CredentialConfi
credConfig = &CredentialConfig{}
}
sc.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) {
err := req.SetProjectId(PickResourceID(req.GetProjectId()))
return req, err
return normalizeProjectID(req)
})
// Platform request logging: every API request is logged uniformly at the SDK
// layer (replaces per-command hand-rolled logging; products no longer build
Expand Down
140 changes: 140 additions & 0 deletions cmd/internal/platform/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (

uhttp "github.com/ucloud/ucloud-sdk-go/private/protocol/http"
"github.com/ucloud/ucloud-sdk-go/services/uaccount"
"github.com/ucloud/ucloud-sdk-go/ucloud"
"github.com/ucloud/ucloud-sdk-go/ucloud/request"
)

func injectorHeaders(t *testing.T, cred *CredentialConfig) map[string]string {
Expand Down Expand Up @@ -61,6 +63,144 @@ func TestInjectorOAuthEmptyToken(t *testing.T) {
}
}

// injectBody 把 body 以指定 Content-Type 过一遍 injector,返回处理后的请求。
func injectBody(t *testing.T, cred *CredentialConfig, contentType, body string) *uhttp.HttpRequest {
t.Helper()
req := uhttp.NewHttpRequest()
if err := req.SetHeader(uhttp.HeaderNameContentType, contentType); err != nil {
t.Fatal(err)
}
if err := req.SetRequestBody([]byte(body)); err != nil {
t.Fatal(err)
}
out, err := newCredHeaderInjector(cred)(nil, req)
if err != nil {
t.Fatal(err)
}
return out
}

// oauth + JSON body:剥离 Signature/PublicKey,其余字段与 Content-Type 原样保留。
// JSON 编码器此前不可达;products/pgsql(#127) 是第一个走这条路的产品(UPgSQL 网关
// 无法把 form 的字符串 "100" unmarshal 进 Go 的 *int,RetCode 214001,故切 JSONEncoder)。
func TestInjectorOAuthStripsJSONSignature(t *testing.T) {
body := `{"Action":"ListUPgSQLParamTemplate","Count":100,"PublicKey":"pub","Region":"cn-bj2","Signature":"deadbeef"}`
out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok123"}, uhttp.MimeJSON, body)

var got map[string]interface{}
if err := json.Unmarshal(out.GetRequestBody(), &got); err != nil {
t.Fatalf("body is not valid json after strip: %v", err)
}
if _, ok := got["Signature"]; ok {
t.Error("Signature must be stripped from oauth json body")
}
if _, ok := got["PublicKey"]; ok {
t.Error("PublicKey must be stripped from oauth json body")
}
if got["Action"] != "ListUPgSQLParamTemplate" || got["Region"] != "cn-bj2" {
t.Errorf("business fields must survive untouched, got %v", got)
}
// int 必须仍是 JSON number —— 产品切 JSONEncoder 的全部意义就在于此,
// 剥离过程若把它变回字符串就重新踩回 214001。
if got["Count"] != float64(100) {
t.Errorf("Count = %#v, want JSON number 100", got["Count"])
}
if out.GetHeaderMap()[uhttp.HeaderNameContentType] != uhttp.MimeJSON {
t.Error("Content-Type must stay application/json")
}
if out.GetHeaderMap()["Authorization"] != "Bearer tok123" {
t.Error("Bearer must still be injected for json body")
}
}

// CRITICAL 回归:非 oauth(aksk)模式下 JSON body 必须逐字节不变 ——
// AK/SK 路径的签名就活在 body 里,碰一下就验签失败。
func TestInjectorAkskJSONBodyUntouched(t *testing.T) {
body := `{"Action":"X","PublicKey":"pub","Signature":"deadbeef"}`
out := injectBody(t, &CredentialConfig{PublicKey: "pub", PrivateKey: "pri"}, uhttp.MimeJSON, body)
if string(out.GetRequestBody()) != body {
t.Errorf("aksk json body must be byte-identical\n got: %s\nwant: %s", out.GetRequestBody(), body)
}
}

// CRITICAL 回归:oauth + form body 行为与历史完全一致(本次只加分支,不动 form)。
func TestInjectorOAuthFormUnchanged(t *testing.T) {
body := "Action=X&PublicKey=pub&Region=cn-bj2&Signature=deadbeef"
out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, uhttp.MimeFormURLEncoded, body)
vals, err := url.ParseQuery(string(out.GetRequestBody()))
if err != nil {
t.Fatal(err)
}
if vals.Has("Signature") || vals.Has("PublicKey") {
t.Errorf("form signature params must be stripped, got %s", out.GetRequestBody())
}
if vals.Get("Action") != "X" || vals.Get("Region") != "cn-bj2" {
t.Errorf("form business fields changed: %s", out.GetRequestBody())
}
}

// 不认识的 Content-Type:不碰 body(盲目重编码会毁掉它),但 Bearer 照常注入。
func TestInjectorOAuthUnknownContentTypeBodyUntouched(t *testing.T) {
body := `<xml><Signature>deadbeef</Signature></xml>`
out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, "application/xml", body)
if string(out.GetRequestBody()) != body {
t.Errorf("unknown content-type body must be untouched, got %s", out.GetRequestBody())
}
if out.GetHeaderMap()["Authorization"] != "Bearer tok" {
t.Error("Bearer must still be injected for unknown content-type")
}
}

// 端到端:真实 SDK JSONEncoder + 真实 oauth 凭据,确认
// (a) SDK 即使凭据为空也会附加 Signature(这正是必须剥离的原因);
// (b) injector 之后 body 内再无签名参数,只剩 Bearer 一种凭据机制。
func TestInjectorOAuthJSONEndToEndWithSDKEncoder(t *testing.T) {
credConfig := &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}
cfg := ucloud.NewConfig()
cred := BuildCredentialFrom(credConfig)

req := &request.CommonBase{}
if err := req.SetAction("ListUPgSQLParamTemplate"); err != nil {
t.Fatal(err)
}
if err := req.SetRegion("cn-bj2"); err != nil {
t.Fatal(err)
}
httpReq, err := request.NewJSONEncoder(&cfg, cred).Encode(req)
if err != nil {
t.Fatal(err)
}

var before map[string]interface{}
if err := json.Unmarshal(httpReq.GetRequestBody(), &before); err != nil {
t.Fatal(err)
}
if _, ok := before["Signature"]; !ok {
t.Fatal("premise broken: SDK JSONEncoder no longer attaches Signature for empty credential")
}

out, err := newCredHeaderInjector(credConfig)(nil, httpReq)
if err != nil {
t.Fatal(err)
}
var after map[string]interface{}
if err := json.Unmarshal(out.GetRequestBody(), &after); err != nil {
t.Fatal(err)
}
if _, ok := after["Signature"]; ok {
t.Error("Signature survived the injector on a real SDK-encoded json body")
}
if _, ok := after["PublicKey"]; ok {
t.Error("PublicKey survived the injector on a real SDK-encoded json body")
}
if after["Action"] != "ListUPgSQLParamTemplate" {
t.Errorf("Action lost: %v", after)
}
if out.GetHeaderMap()["Authorization"] != "Bearer tok" {
t.Error("Bearer missing after injector")
}
}

// recordedRequest 记录业务请求实际携带的 header 与全部参数(query + form 合并)
type recordedRequest struct {
header http.Header
Expand Down
Loading
Loading