Files
iotlcm/internal/logic/upstreamimpl/beixin_token_provider_test.go
T
2026-10-29 14:59:32 +08:00

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