336 lines
13 KiB
Go
336 lines
13 KiB
Go
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 (
|
|
beixinTestPwdKey = "test-pwdkey"
|
|
beixinTestChannelNo = "chanel-202607100639054511"
|
|
beixinTestToken = "v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"
|
|
)
|
|
|
|
func newBeixinTestProvider(t *testing.T, baseURL string) *BeixinTokenProvider {
|
|
t.Helper()
|
|
return NewBeixinTokenProvider(BeixinTokenConfig{
|
|
BaseURL: baseURL, Path: beixinOneLinkTokenPath,
|
|
PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo, Timeout: time.Second,
|
|
})
|
|
}
|
|
|
|
// TestBeixinTokenProviderSendRequestContract pins the exact wire shape as
|
|
// observed against the live endpoint on 2026-10-19: a GET with lowercase
|
|
// pwdkey/c_no query parameters, and a bare JSON string in data.
|
|
func TestBeixinTokenProviderSendRequestContract(t *testing.T) {
|
|
var (
|
|
gotMethod string
|
|
gotPath string
|
|
gotPwdKey string
|
|
gotCNo string
|
|
)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
gotPwdKey = r.URL.Query().Get("pwdkey")
|
|
gotCNo = r.URL.Query().Get("c_no")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err != nil {
|
|
t.Fatalf("fetch token: %v", err)
|
|
}
|
|
|
|
if gotMethod != http.MethodGet {
|
|
t.Fatalf("method = %q, want GET", gotMethod)
|
|
}
|
|
if gotPath != beixinOneLinkTokenPath {
|
|
t.Fatalf("path = %q, want %q", gotPath, beixinOneLinkTokenPath)
|
|
}
|
|
if gotPwdKey != beixinTestPwdKey {
|
|
t.Fatalf("pwdkey = %q, want %q", gotPwdKey, beixinTestPwdKey)
|
|
}
|
|
if gotCNo != beixinTestChannelNo {
|
|
t.Fatalf("c_no = %q, want %q", gotCNo, beixinTestChannelNo)
|
|
}
|
|
}
|
|
|
|
// TestBeixinTokenProviderReturnsValueAndLocalTTL pins that the bare string in
|
|
// data is unwrapped and that the local one-hour window is applied, since the
|
|
// upstream reports no expiry.
|
|
func TestBeixinTokenProviderReturnsValueAndLocalTTL(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
before := time.Now()
|
|
token, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("fetch token: %v", err)
|
|
}
|
|
if token.Value != beixinTestToken {
|
|
t.Fatalf("token value = %q, want %q", token.Value, beixinTestToken)
|
|
}
|
|
// Allow a second of slack for slow CI clocks.
|
|
if delta := token.ExpiresAt.Sub(before); delta < beixinTokenTTL-time.Second || delta > beixinTokenTTL+time.Second {
|
|
t.Fatalf("expiresAt - before = %s, want ~%s", delta, beixinTokenTTL)
|
|
}
|
|
}
|
|
|
|
// TestBeixinTokenProviderRejectsBusinessFailure pins that a non-200 code is an
|
|
// error rather than an empty-but-successful token.
|
|
func TestBeixinTokenProviderRejectsBusinessFailure(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"code":500,"msg":"获取token失败","data":null}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
|
|
if err == nil {
|
|
t.Fatal("business failure must return an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "获取token失败") {
|
|
t.Fatalf("error = %v, want the upstream message", err)
|
|
}
|
|
}
|
|
|
|
// TestBeixinTokenProviderRejectsEmptyToken pins that a successful envelope with
|
|
// an empty data string is still an error.
|
|
func TestBeixinTokenProviderRejectsEmptyToken(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":""}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background())
|
|
if err == nil {
|
|
t.Fatal("empty token must return an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "beixin shared token is empty") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBeixinTokenProviderRejectsObjectData pins that data is expected to be a
|
|
// JSON string. A future upstream change to an object shape must fail loudly
|
|
// instead of silently yielding an empty token.
|
|
func TestBeixinTokenProviderRejectsObjectData(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":{"token":"abc"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
if _, err := newBeixinTestProvider(t, server.URL).Fetch(context.Background()); err == nil {
|
|
t.Fatal("object-shaped data must return an error")
|
|
}
|
|
}
|
|
|
|
// TestBeixinTokenProviderRequiresCredentials pins the guard so a channel
|
|
// missing chan_config.pwdKey or channelNo cannot build a provider that fails
|
|
// on every request.
|
|
func TestBeixinTokenProviderRequiresCredentials(t *testing.T) {
|
|
cases := map[string]BeixinTokenConfig{
|
|
"missing baseUrl": {Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey, ChannelNo: beixinTestChannelNo},
|
|
"missing pwdKey": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, ChannelNo: beixinTestChannelNo},
|
|
"missing channelNo": {BaseURL: "http://example.com", Path: beixinOneLinkTokenPath, PwdKey: beixinTestPwdKey},
|
|
}
|
|
for name, cfg := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
start := time.Now()
|
|
_, err := NewBeixinTokenProvider(cfg).Fetch(context.Background())
|
|
if err == nil {
|
|
t.Fatal("incomplete credentials must return an error")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > time.Second {
|
|
t.Fatalf("validation must not hit the network, took %s", elapsed)
|
|
}
|
|
if !strings.Contains(err.Error(), "beixin baseUrl, pwdKey and channelNo are required") {
|
|
t.Fatalf("error = %v, want the credential guard message", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBeixinTemplateRegistration pins that a provider=beixin template
|
|
// instantiates through the shared_token model with a chan_config carrying the
|
|
// two required credentials.
|
|
func TestBeixinTemplateRegistration(t *testing.T) {
|
|
template, ok := service.UpstreamAPI().GetTemp(sharedOneLinkBeixinTemplateKey)
|
|
if !ok || template == nil {
|
|
t.Fatal("beixin template not registered")
|
|
}
|
|
|
|
config := &model.SimChannelConfigItemCache{
|
|
Template: &entity.TSimChannelTemplate{
|
|
TempKey: sharedOneLinkBeixinTemplateKey,
|
|
TempModel: templateModelSharedToken,
|
|
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
|
|
`"path":"/prod-api/open/api/oneLinkToken"},"api":{"baseUrl":"https://api.iot.10086.cn/v5/","timeout":"1s"}}`,
|
|
},
|
|
Channel: &entity.TSimChannels{
|
|
ChanKey: "252",
|
|
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
|
|
},
|
|
}
|
|
if instance := template.NewTemp(context.Background(), config); instance == nil {
|
|
t.Fatal("beixin template failed to instantiate")
|
|
}
|
|
}
|
|
|
|
// TestBeixinTokenPathComesFromTemplate pins that the request path is whatever
|
|
// the template declares, not a hardcoded default. This is the same property
|
|
// TestLanShuTokenPathComesFromTemplate pins for lanshu, and it is what makes a
|
|
// template reusable across upstream hosts.
|
|
func TestBeixinTokenPathComesFromTemplate(t *testing.T) {
|
|
const templatePath = "/some/other/open/route"
|
|
if templatePath == beixinOneLinkTokenPath {
|
|
t.Fatal("test precondition broken: templatePath must differ from beixinOneLinkTokenPath")
|
|
}
|
|
|
|
var gotPath string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
_, _ = w.Write([]byte(`{"code":200,"msg":"请求成功","data":"` + beixinTestToken + `"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
channelConfig := &model.SimChannelConfigItemCache{
|
|
Template: &entity.TSimChannelTemplate{
|
|
TempKey: sharedOneLinkBeixinTemplateKey,
|
|
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + server.URL + `",` +
|
|
`"path":"` + templatePath + `"}}`,
|
|
},
|
|
Channel: &entity.TSimChannels{
|
|
ChanKey: "252",
|
|
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`,
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
provider := NewBeixinTokenProvider(BeixinTokenConfig{
|
|
BaseURL: cfg.TokenBaseURL, Path: cfg.TokenPath,
|
|
PwdKey: cfg.PwdKey, ChannelNo: cfg.ChannelNo, 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)
|
|
}
|
|
}
|
|
|
|
// TestBeixinChannelConfigRejectsIncompleteCredentials pins the parse-time
|
|
// guard, so the gate fires before any network call.
|
|
func TestBeixinChannelConfigRejectsIncompleteCredentials(t *testing.T) {
|
|
base := func(chanConfig string) *model.SimChannelConfigItemCache {
|
|
return &model.SimChannelConfigItemCache{
|
|
Template: &entity.TSimChannelTemplate{
|
|
TempKey: sharedOneLinkBeixinTemplateKey,
|
|
TempConfig: `{"token":{"provider":"beixin","baseUrl":"http://www.jsbxkj.vip",` +
|
|
`"path":"/prod-api/open/api/oneLinkToken"}}`,
|
|
},
|
|
Channel: &entity.TSimChannels{ChanKey: "252", ChanConfig: chanConfig},
|
|
}
|
|
}
|
|
|
|
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{"pwdKey":"only"}`)); err == nil {
|
|
t.Fatal("chan_config without channelNo must be rejected")
|
|
}
|
|
if _, _, err := parseSharedOneLinkConfig(context.Background(), base(`{}`)); err == nil {
|
|
t.Fatal("empty chan_config must be rejected")
|
|
}
|
|
|
|
parsed, _, err := parseSharedOneLinkConfig(context.Background(), base(
|
|
`{"pwdKey":"`+beixinTestPwdKey+`","channelNo":"`+beixinTestChannelNo+`"}`))
|
|
if err != nil {
|
|
t.Fatalf("complete chan_config must parse: %v", err)
|
|
}
|
|
if parsed.PwdKey != beixinTestPwdKey || parsed.ChannelNo != beixinTestChannelNo {
|
|
t.Fatalf("parsed credentials = %q / %q", parsed.PwdKey, parsed.ChannelNo)
|
|
}
|
|
}
|
|
|
|
// TestBeixinChannelTokenBaseURLOverride pins the per-channel host override that
|
|
// lets several Beixin accounts (different c_no values, possibly different
|
|
// hosts) share one template without a code change.
|
|
func TestBeixinChannelTokenBaseURLOverride(t *testing.T) {
|
|
const templateHost = "http://template-host.example.com"
|
|
const channelHost = "http://channel-host.example.com"
|
|
|
|
config := &model.SimChannelConfigItemCache{
|
|
Template: &entity.TSimChannelTemplate{
|
|
TempKey: sharedOneLinkBeixinTemplateKey,
|
|
TempConfig: `{"token":{"provider":"beixin","baseUrl":"` + templateHost + `",` +
|
|
`"path":"/prod-api/open/api/oneLinkToken"}}`,
|
|
},
|
|
Channel: &entity.TSimChannels{
|
|
ChanKey: "252",
|
|
ChanConfig: `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `",` +
|
|
`"tokenBaseUrl":"` + channelHost + `"}`,
|
|
},
|
|
}
|
|
|
|
parsed, _, err := parseSharedOneLinkConfig(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("parse config: %v", err)
|
|
}
|
|
if parsed.TokenBaseURL != channelHost {
|
|
t.Fatalf("TokenBaseURL = %q, want the channel override %q", parsed.TokenBaseURL, channelHost)
|
|
}
|
|
|
|
// Without the override the template host must win.
|
|
config.Channel.ChanConfig = `{"pwdKey":"` + beixinTestPwdKey + `","channelNo":"` + beixinTestChannelNo + `"}`
|
|
parsed, _, err = parseSharedOneLinkConfig(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("parse config: %v", err)
|
|
}
|
|
if parsed.TokenBaseURL != templateHost {
|
|
t.Fatalf("TokenBaseURL = %q, want the template default %q", parsed.TokenBaseURL, templateHost)
|
|
}
|
|
}
|
|
|
|
// TestBeixinResponseShapeMatchesObservedBody replays the exact body captured
|
|
// from the live endpoint, so field-case drift breaks the build rather than
|
|
// production.
|
|
func TestBeixinResponseShapeMatchesObservedBody(t *testing.T) {
|
|
const observed = `{"code":200,"msg":"请求成功","data":"v5feb8f288c5ba96631b627dc18a5b6a5318e7e7b1923cf7e0aa0efdefa746e4"}`
|
|
|
|
var output beixinTokenResponse
|
|
if err := json.Unmarshal([]byte(observed), &output); err != nil {
|
|
t.Fatalf("decode observed body: %v", err)
|
|
}
|
|
if output.Code != 200 || output.Msg != "请求成功" {
|
|
t.Fatalf("envelope = %+v", output)
|
|
}
|
|
var value string
|
|
if err := json.Unmarshal(output.Data, &value); err != nil {
|
|
t.Fatalf("decode data: %v", err)
|
|
}
|
|
if len(value) != 64 || !strings.HasPrefix(value, "v5") {
|
|
t.Fatalf("token shape changed: %q", value)
|
|
}
|
|
}
|
|
|
|
// silence unused-import drift if the file is trimmed later.
|
|
var _ = io.Discard
|