96b1edff45
iotlcm-upstream-chan:运营商上游通道适配服务(统一 /sim/* 协议)。 - 内联原私有依赖:yunze-public → internal/pub/(10 包 18 文件), yunze-pb/pb → internal/pb/(9 文件);Consul 注册改用官方 gogf/gf/contrib/registry/consul/v2(API 兼容) - Go module:yz-svr → git.5iot.com/iotlcm-public/iotlcm-upstream-chan - 修复 mingqian 包测试:实现重构后 `adapter.operations` 字段已移除, 测试断言按当前实现语义重写(不改产品行为) - 凭据与内部标识脱敏:MySQL/Redis/Consul 凭据、私网与生产 IP、 内网域名、真实手机号、注释里的真实 token/transid - 移除内部资产:CI 触发器、生产通道种子 SQL、真实凭据测试资源文档 - Dockerfile 改用公共基础镜像,移除写死的 registry 凭据 - 补 Apache-2.0 LICENSE,重写 README,LF 行尾 + .gitattributes
295 lines
11 KiB
Go
295 lines
11 KiB
Go
package mingqian
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
|
|
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testAdapter(t *testing.T, handler http.HandlerFunc) *Adapter {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
client, err := NewClient(server.URL, "test-app", "test-secret", time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &Adapter{client: client}
|
|
}
|
|
|
|
func TestSign(t *testing.T) {
|
|
params := map[string]string{"appId": "test-app", "reqtime": "2026-09-17", "timestamp": "1789603260000", "transid": "20260917080000000abcdef", "version": "3.0", "sign": "ignored"}
|
|
if got := Sign(params, "test-secret"); got != "78e5d4f24b86159ad2f664ddbcc66282" {
|
|
t.Fatalf("wrong sign: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestWireAndUsageMapping(t *testing.T) {
|
|
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodPost || (request.URL.Path != usagePath && request.URL.Path != packageUsagePath) || request.Header.Get("Content-Type") != "application/json" {
|
|
t.Error("incorrect HTTP contract")
|
|
}
|
|
var payload struct {
|
|
Body map[string]string `json:"body"`
|
|
Data string `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
t.Error(err)
|
|
writer.WriteHeader(400)
|
|
return
|
|
}
|
|
if payload.Body["sign"] != Sign(payload.Body, "test-secret") {
|
|
t.Error("invalid signature")
|
|
}
|
|
stamp, err := strconv.ParseInt(payload.Body["timestamp"], 10, 64)
|
|
if err != nil || len(payload.Body["timestamp"]) != 13 || time.Until(time.UnixMilli(stamp)) < 55*time.Second || time.Until(time.UnixMilli(stamp)) > 65*time.Second {
|
|
t.Error("wrong millisecond timestamp offset")
|
|
}
|
|
if !regexp.MustCompile(`^\d{17}[0-9a-f]{6}$`).MatchString(payload.Body["transid"]) {
|
|
t.Error("invalid transaction ID")
|
|
}
|
|
var data struct {
|
|
Numbers []string `json:"numberArray"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload.Data), &data); err != nil || len(data.Numbers) != 3 {
|
|
t.Error("data must be escaped JSON containing requested cards")
|
|
}
|
|
if request.URL.Path == packageUsagePath {
|
|
fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","totalpackage":10240,"targetpackageusage":2048},{"iccid":"A","totalpackage":"10240","targetpackageusage":"8192"}]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","use":2048},{"iccid":"A","use":"1024"}]}`)
|
|
})
|
|
result, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}, {ICCID: "missing"}}})
|
|
if err != nil || len(result) != 3 {
|
|
t.Fatalf("result: %v %v", result, err)
|
|
}
|
|
if result[0].Code != 0 || result[0].ResData.DataUsage != 1 || result[1].ResData.DataUsage != 2 || result[2].Code == 0 {
|
|
t.Fatalf("incorrect per-card mapping: %+v", result)
|
|
}
|
|
if result[0].ResData.TotalFlow != nil {
|
|
t.Fatal("monthly usage must not invent package total")
|
|
}
|
|
packageFlow := result[0].ResData.PackageFlow
|
|
if packageFlow == nil || packageFlow.TotalFlow != 10240 || packageFlow.DataUsage != 8192 || packageFlow.RemainingFlow != 2048 {
|
|
t.Fatalf("package cumulative flow must remain in MB and separate from monthly usage: %+v", packageFlow)
|
|
}
|
|
if result[1].ResData.PackageFlow == nil || result[1].ResData.PackageFlow.RemainingFlow != 8192 {
|
|
t.Fatal("package response must match the requested card, not array position")
|
|
}
|
|
}
|
|
|
|
func TestInvalidUsage(t *testing.T) {
|
|
for _, data := range []string{`{"iccid":"A"}`, `{"iccid":"A","use":null}`, `{"iccid":"A","use":-1}`, `{"iccid":"A","use":"NaN"}`, `{"iccid":"A","use":""}`} {
|
|
t.Run(data, func(t *testing.T) {
|
|
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
|
|
fmt.Fprintf(writer, `{"code":200,"data":[%s]}`, data)
|
|
})
|
|
result, _ := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
|
|
if result[0].Code == 0 {
|
|
t.Fatal("unknown usage reported as zero success")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCardStates(t *testing.T) {
|
|
for state, want := range map[int]int{1: 1, 2: 2, 3: 4, 4: 6, 5: 7, 6: 9} {
|
|
got, err := cardStatus(&state)
|
|
if err != nil || got != want {
|
|
t.Fatalf("state %d mapped to %d: %v", state, got, err)
|
|
}
|
|
}
|
|
unknown := 7
|
|
if _, err := cardStatus(&unknown); err == nil {
|
|
t.Fatal("expired state cannot be assumed to mean cancelled")
|
|
}
|
|
if _, err := cardStatus(nil); err == nil {
|
|
t.Fatal("missing state must fail")
|
|
}
|
|
}
|
|
|
|
func TestBatchAndBasicInfo(t *testing.T) {
|
|
calls := 0
|
|
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
|
|
calls++
|
|
var payload struct {
|
|
Data string `json:"data"`
|
|
}
|
|
json.NewDecoder(request.Body).Decode(&payload)
|
|
var data struct {
|
|
Numbers []string `json:"numberArray"`
|
|
}
|
|
json.Unmarshal([]byte(payload.Data), &data)
|
|
if len(data.Numbers) > 50 {
|
|
t.Error("query batch exceeds 50")
|
|
}
|
|
rows := []map[string]any{}
|
|
for _, value := range data.Numbers {
|
|
rows = append(rows, map[string]any{"iccid": value, "msisdn": "144000", "state": 2, "statename": "active", "activedate": "2026-01-01"})
|
|
}
|
|
json.NewEncoder(writer).Encode(map[string]any{"code": 200, "data": rows})
|
|
})
|
|
keys := []*model.SimKey{}
|
|
for index := 0; index < 51; index++ {
|
|
keys = append(keys, &model.SimKey{ICCID: strconv.Itoa(index)})
|
|
}
|
|
result, _ := adapter.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: keys})
|
|
if calls != 2 || len(result) != 51 {
|
|
t.Fatalf("calls=%d rows=%d", calls, len(result))
|
|
}
|
|
for _, item := range result {
|
|
if item.Code != 0 || item.ResData.ActiveDate != "" || item.ResData.IMSI != "" {
|
|
t.Fatal("incorrect or fabricated basic fields")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOperationsAreSafe(t *testing.T) {
|
|
calls := 0
|
|
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
|
|
calls++
|
|
if request.URL.Path != "/simlist/test-resume" && request.URL.Path != "/simlist/get-sim-switchoff-by-number" {
|
|
t.Error("wrong operation route")
|
|
}
|
|
fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"A","issuccess":1},{"iccid":"B","issuccess":0}]}`)
|
|
})
|
|
input := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}}
|
|
result, _ := adapter.ChangeSimStatus(context.Background(), input)
|
|
if calls != 1 || result[0].Code != 0 || result[1].Code == 0 {
|
|
t.Fatal("stop operation must reach the confirmed endpoint and inspect per-card success flags")
|
|
}
|
|
before := calls
|
|
input.ReqData.OperType = "1"
|
|
result, _ = adapter.ChangeSimStatus(context.Background(), input)
|
|
if calls != before || result[0].Code == 0 {
|
|
t.Fatal("unconfirmed resume path must not send any request")
|
|
}
|
|
adapter.resumePath = "/simlist/test-resume"
|
|
for _, operation := range []string{"0", "1"} {
|
|
input.ReqData.OperType = operation
|
|
result, _ = adapter.ChangeSimStatus(context.Background(), input)
|
|
if result[0].Code != 0 || result[1].Code == 0 {
|
|
t.Fatal("must inspect per-card success flags")
|
|
}
|
|
}
|
|
before = calls
|
|
input.ReqData.OperType = "invalid"
|
|
adapter.ChangeSimStatus(context.Background(), input)
|
|
if calls != before {
|
|
t.Fatal("invalid operation sent")
|
|
}
|
|
}
|
|
|
|
func TestResponseFailuresAndNoRetry(t *testing.T) {
|
|
for _, body := range []string{`{}`, `{"code":401,"msg":"test-secret"}`, `{"code":200}`, `{"code":200,"data":null}`, `{"code":200,"data":[{"iccid":"A","state":2},{"iccid":"A","state":3}]}`} {
|
|
t.Run(body, func(t *testing.T) {
|
|
calls := 0
|
|
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++; fmt.Fprint(writer, body) })
|
|
result, _ := adapter.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
|
|
if calls != 1 || result[0].Code == 0 || strings.Contains(result[0].Message, "test-secret") {
|
|
t.Fatal("invalid response accepted, retried or leaked secret")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfiguration(t *testing.T) {
|
|
cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"timeout":"1s"}}`}, Channel: &entity.TSimChannels{AppKey: "app", AppSecret: "secret"}}
|
|
adapter, err := New(cfg)
|
|
if err != nil || adapter.resumePath != "" {
|
|
t.Fatalf("invalid default configuration: %v", err)
|
|
}
|
|
cfg.Channel.AppSecret = ""
|
|
if _, err := New(cfg); err == nil {
|
|
t.Fatal("missing secret accepted")
|
|
}
|
|
cfg.Channel.AppSecret = "secret"
|
|
for _, raw := range []string{`{"api":`, `{"api":{"timeout":"0s"}}`, `{"api":{"resumePath":"/simlist/get-sim-switchoff-by-number"}}`} {
|
|
cfg.Template.TempConfig = raw
|
|
if _, err := New(cfg); err == nil {
|
|
t.Fatal("invalid config accepted")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOperationBatchLimitAndHTTPFailure(t *testing.T) {
|
|
calls := 0
|
|
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
|
|
calls++
|
|
var payload struct {
|
|
Data string `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
t.Error(err)
|
|
}
|
|
var data struct {
|
|
Numbers []string `json:"numberArray"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload.Data), &data); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if len(data.Numbers) > 20 {
|
|
t.Error("operation batch exceeds 20")
|
|
}
|
|
writer.WriteHeader(http.StatusTooManyRequests)
|
|
})
|
|
keys := make([]*model.SimKey, 21)
|
|
for index := range keys {
|
|
keys[index] = &model.SimKey{ICCID: strconv.Itoa(index)}
|
|
}
|
|
output, err := adapter.ChangeSimStatus(context.Background(), &model.SimCardStatusChangeInput{SimKeys: keys, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}})
|
|
if err != nil || calls != 2 || len(output) != 21 {
|
|
t.Fatalf("calls=%d len=%d err=%v", calls, len(output), err)
|
|
}
|
|
for _, item := range output {
|
|
if item.Code == 0 || !strings.Contains(item.Message, "429") {
|
|
t.Fatal("HTTP failure must not appear as successful operation")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAccountGateSharing(t *testing.T) {
|
|
first, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.gate != second.gate {
|
|
t.Fatal("same account must share rate limiter")
|
|
}
|
|
first.gate.mu.Lock()
|
|
first.gate.next = time.Now().Add(time.Second)
|
|
first.gate.mu.Unlock()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
|
defer cancel()
|
|
if err := second.gate.wait(ctx); err == nil {
|
|
t.Fatal("rate limit wait must honor context timeout")
|
|
}
|
|
}
|
|
|
|
func TestCancelledAndUnsupportedDoNotCall(t *testing.T) {
|
|
calls := 0
|
|
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ })
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
adapter.QuerySimCardStatus(ctx, &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
|
|
adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{nil, {IMSI: "only-imsi"}}})
|
|
adapter.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
|
|
if calls != 0 {
|
|
t.Fatal("unexpected upstream request")
|
|
}
|
|
}
|