Files
zhiqiang feng ce4c2b088f fix: comprehensive bug fixes, hardening, and test coverage
Bug fixes:
- Fix nil-pointer panic in sendMessage SOAP fault handler (used err.Error() on nil)
- Fix missing return after SOAP fault response (caused fall-through to 200 OK)
- Fix timeout=0 shadowing: removed package-level constant, use configured value
- Fix receiveMessage not checking SOAP faults from CIIMS

Codec hardening:
- Replace fragile regex parsing with encoding/xml.Decoder for namespace-agnostic XML
- XML-escape all user inputs (user, pass, event) — not just message body
- Remove magic-number-based string slicing (split, GetErrMsg)

Transport improvements:
- Extract Client type with connection pooling (reuse http.Client across requests)
- Add CIIMSResponse domain type with IsFault(), ErrorMessage(), Messages()
- Add HTTPStatusError and ResponseTooLargeError typed errors
- Replace deprecated ioutil.ReadAll with io.ReadAll
- Add response body size limit (64 MiB)

Security hardening:
- Add MaxBytesReader (1MB request body limit) on both endpoints
- Add URL allowlist via CIIMS_ALLOWED_SERVERS env var
- Add normalizeBaseURL with scheme/host/query validation
- Add count bounds validation (1-1000) on /receive
- Add server-level timeouts (ReadHeaderTimeout, ReadTimeout, IdleTimeout)

Configuration:
- Introduce Config struct to replace package-level globals
- Add loadConfig() with full validation and error propagation
- Add getIntEnv() with positive-value enforcement

Test coverage (75 tests, 35 new):
- Phase 1: 8 internal tests (nil receiver, error types, SOAP+HTTP500, malformed XML)
- Phase 2: 19 pure function tests (normalizeBaseURL, parseAllowedServers, getIntEnv)
- Phase 3: 16 handler tests (send/receive success, SOAP fault, network error, HTTP 500,
  body too large, invalid JSON, missing fields, URL allowlist)
- Phase 4: 8 config/router tests (loadConfig, newServer, newRouter)

Toolchain:
- Upgrade Go 1.13 → 1.22, gin 1.6.3 → 1.10.0, testify 1.5.1 → 1.10.0

Cleanup:
- Remove dead code (unused post() function, commented-out defaults)
- Replace println with structured logging
- Add .gitignore
- Rewrite README with API docs, env vars, security considerations
2026-07-08 16:23:00 +08:00

280 lines
8.1 KiB
Go

package internal
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClientSend_SOAPFault(t *testing.T) {
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=UTF-8")
fmt.Fprint(w, ErrMsg)
}))
defer ciims.Close()
client, err := NewClient(10)
require.NoError(t, err)
resp, err := client.Send(context.Background(), ciims.URL+"/services/ExchangeService", "<dummy>")
assert.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.IsFault())
assert.Equal(t, "Can not find the event [FLOP-ESTT-ATC-ALL1]", resp.ErrorMessage())
}
func TestClientSend_Success(t *testing.T) {
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=UTF-8")
fmt.Fprint(w, SendOk)
}))
defer ciims.Close()
client, err := NewClient(10)
require.NoError(t, err)
resp, err := client.Send(context.Background(), ciims.URL+"/services/ExchangeService", "<dummy>")
assert.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, "", resp.ErrorMessage())
}
func TestClientSend_ReceiveMessages(t *testing.T) {
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=UTF-8")
fmt.Fprint(w, ReceiveResp)
}))
defer ciims.Close()
client, err := NewClient(10)
require.NoError(t, err)
resp, err := client.Send(context.Background(), ciims.URL+"/services/ExchangeService", "<dummy>")
assert.NoError(t, err)
require.NotNil(t, resp)
msgs := resp.Messages()
assert.Equal(t, 2, len(msgs))
assert.Equal(t, Msg, msgs[0])
assert.Equal(t, Msg, msgs[1])
}
func TestClientSend_NetworkTimeout(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := listener.Addr().String()
go func() {
conn, _ := listener.Accept()
if conn != nil {
io.Copy(io.Discard, conn)
conn.Close()
}
}()
client, err := NewClient(1)
require.NoError(t, err)
_, err = client.Send(context.Background(), "http://"+addr+"/services/ExchangeService", "<dummy>")
listener.Close()
assert.Error(t, err)
}
func TestClientSend_ServerError(t *testing.T) {
client, err := NewClient(1)
require.NoError(t, err)
_, err = client.Send(context.Background(), "http://127.0.0.1:1/nonexistent", "<dummy>")
assert.Error(t, err)
}
func TestClientSend_HTTPStatusError(t *testing.T) {
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "backend failed", http.StatusInternalServerError)
}))
defer ciims.Close()
client, err := NewClient(10)
require.NoError(t, err)
resp, err := client.Send(context.Background(), ciims.URL+"/services/ExchangeService", "<dummy>")
require.Error(t, err)
require.NotNil(t, resp)
var statusErr *HTTPStatusError
require.True(t, errors.As(err, &statusErr))
assert.Equal(t, http.StatusInternalServerError, statusErr.StatusCode)
assert.Contains(t, statusErr.Body, "backend failed")
}
func TestGetMsgs_Empty(t *testing.T) {
msgs := GetMsgs("<soap><body></body></soap>")
assert.Equal(t, 0, len(msgs))
}
func TestGetMsgs_DifferentNamespacePrefix(t *testing.T) {
resp := `<soap:Envelope><soap:Body><ns2:receiveResponse xmlns:ns2="http://ciims.bhia.itdcl.com/ExchangeService">
<ns2:out><ns2:string>&lt;MSG/&gt;</ns2:string></ns2:out>
</ns2:receiveResponse></soap:Body></soap:Envelope>`
msgs := GetMsgs(resp)
assert.Equal(t, 1, len(msgs))
assert.Equal(t, "<MSG/>", msgs[0])
}
func TestGetMsgs_AttributesWhitespaceAndMultiline(t *testing.T) {
resp := `<soap:Envelope><soap:Body><ns2:out>
<ns2:string id="1">
&lt;MSG&gt;
&lt;A&gt;1&lt;/A&gt;
&lt;/MSG&gt;
</ns2:string>
</ns2:out></soap:Body></soap:Envelope>`
msgs := GetMsgs(resp)
require.Len(t, msgs, 1)
assert.Contains(t, msgs[0], "<MSG>")
assert.Contains(t, msgs[0], "<A>1</A>")
}
func TestGetErrMsg_NoError(t *testing.T) {
errMsg := GetErrMsg(SendOk)
assert.Equal(t, "", errMsg)
}
func TestGetErrMsg_Empty(t *testing.T) {
errMsg := GetErrMsg("<soap><body></body></soap>")
assert.Equal(t, "", errMsg)
}
func TestGetErrMsg_AttributesWhitespaceAndMultiline(t *testing.T) {
resp := `<soap:Envelope><soap:Body><soap:Fault>
<errorMessage xmlns="http://msg.ciims.bhia.itdcl.com" code="20">
Can not find
the event
</errorMessage>
</soap:Fault></soap:Body></soap:Envelope>`
errMsg := GetErrMsg(resp)
assert.Contains(t, errMsg, "Can not find")
assert.Contains(t, errMsg, "the event")
}
func TestCreateSend_XMLEscapes(t *testing.T) {
result := CreateSend("user<>&\"'", "pass", 0, "event", false, "<MSG/>")
assert.Contains(t, result, "user&lt;&gt;&amp;&#34;&#39;")
assert.Contains(t, result, "pass")
assert.Contains(t, result, "&lt;MSG/&gt;")
}
func TestCreateReceive_XMLEscapes(t *testing.T) {
result := CreateReceive("user<>&\"'", "pass>", 2)
assert.Contains(t, result, "user&lt;&gt;&amp;&#34;&#39;")
assert.Contains(t, result, "pass&gt;")
}
func TestClientSend_ContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client, err := NewClient(10)
require.NoError(t, err)
_, err = client.Send(ctx, "http://127.0.0.1:1/nonexistent", "<dummy>")
assert.ErrorIs(t, err, context.Canceled)
}
func TestClientSend_ResponseTooLarge(t *testing.T) {
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=UTF-8")
_, _ = io.Copy(w, io.LimitReader(zeroReader{}, maxResponseBytes+1))
}))
defer ciims.Close()
client, err := NewClient(10)
require.NoError(t, err)
resp, err := client.Send(context.Background(), ciims.URL+"/services/ExchangeService", "<dummy>")
require.Error(t, err)
assert.Nil(t, resp)
var tooLarge *ResponseTooLargeError
assert.True(t, errors.As(err, &tooLarge))
}
func TestNewClient_ZeroOrNegativeTimeout(t *testing.T) {
_, err := NewClient(0)
require.Error(t, err)
assert.Contains(t, err.Error(), "positive")
_, err = NewClient(-1)
require.Error(t, err)
assert.Contains(t, err.Error(), "positive")
}
func TestNewClient_ValidTimeout(t *testing.T) {
client, err := NewClient(30)
require.NoError(t, err)
require.NotNil(t, client)
}
func TestHTTPStatusError_Truncation(t *testing.T) {
body := strings.Repeat("x", 600)
err := &HTTPStatusError{StatusCode: 500, Status: "500 Internal Server Error", Body: body}
errStr := err.Error()
assert.Contains(t, errStr, "...")
assert.LessOrEqual(t, len(errStr), 570) // 46 prefix + 512 body + 3 ellipsis = 561
}
func TestHTTPStatusError_EmptyBody(t *testing.T) {
err := &HTTPStatusError{StatusCode: 502, Status: "502 Bad Gateway", Body: ""}
assert.Equal(t, "ciims returned 502 Bad Gateway", err.Error())
}
func TestResponseTooLargeError_Error(t *testing.T) {
err := &ResponseTooLargeError{Limit: 100}
assert.Equal(t, "ciims response exceeds 100 bytes", err.Error())
}
func TestCIIMSResponse_NilReceiver(t *testing.T) {
var nilResp *CIIMSResponse
assert.False(t, nilResp.IsFault())
assert.Equal(t, "", nilResp.ErrorMessage())
assert.Nil(t, nilResp.Messages())
}
func TestClientSend_SOAPFaultWithHTTP500(t *testing.T) {
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=UTF-8")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, ErrMsg)
}))
defer ciims.Close()
client, err := NewClient(10)
require.NoError(t, err)
resp, err := client.Send(context.Background(), ciims.URL+"/services/ExchangeService", "<dummy>")
require.Error(t, err)
require.NotNil(t, resp)
var statusErr *HTTPStatusError
require.True(t, errors.As(err, &statusErr))
assert.Equal(t, http.StatusInternalServerError, statusErr.StatusCode)
assert.True(t, resp.IsFault())
assert.Equal(t, "Can not find the event [FLOP-ESTT-ATC-ALL1]", resp.ErrorMessage())
}
func TestXmlElementTexts_MalformedXML(t *testing.T) {
msgs := GetMsgs("<soap><Body><string>incomplete")
assert.Equal(t, 0, len(msgs))
}
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 'a'
}
return len(p), nil
}