package upstreamimpl import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service" ) const ( lanshuTestAppID = "HeNanPuWeiTongXun10W" lanshuTestPassword = "test-password" lanshuTestSign = "7F4F56AEF91980FEBEAA9EC28E958EAA" lanshuTestTokenType = "EC11_XY" lanshuTestToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ) func newLanshuTestProvider(t *testing.T, baseURL string) *LanShuTokenProvider { t.Helper() return NewLanShuTokenProvider(LanShuTokenConfig{ BaseURL: baseURL, Path: "/route/open/sharedToken", AppID: lanshuTestAppID, Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType, Timeout: time.Second, }) } // TestLanShuTokenProviderSendRequestContract pins the exact wire shape that // the upstream expects: capitalized Param, verify.* fields passed through // verbatim, and a real millisecond timeStamp. func TestLanShuTokenProviderSendRequestContract(t *testing.T) { var ( gotMethod string gotPath string gotContentType string gotBody []byte ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path gotContentType = r.Header.Get("Content-Type") body, _ := io.ReadAll(r.Body) gotBody = body w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`)) })) defer server.Close() provider := newLanshuTestProvider(t, server.URL) before := time.Now().UnixMilli() token, err := provider.Fetch(context.Background()) after := time.Now().UnixMilli() if err != nil { t.Fatalf("fetch token: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %q, want POST", gotMethod) } if gotPath != "/route/open/sharedToken" { t.Fatalf("path = %q", gotPath) } if gotContentType != "application/json" { t.Fatalf("Content-Type = %q", gotContentType) } var payload struct { Param struct { TokenType string `json:"tokenType"` } `json:"Param"` Verify struct { TimeStamp int64 `json:"timeStamp"` Password string `json:"password"` AppID string `json:"appId"` Sign string `json:"sign"` } `json:"verify"` } if err := json.Unmarshal(gotBody, &payload); err != nil { t.Fatalf("decode request body %s: %v", gotBody, err) } if payload.Param.TokenType != lanshuTestTokenType { t.Fatalf("Param.tokenType = %q", payload.Param.TokenType) } if payload.Verify.AppID != lanshuTestAppID { t.Fatalf("verify.appId = %q", payload.Verify.AppID) } if payload.Verify.Password != lanshuTestPassword { t.Fatalf("verify.password = %q", payload.Verify.Password) } if payload.Verify.Sign != lanshuTestSign { t.Fatalf("verify.sign = %q (must be passed through verbatim)", payload.Verify.Sign) } if payload.Verify.TimeStamp < before || payload.Verify.TimeStamp > after { t.Fatalf("verify.timeStamp = %d, want millis in [%d, %d]", payload.Verify.TimeStamp, before, after) } if token.Value != lanshuTestToken { t.Fatalf("token = %q", token.Value) } // expireTime is seconds, so the expiry must land near now+1162s rather // than now+1162ms. wantExpiry := time.Now().Add(1162 * time.Second) if diff := token.ExpiresAt.Sub(wantExpiry); diff > 5*time.Second || diff < -5*time.Second { t.Fatalf("ExpiresAt = %s, want ~%s", token.ExpiresAt, wantExpiry) } if token.CreatedAt.IsZero() { t.Fatal("CreatedAt must be set") } } func TestLanShuTokenProviderRejectsBusinessFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"result":{},"message":"未找到对应token获取","status":"500"}`)) })) defer server.Close() provider := newLanshuTestProvider(t, server.URL) token, err := provider.Fetch(context.Background()) if err == nil { t.Fatalf("expected error for status=500, got token %q", token.Value) } if token.Value != "" { t.Fatalf("token must not be returned on failure, got %q", token.Value) } } func TestLanShuTokenProviderRejectsEmptyToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":1162,"token":""}},"message":"success","status":"0"}`)) })) defer server.Close() provider := newLanshuTestProvider(t, server.URL) if _, err := provider.Fetch(context.Background()); err == nil { t.Fatal("expected empty token error") } } func TestLanShuTokenProviderRequiresCredentials(t *testing.T) { // The base URL points at an unroutable TEST-NET-1 address, so a missing // validation guard would surface as a dial timeout instead of the // credential error this test asserts on. const deadURL = "http://192.0.2.1:1" cases := map[string]LanShuTokenConfig{ "missing sign": { BaseURL: deadURL, Path: "/route/open/sharedToken", AppID: lanshuTestAppID, Password: lanshuTestPassword, TokenType: lanshuTestTokenType, }, "missing password": { BaseURL: deadURL, Path: "/route/open/sharedToken", AppID: lanshuTestAppID, Sign: lanshuTestSign, TokenType: lanshuTestTokenType, }, "missing appId": { BaseURL: deadURL, Path: "/route/open/sharedToken", Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType, }, "missing tokenType": { BaseURL: deadURL, Path: "/route/open/sharedToken", AppID: lanshuTestAppID, Password: lanshuTestPassword, Sign: lanshuTestSign, }, "missing baseUrl": { Path: "/route/open/sharedToken", AppID: lanshuTestAppID, Password: lanshuTestPassword, Sign: lanshuTestSign, TokenType: lanshuTestTokenType, }, } for name, config := range cases { t.Run(name, func(t *testing.T) { provider := NewLanShuTokenProvider(config) start := time.Now() _, err := provider.Fetch(context.Background()) if err == nil { t.Fatal("expected credential validation error") } // The guard must reject before any HTTP round trip. if elapsed := time.Since(start); elapsed > time.Second { t.Fatalf("validation must not hit the network, took %s", elapsed) } if !strings.Contains(err.Error(), "lanshu baseUrl, appId, password, sign and tokenType are required") { t.Fatalf("error = %v, want the credential guard message", err) } }) } } // TestLanShuTemplateRegistration pins that a provider=lanshu template // instantiates with a chan_config carrying the four required credentials. func TestLanShuTemplateRegistration(t *testing.T) { template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkLanshuTemplateKey) if !ok || template == nil { t.Fatal("lanshu template not registered") } config := &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkLanshuTemplateKey, TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` + `"path":"/route/open/sharedToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`, }, Channel: &entity.TSimChannels{ ChanKey: "LSH-001", ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, }, } if instance := template.NewTemp(context.Background(), config); instance == nil { t.Fatal("lanshu template failed to instantiate") } } // TestLanShuTokenPathComesFromTemplate pins the answer to "is the token path // read from the template, or hardcoded in the provider?": the provider's // normalizeProviderPath fallback is unreachable on the template path, so the // request must land on whatever token.path the template declares. The path // used here deliberately differs from lanshuTokenPath, so a hardcoded default // would fail this assertion. // // This stops one layer below the token manager: going through GetSharedToken // would also exercise the DB-backed persister, which is out of scope here. func TestLanShuTokenPathComesFromTemplate(t *testing.T) { // A path that is deliberately NOT lanshuTokenPath. const templatePath = "/some/other/open/route" if templatePath == lanshuTokenPath { t.Fatal("test precondition broken: templatePath must differ from lanshuTokenPath") } var gotPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"result":{"Data":{"expireTime":600,"token":"` + lanshuTestToken + `"}},"message":"success","status":"0"}`)) })) defer server.Close() channelConfig := &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkLanshuTemplateKey, TempConfig: `{"token":{"provider":"lanshu","baseUrl":"` + server.URL + `",` + `"path":"` + templatePath + `"}}`, }, Channel: &entity.TSimChannels{ ChanKey: "LSH-001", ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, }, } // The parsed config must carry the template path, not the fallback. cfg, _, err := parseSharedOneLinkConfig(context.Background(), channelConfig) if err != nil { t.Fatalf("parse config: %v", err) } if cfg.TokenPath != templatePath { t.Fatalf("parsed TokenPath = %q, want the template-declared %q", cfg.TokenPath, templatePath) } // And the provider built from it must issue the request to that path. provider := NewLanShuTokenProvider(LanShuTokenConfig{ BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath, AppID: cfg.AppID, Password: cfg.Password, Sign: cfg.Sign, TokenType: cfg.TokenType, Timeout: cfg.Timeout, }) if _, err := provider.Fetch(context.Background()); err != nil { t.Fatalf("fetch token: %v", err) } if gotPath != templatePath { t.Fatalf("request path = %q, want the template-declared %q", gotPath, templatePath) } } // TestLanShuTokenPathIsRequiredFromTemplate pins that a template without // token.path is rejected rather than silently falling back to a default. func TestLanShuTokenPathIsRequiredFromTemplate(t *testing.T) { config := &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkLanshuTemplateKey, TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com"}}`, }, Channel: &entity.TSimChannels{ ChanKey: "LSH-001", ChanConfig: `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, }, } if _, _, err := parseSharedOneLinkConfig(context.Background(), config); err == nil { t.Fatal("missing token.path must be rejected") } } // TestLanShuChannelConfigRejectsIncompleteCredentials pins the parse-time // guard so a channel missing chan_config.sign cannot silently build a // provider that fails on every request. func TestLanShuChannelConfigRejectsIncompleteCredentials(t *testing.T) { base := func(chanConfig string) *model.SimChannelConfigItemCache { return &model.SimChannelConfigItemCache{ Template: &entity.TSimChannelTemplate{ TempKey: sharedOneLinkLanshuTemplateKey, TempConfig: `{"token":{"provider":"lanshu","baseUrl":"http://api.jswl999.com",` + `"path":"/route/open/sharedToken"}}`, }, Channel: &entity.TSimChannels{ChanKey: "LSH-001", ChanConfig: chanConfig}, } } complete := `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}` parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(complete)) if err != nil { t.Fatalf("complete chan_config must parse: %v", err) } if parsed.Sign != lanshuTestSign { t.Fatalf("parsed sign = %q", parsed.Sign) } if parsed.TokenType != lanshuTestTokenType { t.Fatalf("parsed tokenType = %q", parsed.TokenType) } for name, incomplete := range map[string]string{ "missing sign": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","tokenType":"` + lanshuTestTokenType + `"}`, "missing password": `{"appId":"` + lanshuTestAppID + `","sign":"` + lanshuTestSign + `","tokenType":"` + lanshuTestTokenType + `"}`, "missing tokenType": `{"appId":"` + lanshuTestAppID + `","password":"` + lanshuTestPassword + `","sign":"` + lanshuTestSign + `"}`, } { t.Run(name, func(t *testing.T) { if _, _, err := parseSharedOneLinkConfig(context.Background(), base(incomplete)); err == nil { t.Fatal("expected chan_config validation error") } }) } }