package inbound import ( "bytes" "context" "encoding/binary" "encoding/json" "log" "net" "net/http" "net/http/httptest" "reflect" "strings" "sync" "sync/atomic" "testing" "time" cmpp "github.com/bigwhite/gocmpp" cmpputils "github.com/bigwhite/gocmpp/utils" ) type memoryPresenceStore struct { snapshots map[string]DownstreamPresence removed []string } type memoryRecoveryStore struct { decisions map[string]RecoveryStartDecision completed []DownstreamRecoveryStatus } type synchronizedBuffer struct { mu sync.Mutex buffer bytes.Buffer } func (b *synchronizedBuffer) Write(data []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buffer.Write(data) } func (b *synchronizedBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buffer.String() } func (m *memoryPresenceStore) TouchAccount(_ context.Context, snapshot DownstreamPresence) error { if m.snapshots == nil { m.snapshots = map[string]DownstreamPresence{} } m.snapshots[snapshot.Account] = snapshot return nil } func (m *memoryPresenceStore) RemoveAccount(_ context.Context, account string) error { delete(m.snapshots, account) m.removed = append(m.removed, account) return nil } func (m *memoryPresenceStore) ListAccounts(_ context.Context) ([]DownstreamPresence, error) { result := make([]DownstreamPresence, 0, len(m.snapshots)) for _, snapshot := range m.snapshots { result = append(result, snapshot) } return result, nil } func (m *memoryRecoveryStore) StartAccountRecovery(_ context.Context, account string, _ string) (RecoveryStartDecision, error) { if decision, ok := m.decisions[account]; ok { return decision, nil } return RecoveryStartDecision{Allowed: true}, nil } func (m *memoryRecoveryStore) CompleteAccountRecovery(_ context.Context, status DownstreamRecoveryStatus) error { m.completed = append(m.completed, status) return nil } func (m *memoryRecoveryStore) ListRecoveryStatuses(_ context.Context) ([]DownstreamRecoveryStatus, error) { return append([]DownstreamRecoveryStatus(nil), m.completed...), nil } func (m *memoryRecoveryStore) GetAccountRecoveryStatus(_ context.Context, account string) (DownstreamRecoveryStatus, error) { for _, item := range m.completed { if item.Account == account { return item, nil } } return DownstreamRecoveryStatus{Account: account}, nil } func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100001" password := "secret-hash" var gotAuth authRequest var gotSubmit submitRequest connectionEvents := make(chan downstreamConnectionEvent, 8) acknowledgements := make(chan map[string]any, 1) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/inbound/authenticate": if err := json.NewDecoder(r.Body).Decode(&gotAuth); err != nil { t.Fatalf("decode auth: %v", err) } _ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account}) case "/api/gateway/events/inbound/submit": if err := json.NewDecoder(r.Body).Decode(&gotSubmit); err != nil { t.Fatalf("decode submit: %v", err) } _ = json.NewEncoder(w).Encode(submitResponse{ Accepted: true, MessageID: "MSG-1", Messages: []submitResponseMessage{ {PhoneNumber: "13500002696", MessageID: "MSG-1"}, {PhoneNumber: "13600002696", MessageID: "MSG-2"}, }, }) case "/api/gateway/events/downstream/pending": _ = json.NewEncoder(w).Encode([]pendingDelivery{}) case "/api/gateway/events/inbound/connection": var event downstreamConnectionEvent if err := json.NewDecoder(r.Body).Decode(&event); err != nil { t.Fatalf("decode connection event: %v", err) } connectionEvents <- event w.WriteHeader(http.StatusOK) case "/api/gateway/events/downstream/sent": w.WriteHeader(http.StatusOK) case "/api/gateway/events/downstream/acknowledged": var event map[string]any if err := json.NewDecoder(r.Body).Decode(&event); err != nil { t.Fatalf("decode acknowledgement: %v", err) } acknowledgements <- event w.WriteHeader(http.StatusOK) case "/api/gateway/events/protocol-log": _ = json.NewEncoder(w).Encode(map[string]bool{"accepted": true}) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() addr := reserveTCPAddr(t) go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }() time.Sleep(300 * time.Millisecond) client := cmpp.NewClient(cmpp.V30) defer client.Disconnect() if err := client.Connect(addr, account, password, 2*time.Second); err != nil { t.Fatalf("connect inbound cmpp: %v", err) } select { case event := <-connectionEvents: if event.Status != "connected" || event.Account != account || event.ConnectionID == "" || event.RemoteIP == "" || event.Protocol != "cmpp30" { t.Fatalf("unexpected connection event: %+v", event) } case <-time.After(2 * time.Second): t.Fatal("expected downstream connected callback") } content, err := cmpputils.Utf8ToUcs2("测试入站") if err != nil { t.Fatalf("encode content: %v", err) } _, err = client.SendReqPkt(&cmpp.Cmpp3SubmitReqPkt{ PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1, ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696", MsgFmt: 8, MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000", DestUsrTl: 2, DestTerminalId: []string{"13500002696", "13600002696"}, MsgLength: uint8(len(content)), MsgContent: content, }) if err != nil { t.Fatalf("send submit: %v", err) } rsp := recvSubmitRsp(t, client) if rsp.Result != 0 || rsp.MsgId == 0 { t.Fatalf("unexpected submit response: %+v", rsp) } sendResult, err := PushReceiptWithResult(DownstreamReceipt{ DeliveryID: "delivery-1", MessageID: "MSG-2", PhoneNumber: "13600002696", ReceiptStatus: "delivered", DeliveredAt: time.Now().UTC().Format(time.RFC3339Nano), }) if err != nil || !sendResult.Sent { t.Fatalf("push receipt sent=%v err=%v", sendResult.Sent, err) } deliver := recvDeliver(t, client) if deliver.RegisterDelivery != 1 { t.Fatalf("expected receipt deliver, got %+v", deliver) } var receipt cmpp.CmppReceiptPkt if err := receipt.Unpack([]byte(deliver.MsgContent)); err != nil { t.Fatalf("unpack pushed receipt: %v", err) } if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId { t.Fatalf("unexpected pushed receipt: %+v", receipt) } if err := client.SendRspPkt(&cmpp.Cmpp3DeliverRspPkt{MsgId: deliver.MsgId, Result: 0}, deliver.SeqId); err != nil { t.Fatalf("send deliver response: %v", err) } select { case event := <-acknowledgements: if event["id"] != "delivery-1" || event["result"] != float64(0) { t.Fatalf("unexpected acknowledgement callback: %+v", event) } case <-time.After(2 * time.Second): t.Fatal("expected downstream acknowledgement callback") } if gotAuth.Account != account || gotAuth.AuthSource == "" || gotAuth.RemoteIP == "" || gotAuth.Version != "cmpp30" || gotAuth.RequestedVersion != uint8(cmpp.V30) { t.Fatalf("unexpected auth payload: %+v", gotAuth) } if gotSubmit.Account != account || gotSubmit.PhoneNumber != "13500002696" || gotSubmit.Content != "测试入站" || !reflect.DeepEqual(gotSubmit.PhoneNumbers, []string{"13500002696", "13600002696"}) { t.Fatalf("unexpected submit payload: %+v", gotSubmit) } } func TestDecodeInboundLongMessageStripsConcatUDHBeforeUCS2Decode(t *testing.T) { payload, err := cmpputils.Utf8ToUcs2("【深圳市合正物业服务有限公司】第一片正文") if err != nil { t.Fatalf("encode content: %v", err) } raw := append([]byte{0x05, 0x00, 0x03, 0x10, 0x02, 0x01}, []byte(payload)...) content, fragment, err := decodeInboundSubmitContent(inboundSubmitPacket{ pkTotal: 2, pkNumber: 1, tpUdhi: 1, msgFmt: 8, msgContent: string(raw), }) if err != nil { t.Fatalf("decode long message: %v", err) } if content != "【深圳市合正物业服务有限公司】第一片正文" { t.Fatalf("decoded content = %q", content) } if fragment == nil || fragment.Reference != 0x10 || fragment.Total != 2 || fragment.Index != 1 { t.Fatalf("unexpected fragment metadata: %+v", fragment) } } func TestDecodeInboundLongMessageSupports16BitConcatReference(t *testing.T) { payload, err := cmpputils.Utf8ToUcs2("第二片正文") if err != nil { t.Fatalf("encode content: %v", err) } raw := append([]byte{0x06, 0x08, 0x04, 0x12, 0x34, 0x02, 0x02}, []byte(payload)...) content, fragment, err := decodeInboundSubmitContent(inboundSubmitPacket{ pkTotal: 2, pkNumber: 2, tpUdhi: 1, msgFmt: 8, msgContent: string(raw), }) if err != nil { t.Fatalf("decode long message: %v", err) } if content != "第二片正文" { t.Fatalf("decoded content = %q", content) } if fragment == nil || fragment.Reference != 0x1234 || fragment.Total != 2 || fragment.Index != 2 { t.Fatalf("unexpected fragment metadata: %+v", fragment) } } func TestInboundServerForwardsLongMessageFragmentsWithoutUDHAndAcknowledgesEachSubmit(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100001" password := "secret-hash" var mu sync.Mutex submits := make([]submitRequest, 0, 2) api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/inbound/authenticate": _ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account}) case "/api/gateway/events/inbound/submit": var submit submitRequest if err := json.NewDecoder(r.Body).Decode(&submit); err != nil { t.Fatalf("decode submit: %v", err) } mu.Lock() submits = append(submits, submit) count := len(submits) mu.Unlock() status := "fragment_pending" if count == 2 { status = "accepted" } _ = json.NewEncoder(w).Encode(map[string]any{ "accepted": true, "messageId": "MSG-LONG-1", "status": status, }) case "/api/gateway/events/downstream/pending": _ = json.NewEncoder(w).Encode([]pendingDelivery{}) case "/api/gateway/events/inbound/connection": w.WriteHeader(http.StatusOK) case "/api/gateway/events/protocol-log": _ = json.NewEncoder(w).Encode(map[string]bool{"accepted": true}) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() addr := reserveTCPAddr(t) go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }() time.Sleep(300 * time.Millisecond) client := cmpp.NewClient(cmpp.V20) defer client.Disconnect() if err := client.Connect(addr, account, password, 2*time.Second); err != nil { t.Fatalf("connect CMPP2 inbound: %v", err) } parts := []string{"【签名】第一片", "第二片正文"} var firstResponseMsgID uint64 for index, text := range parts { payload, _ := cmpputils.Utf8ToUcs2(text) raw := append([]byte{0x05, 0x00, 0x03, 0x22, 0x02, byte(index + 1)}, []byte(payload)...) if _, err := client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{ PkTotal: 2, PkNumber: uint8(index + 1), TpUdhi: 1, RegisteredDelivery: 1, MsgLevel: 1, ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696", MsgFmt: 8, MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000", DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(raw)), MsgContent: string(raw), }); err != nil { t.Fatalf("send long-message fragment %d: %v", index+1, err) } if rsp := recvSubmitRsp20(t, client); rsp.Result != 0 || rsp.MsgId == 0 { t.Fatalf("unexpected fragment %d response: %+v", index+1, rsp) } else if index == 0 { firstResponseMsgID = rsp.MsgId } } mu.Lock() defer mu.Unlock() if len(submits) != 2 { t.Fatalf("submit API calls = %d, want 2", len(submits)) } for index, submit := range submits { if submit.Content != parts[index] { t.Fatalf("fragment %d content = %q", index+1, submit.Content) } if submit.RegisteredDelivery != 1 { t.Fatalf("fragment %d Registered_Delivery = %d, want 1", index+1, submit.RegisteredDelivery) } if submit.LongMessage == nil || submit.LongMessage.Reference != 0x22 || submit.LongMessage.Total != 2 || submit.LongMessage.Index != index+1 || submit.LongMessage.Format != 8 { t.Fatalf("fragment %d metadata = %+v", index+1, submit.LongMessage) } } downstreamRegistry.RLock() session := downstreamRegistry.byMessageID["MSG-LONG-1"] downstreamRegistry.RUnlock() if session == nil || session.gatewayMsgID != firstResponseMsgID { t.Fatalf("stored long-message Msg_Id = %v, want first fragment Msg_Id %v", session, firstResponseMsgID) } } func TestSubmitResponsePrecedesQueuedFailureReceipt(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100001" password := "secret-hash" var mu sync.Mutex var submit submitRequest pendingReturned := false api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/inbound/authenticate": _ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account}) case "/api/gateway/events/inbound/submit": mu.Lock() defer mu.Unlock() if err := json.NewDecoder(r.Body).Decode(&submit); err != nil { t.Fatalf("decode submit: %v", err) } _ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-ORDER"}) case "/api/gateway/events/downstream/pending": mu.Lock() defer mu.Unlock() if submit.SequenceID == 0 || pendingReturned { _ = json.NewEncoder(w).Encode([]pendingDelivery{}) return } payload, _ := json.Marshal(DownstreamReceipt{ Account: account, MessageID: "MSG-ORDER", PhoneNumber: "13500002696", ReceiptStatus: "undelivered", RawStatus: "REJECTD", SubmitSequenceID: submit.SequenceID, }) pendingReturned = true _ = json.NewEncoder(w).Encode([]pendingDelivery{{ ID: "delivery-order", DeliveryType: "receipt", Payload: payload, CreatedAt: time.Now().UTC(), }}) case "/api/gateway/events/inbound/connection", "/api/gateway/events/downstream/sent": w.WriteHeader(http.StatusOK) case "/api/gateway/events/protocol-log": _ = json.NewEncoder(w).Encode(map[string]bool{"accepted": true}) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() addr := reserveTCPAddr(t) go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }() time.Sleep(300 * time.Millisecond) client := cmpp.NewClient(cmpp.V20) defer client.Disconnect() if err := client.Connect(addr, account, password, 2*time.Second); err != nil { t.Fatalf("connect CMPP2 inbound: %v", err) } content, _ := cmpputils.Utf8ToUcs2("测试回执顺序") if _, err := client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{ PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1, ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696", MsgFmt: 8, MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000", DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(content)), MsgContent: content, }); err != nil { t.Fatalf("send submit: %v", err) } first, err := client.RecvAndUnpackPkt(2 * time.Second) if err != nil { t.Fatalf("receive first packet: %v", err) } submitResponse, ok := first.(*cmpp.Cmpp2SubmitRspPkt) if !ok || submitResponse.Result != 0 || submitResponse.MsgId == 0 { t.Fatalf("first packet must be successful SUBMIT_RESP, got %T %+v", first, first) } deliver := recvDeliver20(t, client) if deliver.MsgId != submitResponse.MsgId { t.Fatalf("receipt Msg_Id=%d does not match SUBMIT_RESP Msg_Id=%d", deliver.MsgId, submitResponse.MsgId) } } func TestDailyLimitRejectsSubmitSynchronouslyWithoutPendingReceipt(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100001" password := "secret-hash" var pendingCalls atomic.Int32 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/inbound/authenticate": _ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account}) case "/api/gateway/events/inbound/submit": _ = json.NewEncoder(w).Encode(submitResponse{Accepted: false, Result: 8, MessageID: "MSG-LIMIT"}) case "/api/gateway/events/downstream/pending": pendingCalls.Add(1) _ = json.NewEncoder(w).Encode([]pendingDelivery{}) case "/api/gateway/events/inbound/connection": w.WriteHeader(http.StatusOK) case "/api/gateway/events/protocol-log": _ = json.NewEncoder(w).Encode(map[string]bool{"accepted": true}) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() addr := reserveTCPAddr(t) go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }() time.Sleep(300 * time.Millisecond) client := cmpp.NewClient(cmpp.V20) defer client.Disconnect() if err := client.Connect(addr, account, password, 2*time.Second); err != nil { t.Fatalf("connect CMPP2 inbound: %v", err) } time.Sleep(100 * time.Millisecond) pendingCallsAfterBind := pendingCalls.Load() content, _ := cmpputils.Utf8ToUcs2("日限额拒绝") if _, err := client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{ PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1, ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696", MsgFmt: 8, MsgSrc: account, FeeType: "02", FeeCode: "0", SrcId: "10690000", DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(content)), MsgContent: content, }); err != nil { t.Fatalf("send submit: %v", err) } rsp := recvSubmitRsp20(t, client) if rsp.Result != 8 || rsp.MsgId != 0 { t.Fatalf("expected synchronous daily-limit result=8 without Msg_Id, got %+v", rsp) } time.Sleep(100 * time.Millisecond) if pendingCalls.Load() != pendingCallsAfterBind { t.Fatalf("daily-limit rejection must not add downstream receipt polling, before=%d after=%d", pendingCallsAfterBind, pendingCalls.Load()) } } func TestReceiptWithoutOriginalSequenceIsUnrecoverable(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() result, err := PushReceiptWithResult(DownstreamReceipt{ DeliveryID: "delivery-history", Account: "100001", MessageID: "MSG-HISTORY", ReceiptStatus: "undelivered", }) if err != nil { t.Fatalf("push receipt: %v", err) } if result.Sent || result.Retryable || result.ReasonCode != "MISSING_SUBMIT_SEQUENCE_ID" { t.Fatalf("unexpected result: %+v", result) } } func TestRecoverableReceiptWaitsForClientConnection(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() result, err := PushReceiptWithResult(DownstreamReceipt{ DeliveryID: "delivery-retry", Account: "100001", MessageID: "MSG-RETRY", SubmitSequenceID: 77, ReceiptStatus: "delivered", }) if err != nil { t.Fatalf("push receipt: %v", err) } if result.Sent || !result.Retryable || result.ReasonCode != "CLIENT_DISCONNECTED" { t.Fatalf("unexpected result: %+v", result) } } func TestReceiptWaitsUntilCurrentSubmitResponseHasBeenWritten(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() conn := &cmpp.Conn{} session := &downstreamSession{ messageID: "MSG-LONG-1", account: "100001", conn: conn, } downstreamRegistry.Lock() downstreamRegistry.byMessageID[session.messageID] = session downstreamRegistry.byAccount[session.account] = session downstreamRegistry.byConn[conn] = session downstreamRegistry.Unlock() release := beginDownstreamSubmitBarrier(conn) result, err := PushReceiptWithResult(DownstreamReceipt{ DeliveryID: "delivery-long-failed", Account: session.account, MessageID: session.messageID, ReceiptStatus: "undelivered", }) if err != nil { t.Fatalf("push guarded receipt: %v", err) } if result.Sent || !result.Retryable || result.ReasonCode != "SUBMIT_RESPONSE_PENDING" { t.Fatalf("unexpected guarded result: %+v", result) } release() if downstreamSubmitResponsePending(conn) { t.Fatal("submit response barrier remained active after release") } } func TestInboundServerNegotiatesCMPP2AndUsesAuthenticatedAccountForSubmit(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100001" password := "secret-hash" var gotSubmit submitRequest submitCalls := 0 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/inbound/authenticate": _ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: "SP0001"}) case "/api/gateway/events/inbound/submit": submitCalls++ if err := json.NewDecoder(r.Body).Decode(&gotSubmit); err != nil { t.Fatalf("decode submit: %v", err) } _ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-CMPP2"}) case "/api/gateway/events/downstream/pending": _ = json.NewEncoder(w).Encode([]pendingDelivery{}) case "/api/gateway/events/inbound/connection": w.WriteHeader(http.StatusOK) case "/api/gateway/events/protocol-log": _ = json.NewEncoder(w).Encode(map[string]bool{"accepted": true}) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() addr := reserveTCPAddr(t) go func() { _ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe() }() time.Sleep(300 * time.Millisecond) client := cmpp.NewClient(cmpp.V20) defer client.Disconnect() if err := client.Connect(addr, account, password, 2*time.Second); err != nil { t.Fatalf("connect CMPP2 inbound: %v", err) } content, err := cmpputils.Utf8ToUcs2("测试CMPP2") if err != nil { t.Fatalf("encode content: %v", err) } _, err = client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{ PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1, ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696", MsgFmt: 8, MsgSrc: "SP0001", FeeType: "02", FeeCode: "0", SrcId: "10690000", DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(content)), MsgContent: content, }) if err != nil { t.Fatalf("send CMPP2 submit: %v", err) } rsp := recvSubmitRsp20(t, client) if rsp.Result != 0 || rsp.MsgId == 0 { t.Fatalf("unexpected CMPP2 submit response: %+v", rsp) } if gotSubmit.Account != account || gotSubmit.PhoneNumber != "13500002696" || gotSubmit.Content != "测试CMPP2" { t.Fatalf("unexpected CMPP2 submit payload: %+v", gotSubmit) } _, err = client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{ PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1, ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696", MsgFmt: 8, MsgSrc: "BAD001", FeeType: "02", FeeCode: "0", SrcId: "10690000", DestUsrTl: 1, DestTerminalId: []string{"13500002696"}, MsgLength: uint8(len(content)), MsgContent: content, }) if err != nil { t.Fatalf("send mismatched enterprise code: %v", err) } if rejected := recvSubmitRsp20(t, client); rejected.Result != 9 { t.Fatalf("expected enterprise code rejection, got %+v", rejected) } if submitCalls != 1 { t.Fatalf("submit API calls = %d, want 1", submitCalls) } delivered, err := PushReceipt(DownstreamReceipt{ MessageID: "MSG-CMPP2", PhoneNumber: "13500002696", ReceiptStatus: "delivered", DeliveredAt: time.Now().UTC().Format(time.RFC3339Nano), }) if err != nil || !delivered { t.Fatalf("push CMPP2 receipt delivered=%v err=%v", delivered, err) } deliver := recvDeliver20(t, client) if deliver.RegisterDelivery != 1 { t.Fatalf("expected CMPP2 receipt deliver, got %+v", deliver) } } func TestPostIncludesAPIErrorResponseBody(t *testing.T) { api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"message":"CMPP submit content does not match an approved template and signature","statusCode":400}`)) })) defer api.Close() err := (Server{APIBaseURL: api.URL}).post(context.Background(), "/inbound/submit", map[string]string{"account": "100001"}, nil) if err == nil || !bytes.Contains([]byte(err.Error()), []byte("CMPP submit content does not match an approved template and signature")) { t.Fatalf("expected API response body in error, got %v", err) } } func TestInboundServerLogsReadUnpackFailure(t *testing.T) { addr := reserveTCPAddr(t) var logs synchronizedBuffer go func() { _ = (Server{Addr: addr, LogWriter: &logs}).ListenAndServe() }() time.Sleep(300 * time.Millisecond) conn, err := net.DialTimeout("tcp", addr, time.Second) if err != nil { t.Fatalf("connect inbound server: %v", err) } defer conn.Close() if err := binary.Write(conn, binary.BigEndian, uint32(1)); err != nil { t.Fatalf("write invalid packet length: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if strings.Contains(logs.String(), "read/unpack packet failed") && strings.Contains(logs.String(), "total_length") { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("missing read/unpack failure log: %s", logs.String()) } func TestNormalizeInboundSubmitSupportsCMPP2AndCMPP3(t *testing.T) { tests := []struct { name string packet any protocol string }{ {name: "cmpp2", packet: &cmpp.Cmpp2SubmitReqPkt{MsgSrc: "100001", SeqId: 20}, protocol: "cmpp20"}, {name: "cmpp3", packet: &cmpp.Cmpp3SubmitReqPkt{MsgSrc: "100001", SeqId: 30}, protocol: "cmpp30"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, ok := normalizeInboundSubmit(test.packet) if !ok || got.protocol != test.protocol || got.msgSrc != "100001" { t.Fatalf("unexpected normalized packet: %+v ok=%v", got, ok) } }) } } func TestSetInboundSubmitResponseSupportsCMPP2AndCMPP3(t *testing.T) { cmpp2 := &cmpp.Cmpp2SubmitRspPkt{} setInboundSubmitResponse(cmpp2, 101, 9) if cmpp2.MsgId != 101 || cmpp2.Result != 9 { t.Fatalf("unexpected CMPP2 response: %+v", cmpp2) } cmpp3 := &cmpp.Cmpp3SubmitRspPkt{} setInboundSubmitResponse(cmpp3, 202, 9) if cmpp3.MsgId != 202 || cmpp3.Result != 9 { t.Fatalf("unexpected CMPP3 response: %+v", cmpp3) } } func TestFindSessionByConnUsesAuthenticatedConnection(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() conn := &cmpp.Conn{} session := &downstreamSession{ account: "100001", protocol: "cmpp20", conn: conn, } downstreamRegistry.byConn[conn] = session if got := findSessionByConn(conn); got != session { t.Fatalf("unexpected session: %+v", got) } if got := findSessionByConn(&cmpp.Conn{}); got != nil { t.Fatalf("expected missing session, got %+v", got) } } func TestFlushOnlineAccountsFetchesPendingDeliveries(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100001" calls := 0 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/downstream/pending": calls++ var payload pendingDeliveryRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode pending request: %v", err) } if payload.Account != account { t.Fatalf("unexpected account: %+v", payload) } _ = json.NewEncoder(w).Encode([]pendingDelivery{}) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() downstreamRegistry.Lock() downstreamRegistry.byAccount[account] = &downstreamSession{account: account} downstreamRegistry.Unlock() server := Server{APIBaseURL: api.URL + "/api"} server.flushOnlineAccounts(log.Default()) if calls != 1 { t.Fatalf("pending fetch calls = %d, want 1", calls) } } func TestRecoverPendingCandidatesFetchesPresenceAccounts(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100009" calls := 0 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/downstream/pending": calls++ var payload pendingDeliveryRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatalf("decode pending request: %v", err) } if payload.Account != account { t.Fatalf("unexpected account: %+v", payload) } _ = json.NewEncoder(w).Encode([]pendingDelivery{}) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() store := &memoryPresenceStore{ snapshots: map[string]DownstreamPresence{ account: { Account: account, GatewayInstanceID: "gateway-a", State: "connected", UpdatedAt: time.Now().UTC(), }, }, } server := Server{APIBaseURL: api.URL + "/api", PresenceStore: store} server.recoverPendingCandidates(log.Default()) if calls != 1 { t.Fatalf("recovery pending fetch calls = %d, want 1", calls) } } func TestRecoverPendingCandidatesWritesWaitingConnectionStatus(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() account := "100010" api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/gateway/events/downstream/pending": _ = json.NewEncoder(w).Encode([]pendingDelivery{{ ID: "delivery-1", DeliveryType: "receipt", Payload: json.RawMessage(`{"account":"100010","messageId":"MSG-404","submitSequenceId":77,"phoneNumber":"13800000001","receiptStatus":"delivered"}`), }}) case "/api/gateway/events/downstream/failed": w.WriteHeader(http.StatusOK) case "/api/gateway/events/downstream/recovery-status": w.WriteHeader(http.StatusOK) default: t.Fatalf("unexpected api path: %s", r.URL.Path) } })) defer api.Close() recovery := &memoryRecoveryStore{} store := &memoryPresenceStore{ snapshots: map[string]DownstreamPresence{ account: { Account: account, GatewayInstanceID: "gateway-a", State: "connected", UpdatedAt: time.Now().UTC(), }, }, } server := Server{APIBaseURL: api.URL + "/api", PresenceStore: store, RecoveryStore: recovery} server.recoverPendingCandidates(log.Default()) if len(recovery.completed) != 1 { t.Fatalf("completed recovery statuses = %d, want 1", len(recovery.completed)) } if recovery.completed[0].State != "waiting_connection" { t.Fatalf("unexpected recovery status: %+v", recovery.completed[0]) } } func TestRememberAndForgetAccountUpdatesPresenceStore(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() store := &memoryPresenceStore{} session := downstreamSession{ account: "100001", srcID: "10690000", remoteIP: "127.0.0.1", connectedAt: time.Now().UTC(), conn: &cmpp.Conn{}, mu: &sync.Mutex{}, presence: store, instanceID: "gateway-a", } if !rememberAccount(&session, 1) { t.Fatal("expected account session to be accepted") } snapshot, ok := store.snapshots["100001"] if !ok { t.Fatal("expected presence snapshot to be stored") } if snapshot.Account != "100001" || snapshot.GatewayInstanceID != "gateway-a" || snapshot.State != "connected" { t.Fatalf("unexpected snapshot: %+v", snapshot) } forgetDownstream(downstreamRegistry.byAccount["100001"]) if len(store.removed) != 1 || store.removed[0] != "100001" { t.Fatalf("unexpected removed accounts: %+v", store.removed) } } func TestRememberAccountEnforcesConfiguredConnectionLimit(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() first := &downstreamSession{account: "100001", connectionID: "conn-1", conn: &cmpp.Conn{}, mu: &sync.Mutex{}} second := &downstreamSession{account: "100001", connectionID: "conn-2", conn: &cmpp.Conn{}, mu: &sync.Mutex{}} third := &downstreamSession{account: "100001", connectionID: "conn-3", conn: &cmpp.Conn{}, mu: &sync.Mutex{}} if !rememberAccount(first, 2) || !rememberAccount(second, 2) { t.Fatal("expected first two sessions to fit maxConnections=2") } if rememberAccount(third, 2) { t.Fatal("expected third session to be rejected by maxConnections=2") } forgetDownstream(first) if !rememberAccount(third, 2) { t.Fatal("expected a new session after a previous connection is released") } } func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() events := make(chan downstreamDeliveryLifecycleEvent, 1) protocolEvents := make(chan protocolLogEvent, 1) conn := &cmpp.Conn{} session := &downstreamSession{ conn: conn, connectionID: "conn-1", deliveryReport: func(event downstreamDeliveryLifecycleEvent) { events <- event }, tenantID: "tenant-1", applicationID: "app-1", account: "607532", messageID: "MSG-LONG-1", phoneNumber: "18821203795", protocolLog: func(event protocolLogEvent) { protocolEvents <- event }, } registerDownstreamAck(session, "delivery-1", 37, 9016479179509871733, time.Now().Add(time.Second)) handleDownstreamAcknowledgement(conn, 37, 9016479179509871733, 0, log.Default()) select { case event := <-events: if event.Kind != "acknowledged" || event.DeliveryID != "delivery-1" || event.Result != 0 || event.SequenceID != 37 { t.Fatalf("unexpected acknowledgement event: %+v", event) } case <-time.After(time.Second): t.Fatal("timed out waiting acknowledgement event") } select { case event := <-protocolEvents: if event.Protocol != "cmpp" || event.Direction != "client_to_platform" || event.EventType != "deliver_resp" { t.Fatalf("unexpected acknowledgement protocol event: %+v", event) } if event.MessageID != "MSG-LONG-1" || event.GatewayMessageID != "9016479179509871733" || event.ResultCode != "0" { t.Fatalf("unexpected acknowledgement identifiers: %+v", event) } case <-time.After(time.Second): t.Fatal("timed out waiting acknowledgement protocol event") } } func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() conn := &cmpp.Conn{} session := &downstreamSession{ account: "100001", protocol: "cmpp20", conn: conn, mu: &sync.Mutex{}, connectionID: "conn-1", } if !rememberAccount(session, 1) { t.Fatal("expected account session to be accepted") } if session := findReceiptSession("MSG-NOT-REMEMBERED", "100001"); session != nil { t.Fatalf("receipt unexpectedly fell back to account session: %+v", session) } recovered := recoverReceiptSession(DownstreamReceipt{ MessageID: "MSG-NOT-REMEMBERED", Account: "100001", SubmitSequenceID: 1216579149, }) if recovered == nil { t.Fatal("expected persisted submit sequence to recover receipt session") } if recovered.gatewayMsgID != messageIDFrom("MSG-NOT-REMEMBERED", 1216579149) || recovered.gatewayMsgID == 0 { t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID) } first := recoverReceiptSession(DownstreamReceipt{ MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, }) second := recoverReceiptSession(DownstreamReceipt{ MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, }) if first == nil || second == nil || first.gatewayMsgID != second.gatewayMsgID || first.gatewayMsgID != messageIDFrom("MSG-GROUP", 77) { t.Fatalf("multi-destination recovery did not preserve the original Msg_Id: first=%+v second=%+v", first, second) } } func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) { session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)} first := downstreamReceiptMessageID(DownstreamReceipt{ MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 101, }, session) second := downstreamReceiptMessageID(DownstreamReceipt{ MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 102, }, session) if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) { t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second) } if first == second { t.Fatalf("different fragment Sequence_Id values produced the same Msg_Id: %d", first) } } func TestSendDownstreamRejectsZeroMessageID(t *testing.T) { _, err := sendDownstream( &downstreamSession{mu: &sync.Mutex{}}, &cmpp.Cmpp2DeliverReqPkt{MsgId: 0}, "delivery-zero", ) if err == nil || !strings.Contains(err.Error(), "Msg_Id=0") { t.Fatalf("expected zero Msg_Id rejection, got %v", err) } } func TestDownstreamDeliveryReportsAckTimeout(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() events := make(chan downstreamDeliveryLifecycleEvent, 1) session := &downstreamSession{ conn: &cmpp.Conn{}, connectionID: "conn-1", deliveryReport: func(event downstreamDeliveryLifecycleEvent) { events <- event }, } registerDownstreamAck(session, "delivery-timeout", 38, 9017467844344255865, time.Now().Add(20*time.Millisecond)) select { case event := <-events: if event.Kind != "failed" || event.FailureType != "ack_timeout" || event.DeliveryID != "delivery-timeout" { t.Fatalf("unexpected timeout event: %+v", event) } case <-time.After(time.Second): t.Fatal("timed out waiting acknowledgement timeout") } } func recvDeliver(t *testing.T, client *cmpp.Client) *cmpp.Cmpp3DeliverReqPkt { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond) if err != nil { continue } if deliver, ok := packet.(*cmpp.Cmpp3DeliverReqPkt); ok { return deliver } } t.Fatal("timed out waiting deliver request") return nil } func recvDeliver20(t *testing.T, client *cmpp.Client) *cmpp.Cmpp2DeliverReqPkt { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond) if err != nil { continue } if deliver, ok := packet.(*cmpp.Cmpp2DeliverReqPkt); ok { return deliver } } t.Fatal("timed out waiting CMPP2 deliver request") return nil } func resetDownstreamRegistry() { downstreamRegistry.Lock() defer downstreamRegistry.Unlock() downstreamRegistry.byAccount = make(map[string]*downstreamSession) downstreamRegistry.byMessageID = make(map[string]*downstreamSession) downstreamRegistry.byConn = make(map[*cmpp.Conn]*downstreamSession) downstreamAckRegistry.Lock() for _, tracker := range downstreamAckRegistry.items { if tracker.timer != nil { tracker.timer.Stop() } } downstreamAckRegistry.items = make(map[string]*downstreamAckTracker) downstreamAckRegistry.Unlock() downstreamSubmitBarrier.Lock() downstreamSubmitBarrier.byConn = make(map[*cmpp.Conn]int) downstreamSubmitBarrier.Unlock() } func reserveTCPAddr(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("reserve tcp addr: %v", err) } addr := listener.Addr().String() if err := listener.Close(); err != nil { t.Fatalf("close reserved listener: %v", err) } return addr } func recvSubmitRsp(t *testing.T, client *cmpp.Client) *cmpp.Cmpp3SubmitRspPkt { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond) if err != nil { continue } if rsp, ok := packet.(*cmpp.Cmpp3SubmitRspPkt); ok { return rsp } } t.Fatal("timed out waiting submit response") return nil } func recvSubmitRsp20(t *testing.T, client *cmpp.Client) *cmpp.Cmpp2SubmitRspPkt { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond) if err != nil { continue } if rsp, ok := packet.(*cmpp.Cmpp2SubmitRspPkt); ok { return rsp } } t.Fatal("timed out waiting CMPP2 submit response") return nil }