Compare commits
10
Commits
6c1744eaea
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4c2b088f | ||
|
|
dd3f6769f6 | ||
|
|
04bafe01e7 | ||
|
|
2094ab42f7 | ||
|
|
6f03971b6c | ||
|
|
a37ccea83f | ||
|
|
49d4b0c32a | ||
|
|
4dcc256c28 | ||
|
|
6ae7a86587 | ||
|
|
38618d7ef9 |
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# Binaries
|
||||||
|
ciimsproxy
|
||||||
|
/main
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
# Go local caches
|
||||||
|
.gocache/
|
||||||
|
.gomodcache/
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# CIIMS web service message exchange proxy
|
||||||
|
|
||||||
|
A thin HTTP proxy that translates JSON POST requests into SOAP/XML calls to a CIIMS
|
||||||
|
ExchangeService backend. Designed for airport operational message exchange (AODB/FLOP).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### POST /send
|
||||||
|
|
||||||
|
Send a message to CIIMS.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://domain.tld", // optional, overrides CIIMS_SERVER
|
||||||
|
"user": "ciims username", // required
|
||||||
|
"pass": "ciims password", // required
|
||||||
|
"event": "route event id", // required
|
||||||
|
"priority": 0, // optional, defaults to 0
|
||||||
|
"val": false, // optional, defaults to false
|
||||||
|
"msg": "ciims xml body" // required
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /receive
|
||||||
|
|
||||||
|
Receive messages from CIIMS.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "http://domain.tld", // optional, overrides CIIMS_SERVER
|
||||||
|
"user": "ciims username", // required
|
||||||
|
"pass": "ciims password", // required
|
||||||
|
"count": 3 // required, 1-1000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /ping
|
||||||
|
|
||||||
|
Health check — returns `{"message": "pong"}`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Environment Variable | Description | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `CIIMS_SERVER` | Base URL of the default CIIMS ExchangeService | (required) |
|
||||||
|
| `CIIMS_ALLOWED_SERVERS` | Comma-separated extra CIIMS base URLs allowed for request `url` overrides | empty |
|
||||||
|
| `PROXY_LISTEN` | Bind address for the proxy HTTP server | `:9090` |
|
||||||
|
| `CIIMS_TIMEOUT` | Outbound request timeout in seconds | `240` |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Authentication:** This proxy does not authenticate callers. Deploy behind a firewall or
|
||||||
|
add a reverse-proxy layer (nginx, envoy) with API key / basic auth.
|
||||||
|
- **TLS:** Use a TLS-terminating reverse proxy in production. The proxy itself serves plain
|
||||||
|
HTTP and transmits CIIMS credentials with every request.
|
||||||
|
- **Input limits:** Request bodies are limited to 1 MB, CIIMS responses are limited to 64 MiB, and the `count` parameter on `/receive`
|
||||||
|
is capped at 1000.
|
||||||
|
- **URL overrides:** Per-request `url` values are accepted only when they match `CIIMS_SERVER` or an entry in `CIIMS_ALLOWED_SERVERS` after normalization.
|
||||||
|
- **Credentials:** CIIMS usernames and passwords are XML-escaped before being embedded in
|
||||||
|
SOAP envelopes. They are not logged.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o ciimsproxy ./cmd/main/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the proxy
|
||||||
|
CIIMS_SERVER=http://192.168.1.100:8080 ./ciimsproxy
|
||||||
|
|
||||||
|
# Send a message
|
||||||
|
curl -X POST http://localhost:9090/send \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"user":"FIMS","pass":"FIMS","event":"FLOP-CHDT","msg":"<MSG/>"}'
|
||||||
|
|
||||||
|
# Receive messages
|
||||||
|
curl -X POST http://localhost:9090/receive \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"user":"FIMS","pass":"FIMS","count":5}'
|
||||||
|
```
|
||||||
+272
-51
@@ -1,86 +1,307 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gzzn.com/mini/ciimsproxy/internal"
|
"gzzn.com/mini/ciimsproxy/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
servicePrefix string = "/services/ExchangeService"
|
servicePrefix = "/services/ExchangeService"
|
||||||
// defaultURL string = "http://localhost:8080"
|
defaultTimeout = 240
|
||||||
//defaultURL string = "http://192.168.10.96:8080"
|
maxMsgLen = 1048576 // 1MB
|
||||||
|
maxCount = 1000
|
||||||
|
readHeaderTimeout = 5 * time.Second
|
||||||
|
readTimeout = 10 * time.Second
|
||||||
|
writeTimeoutGrace = 10 * time.Second
|
||||||
|
idleTimeout = 60 * time.Second
|
||||||
|
allowedServersEnvVarName = "CIIMS_ALLOWED_SERVERS"
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultURL = ""
|
type Config struct {
|
||||||
|
ServerURL string
|
||||||
|
AllowedServers map[string]string
|
||||||
|
Listen string
|
||||||
|
Timeout int
|
||||||
|
Client *internal.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type sendRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
User string `json:"user" binding:"required"`
|
||||||
|
Pass string `json:"pass" binding:"required"`
|
||||||
|
Event string `json:"event" binding:"required"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Val bool `json:"val"`
|
||||||
|
Msg string `json:"msg" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type receiveRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
User string `json:"user" binding:"required"`
|
||||||
|
Pass string `json:"pass" binding:"required"`
|
||||||
|
Count int `json:"count" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
defaultURL = os.Getenv("CIIMS_SERVER")
|
config, err := loadConfig()
|
||||||
listen := os.Getenv("PROXY_LISTEN")
|
if err != nil {
|
||||||
if listen == "" {
|
log.Printf("[ERROR] configuration error: %v", err)
|
||||||
listen = ":9090"
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
println("use ciims : " + defaultURL)
|
|
||||||
|
log.Printf("use ciims: %s", config.ServerURL)
|
||||||
|
srv := newServer(config)
|
||||||
|
log.Printf("Starting ciims proxy for %s", config.ServerURL)
|
||||||
|
log.Printf("timeout: %d", config.Timeout)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Printf("[ERROR] server failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (Config, error) {
|
||||||
|
config := Config{
|
||||||
|
Listen: os.Getenv("PROXY_LISTEN"),
|
||||||
|
Timeout: defaultTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL, err := normalizeBaseURL(os.Getenv("CIIMS_SERVER"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("invalid CIIMS_SERVER: %w", err)
|
||||||
|
}
|
||||||
|
config.ServerURL = serverURL
|
||||||
|
config.AllowedServers = map[string]string{serverURL: serverURL}
|
||||||
|
|
||||||
|
allowedServers, err := parseAllowedServers(os.Getenv(allowedServersEnvVarName))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
for _, allowed := range allowedServers {
|
||||||
|
config.AllowedServers[allowed] = allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := getIntEnv("CIIMS_TIMEOUT")
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if t > 0 {
|
||||||
|
config.Timeout = t
|
||||||
|
}
|
||||||
|
if config.Listen == "" {
|
||||||
|
config.Listen = ":9090"
|
||||||
|
}
|
||||||
|
config.Client, err = internal.NewClient(config.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(config Config) *http.Server {
|
||||||
|
return &http.Server{
|
||||||
|
Addr: config.Listen,
|
||||||
|
Handler: newRouter(config),
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: time.Duration(config.Timeout)*time.Second + writeTimeoutGrace,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRouter(config Config) *gin.Engine {
|
||||||
|
if config.AllowedServers == nil && config.ServerURL != "" {
|
||||||
|
config.AllowedServers = map[string]string{config.ServerURL: config.ServerURL}
|
||||||
|
}
|
||||||
|
app := &App{config: config}
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.GET("/ping", func(c *gin.Context) {
|
r.GET("/ping", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"message": "pong",
|
"message": "pong",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
r.POST("/send", sendMessage)
|
r.POST("/send", app.sendMessage)
|
||||||
r.POST("/receive", receiveMessage)
|
r.POST("/receive", app.receiveMessage)
|
||||||
// r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
|
return r
|
||||||
r.Run(listen)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(c *gin.Context) {
|
func (a *App) sendMessage(c *gin.Context) {
|
||||||
var message struct {
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMsgLen)
|
||||||
URL string `json:"url" `
|
|
||||||
User string `json:"user" binding:"required"`
|
var message sendRequest
|
||||||
Pass string `json:"pass" binding:"required"`
|
if err := c.ShouldBind(&message); err != nil {
|
||||||
Event string `json:"event" binding:"required"`
|
a.handleBindError(c, err)
|
||||||
Priority int `json:"priority" binding:"required"`
|
return
|
||||||
Val bool `json:"val"`
|
|
||||||
Msg string `json:"msg" binding:"required"`
|
|
||||||
}
|
}
|
||||||
err := c.Bind(&message)
|
ciimsURL, ok := a.resolveTargetURL(c, message.URL)
|
||||||
if err == nil {
|
if !ok {
|
||||||
msg := internal.CreateSend(message.User, message.Pass,
|
return
|
||||||
message.Priority, message.Event, message.Val, message.Msg)
|
}
|
||||||
url := getURL(message.URL)
|
msg := internal.CreateSend(message.User, message.Pass,
|
||||||
resp := internal.Send(url, msg)
|
message.Priority, message.Event, message.Val, message.Msg)
|
||||||
c.String(http.StatusOK, resp)
|
resp, err := a.config.Client.Send(c.Request.Context(), ciimsURL, msg)
|
||||||
} else {
|
if err != nil {
|
||||||
|
a.handleSendError(c, "send", resp, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errMsg := resp.ErrorMessage(); errMsg != "" {
|
||||||
|
log.Printf("[ERROR] SOAP fault: %s", errMsg)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"error": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) receiveMessage(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMsgLen)
|
||||||
|
|
||||||
|
var message receiveRequest
|
||||||
|
if err := c.ShouldBind(&message); err != nil {
|
||||||
|
a.handleBindError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.Count < 1 || message.Count > maxCount {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "count must be between 1 and " + strconv.Itoa(maxCount)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ciimsURL, ok := a.resolveTargetURL(c, message.URL)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := internal.CreateReceive(message.User, message.Pass, message.Count)
|
||||||
|
resp, err := a.config.Client.Send(c.Request.Context(), ciimsURL, msg)
|
||||||
|
if err != nil {
|
||||||
|
a.handleSendError(c, "receive", resp, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errMsg := resp.ErrorMessage(); errMsg != "" {
|
||||||
|
log.Printf("[ERROR] SOAP fault: %s", errMsg)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"msgs": resp.Messages()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) resolveTargetURL(c *gin.Context, requestedURL string) (string, bool) {
|
||||||
|
targetBase, err := a.getBaseURL(requestedURL)
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
|
return targetBase + servicePrefix, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func receiveMessage(c *gin.Context) {
|
func (a *App) getBaseURL(requestedURL string) (string, error) {
|
||||||
var message struct {
|
if strings.TrimSpace(requestedURL) == "" {
|
||||||
URL string `json:"url" `
|
return a.config.ServerURL, nil
|
||||||
User string `json:"user" binding:"required"`
|
|
||||||
Pass string `json:"pass" binding:"required"`
|
|
||||||
Count int `json:"count" binding:"required"`
|
|
||||||
}
|
}
|
||||||
err := c.Bind(&message)
|
normalized, err := normalizeBaseURL(requestedURL)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
msg := internal.CreateReceive(message.User, message.Pass, message.Count)
|
return "", fmt.Errorf("invalid url: %w", err)
|
||||||
url := getURL(message.URL)
|
|
||||||
resp := internal.Send(url, msg)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"msgs": internal.GetMsgs(resp)})
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
}
|
}
|
||||||
|
if _, ok := a.config.AllowedServers[normalized]; !ok {
|
||||||
|
return "", fmt.Errorf("url is not allowed")
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getURL(url string) string {
|
func normalizeBaseURL(raw string) (string, error) {
|
||||||
var result string
|
raw = strings.TrimSpace(raw)
|
||||||
if len(url) > 0 {
|
if raw == "" {
|
||||||
result = url + servicePrefix
|
return "", fmt.Errorf("must not be empty")
|
||||||
} else {
|
|
||||||
result = defaultURL + servicePrefix
|
|
||||||
}
|
}
|
||||||
return result
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
u.Scheme = strings.ToLower(u.Scheme)
|
||||||
|
u.Host = strings.ToLower(u.Host)
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return "", fmt.Errorf("scheme must be http or https")
|
||||||
|
}
|
||||||
|
if u.Host == "" {
|
||||||
|
return "", fmt.Errorf("host is required")
|
||||||
|
}
|
||||||
|
if u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return "", fmt.Errorf("query and fragment are not allowed")
|
||||||
|
}
|
||||||
|
u.Path = strings.TrimRight(u.Path, "/")
|
||||||
|
u.RawPath = ""
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAllowedServers(raw string) ([]string, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
allowed := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
normalized, err := normalizeBaseURL(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid %s entry %q: %w", allowedServersEnvVarName, part, err)
|
||||||
|
}
|
||||||
|
allowed = append(allowed, normalized)
|
||||||
|
}
|
||||||
|
return allowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleBindError(c *gin.Context, err error) {
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxBytesErr) {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Print(err.Error())
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleSendError(c *gin.Context, operation string, resp *internal.CIIMSResponse, err error) {
|
||||||
|
if resp != nil {
|
||||||
|
if errMsg := resp.ErrorMessage(); errMsg != "" {
|
||||||
|
log.Printf("[ERROR] SOAP fault: %s", errMsg)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var statusErr *internal.HTTPStatusError
|
||||||
|
if errors.As(err, &statusErr) {
|
||||||
|
if errMsg := internal.GetErrMsg(statusErr.Body); errMsg != "" {
|
||||||
|
log.Printf("[ERROR] SOAP fault: %s", errMsg)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[ERROR] %s failed: %v", operation, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIntEnv(key string) (int, error) {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ret, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid %s value %q: must be an integer", key, val)
|
||||||
|
}
|
||||||
|
if ret <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid %s value %q: must be positive", key, val)
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gzzn.com/mini/ciimsproxy/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testErrMsg = `<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><soap:Fault><detail><BHIAFault><errorMessage xmlns="http://msg.ciims.bhia.itdcl.com">Can not find the event [FLOP-ESTT-ATC-ALL1]</errorMessage></BHIAFault></detail></soap:Fault></soap:Body></soap:Envelope>`
|
||||||
|
testSendOK = `<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><ns1:sendResponse xmlns:ns1="http://ciims.bhia.itdcl.com/ExchangeService" /></soap:Body></soap:Envelope>`
|
||||||
|
testReceiveResp = `<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><ns1:receiveResponse xmlns:ns1="http://ciims.bhia.itdcl.com/ExchangeService"><ns1:out><ns1:string><?xml version="1.0" encoding="UTF-8"?><MSG><A>1</A></MSG></ns1:string></ns1:out></ns1:receiveResponse></soap:Body></soap:Envelope>`
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRouter(t *testing.T, handler http.HandlerFunc) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ciims := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(ciims.Close)
|
||||||
|
client, err := internal.NewClient(10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return newRouter(Config{ServerURL: ciims.URL, Listen: ":0", Timeout: 10, Client: client})
|
||||||
|
}
|
||||||
|
|
||||||
|
func performJSON(r http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 2: Pure Function Tests — normalizeBaseURL
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_ValidHTTP(t *testing.T) {
|
||||||
|
result, err := normalizeBaseURL("http://example.com/path/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "http://example.com/path", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_ValidHTTPS(t *testing.T) {
|
||||||
|
result, err := normalizeBaseURL("https://EXAMPLE.COM:8443")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "https://example.com:8443", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_Empty(t *testing.T) {
|
||||||
|
_, err := normalizeBaseURL("")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_WhitespaceOnly(t *testing.T) {
|
||||||
|
_, err := normalizeBaseURL(" ")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_InvalidScheme(t *testing.T) {
|
||||||
|
_, err := normalizeBaseURL("ftp://example.com")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "scheme must be http or https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_MissingHost(t *testing.T) {
|
||||||
|
_, err := normalizeBaseURL("http:///path")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "host is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_QueryNotAllowed(t *testing.T) {
|
||||||
|
_, err := normalizeBaseURL("http://example.com?a=1")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "query and fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_FragmentNotAllowed(t *testing.T) {
|
||||||
|
_, err := normalizeBaseURL("http://example.com#section")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "query and fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL_InvalidURL(t *testing.T) {
|
||||||
|
_, err := normalizeBaseURL("://bad")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 2: Pure Function Tests — parseAllowedServers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestParseAllowedServers_Empty(t *testing.T) {
|
||||||
|
result, err := parseAllowedServers("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAllowedServers_WhitespaceOnly(t *testing.T) {
|
||||||
|
_, err := parseAllowedServers(" , ")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "CIIMS_ALLOWED_SERVERS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAllowedServers_ValidSingle(t *testing.T) {
|
||||||
|
result, err := parseAllowedServers("http://other.example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"http://other.example.com"}, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAllowedServers_ValidMultiple(t *testing.T) {
|
||||||
|
result, err := parseAllowedServers("http://a.com,https://b.com:8443")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, []string{"http://a.com", "https://b.com:8443"}, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAllowedServers_InvalidEntry(t *testing.T) {
|
||||||
|
_, err := parseAllowedServers("http://good.com,ftp://bad.com")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "CIIMS_ALLOWED_SERVERS")
|
||||||
|
assert.Contains(t, err.Error(), "ftp://bad.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 2: Pure Function Tests — getIntEnv
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestGetIntEnv_Unset(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_TEST_UNSET_KEY", "")
|
||||||
|
result, err := getIntEnv("CIIMS_TEST_UNSET_KEY")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIntEnv_Valid(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_TEST_VALID_KEY", "30")
|
||||||
|
result, err := getIntEnv("CIIMS_TEST_VALID_KEY")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 30, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIntEnv_NotAnInteger(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_TEST_BAD_KEY", "abc")
|
||||||
|
_, err := getIntEnv("CIIMS_TEST_BAD_KEY")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "must be an integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIntEnv_Negative(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_TEST_NEG_KEY", "-5")
|
||||||
|
_, err := getIntEnv("CIIMS_TEST_NEG_KEY")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetIntEnv_Zero(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_TEST_ZERO_KEY", "0")
|
||||||
|
_, err := getIntEnv("CIIMS_TEST_ZERO_KEY")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 3: Handler-Level Tests (remaining gaps)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestSendNetworkError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, testSendOK)
|
||||||
|
}))
|
||||||
|
ciims.Close() // close before creating router so the URL is dead
|
||||||
|
|
||||||
|
client, err := internal.NewClient(1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
r := newRouter(Config{
|
||||||
|
ServerURL: ciims.URL,
|
||||||
|
Listen: ":0",
|
||||||
|
Timeout: 1,
|
||||||
|
Client: client,
|
||||||
|
AllowedServers: map[string]string{ciims.URL: ciims.URL},
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "connection refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendInvalidJSON(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
client, err := internal.NewClient(10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
r := newRouter(Config{
|
||||||
|
ServerURL: "http://127.0.0.1:1",
|
||||||
|
Listen: ":0",
|
||||||
|
Timeout: 10,
|
||||||
|
Client: client,
|
||||||
|
AllowedServers: map[string]string{},
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `not json`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiveCountValidBoundary(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, testReceiveResp)
|
||||||
|
}))
|
||||||
|
defer ciims.Close()
|
||||||
|
client, err := internal.NewClient(10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
r := newRouter(Config{
|
||||||
|
ServerURL: ciims.URL,
|
||||||
|
Listen: ":0",
|
||||||
|
Timeout: 10,
|
||||||
|
Client: client,
|
||||||
|
AllowedServers: map[string]string{ciims.URL: ciims.URL},
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/receive", `{"user":"FIMS","pass":"x","count":1000}`)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 4: Config & Router Tests (remaining gaps)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestLoadConfig_Minimal(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "http://example.com")
|
||||||
|
t.Setenv("PROXY_LISTEN", "")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "")
|
||||||
|
|
||||||
|
config, err := loadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, ":9090", config.Listen)
|
||||||
|
assert.Equal(t, 240, config.Timeout)
|
||||||
|
assert.Equal(t, "http://example.com", config.ServerURL)
|
||||||
|
assert.Contains(t, config.AllowedServers, "http://example.com")
|
||||||
|
require.NotNil(t, config.Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_Full(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "http://ciims.example.com")
|
||||||
|
t.Setenv("PROXY_LISTEN", ":8080")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "120")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "http://backup1.example.com,https://backup2.example.com:8443")
|
||||||
|
|
||||||
|
config, err := loadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, ":8080", config.Listen)
|
||||||
|
assert.Equal(t, 120, config.Timeout)
|
||||||
|
assert.Equal(t, "http://ciims.example.com", config.ServerURL)
|
||||||
|
assert.Contains(t, config.AllowedServers, "http://ciims.example.com")
|
||||||
|
assert.Contains(t, config.AllowedServers, "http://backup1.example.com")
|
||||||
|
assert.Contains(t, config.AllowedServers, "https://backup2.example.com:8443")
|
||||||
|
require.NotNil(t, config.Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_InvalidTimeout(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "http://example.com")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "abc")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "")
|
||||||
|
|
||||||
|
_, err := loadConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "CIIMS_TIMEOUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServerConfig(t *testing.T) {
|
||||||
|
client, err := internal.NewClient(30)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
config := Config{
|
||||||
|
ServerURL: "http://example.com",
|
||||||
|
AllowedServers: map[string]string{"http://example.com": "http://example.com"},
|
||||||
|
Listen: ":9999",
|
||||||
|
Timeout: 30,
|
||||||
|
Client: client,
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := newServer(config)
|
||||||
|
assert.Equal(t, ":9999", srv.Addr)
|
||||||
|
assert.Equal(t, readHeaderTimeout, srv.ReadHeaderTimeout)
|
||||||
|
assert.Equal(t, readTimeout, srv.ReadTimeout)
|
||||||
|
assert.Equal(t, idleTimeout, srv.IdleTimeout)
|
||||||
|
assert.NotNil(t, srv.Handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouter_RoutesExist(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
client, err := internal.NewClient(10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
config := Config{
|
||||||
|
ServerURL: "http://example.com",
|
||||||
|
AllowedServers: map[string]string{"http://example.com": "http://example.com"},
|
||||||
|
Listen: ":0",
|
||||||
|
Timeout: 10,
|
||||||
|
Client: client,
|
||||||
|
}
|
||||||
|
r := newRouter(config)
|
||||||
|
|
||||||
|
// Ping route
|
||||||
|
w := performJSON(r, http.MethodGet, "/ping", "")
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Send route (will fail with 400 due to missing fields, but not 404)
|
||||||
|
w = performJSON(r, http.MethodPost, "/send", `{}`)
|
||||||
|
assert.NotEqual(t, http.StatusNotFound, w.Code)
|
||||||
|
|
||||||
|
// Receive route (will fail with 400 due to missing fields, but not 404)
|
||||||
|
w = performJSON(r, http.MethodPost, "/receive", `{}`)
|
||||||
|
assert.NotEqual(t, http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendSuccess(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, testSendOK)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.JSONEq(t, `{"error":""}`, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendSOAPFault(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, testErrMsg)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Can not find the event")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMissingRequiredField(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"user":"FIMS","pass":"x","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendOversizedBody(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
body := `{"user":"FIMS","pass":"x","event":"E1","msg":"` + strings.Repeat("a", maxMsgLen) + `"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/send", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiveSuccess(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, testReceiveResp)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/receive", `{"user":"FIMS","pass":"x","count":2}`)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "MSG")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiveInvalidCount(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
|
||||||
|
w := performJSON(r, http.MethodPost, "/receive", `{"user":"FIMS","pass":"x","count":0}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
w = performJSON(r, http.MethodPost, "/receive", `{"user":"FIMS","pass":"x","count":1001}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiveSOAPFault(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, testErrMsg)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/receive", `{"user":"FIMS","pass":"x","count":2}`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Can not find the event")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTP500WithSOAPFaultReturnsFaultText(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, testErrMsg)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Can not find the event")
|
||||||
|
assert.NotContains(t, w.Body.String(), "ciims returned 500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigRequiresCIIMSServer(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "")
|
||||||
|
_, err := loadConfig()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "CIIMS_SERVER")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigRejectsInvalidCIIMSServer(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "ftp://ciims.example.com")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "")
|
||||||
|
_, err := loadConfig()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "scheme")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigRejectsNonPositiveTimeout(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "http://ciims.example.com")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "0")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "")
|
||||||
|
_, err := loadConfig()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigAllowedServers(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "HTTP://ciims.example.com/base/")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "https://backup.example.com/ciims/")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "3")
|
||||||
|
config, err := loadConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "http://ciims.example.com/base", config.ServerURL)
|
||||||
|
assert.Contains(t, config.AllowedServers, "http://ciims.example.com/base")
|
||||||
|
assert.Contains(t, config.AllowedServers, "https://backup.example.com/ciims")
|
||||||
|
assert.Equal(t, 3, config.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigRejectsInvalidAllowedServer(t *testing.T) {
|
||||||
|
t.Setenv("CIIMS_SERVER", "http://ciims.example.com")
|
||||||
|
t.Setenv("CIIMS_ALLOWED_SERVERS", "http://bad.example.com?x=1")
|
||||||
|
t.Setenv("CIIMS_TIMEOUT", "")
|
||||||
|
_, err := loadConfig()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "CIIMS_ALLOWED_SERVERS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServerTimeouts(t *testing.T) {
|
||||||
|
client, err := internal.NewClient(10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
config := Config{ServerURL: "http://ciims.example.com", Listen: ":0", Timeout: 10, Client: client}
|
||||||
|
srv := newServer(config)
|
||||||
|
assert.Equal(t, readHeaderTimeout, srv.ReadHeaderTimeout)
|
||||||
|
assert.Equal(t, readTimeout, srv.ReadTimeout)
|
||||||
|
assert.Equal(t, 20*time.Second, srv.WriteTimeout)
|
||||||
|
assert.Equal(t, idleTimeout, srv.IdleTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultURLUsesCIIMSServer(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
fmt.Fprint(w, testSendOK)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, servicePrefix, gotPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowedRequestURLSucceeds(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ciims := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, testSendOK)
|
||||||
|
}))
|
||||||
|
defer ciims.Close()
|
||||||
|
client, err := internal.NewClient(10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
r := newRouter(Config{
|
||||||
|
ServerURL: "http://default.example.com",
|
||||||
|
Listen: ":0",
|
||||||
|
Timeout: 10,
|
||||||
|
Client: client,
|
||||||
|
AllowedServers: map[string]string{ciims.URL: ciims.URL, "http://default.example.com": "http://default.example.com"},
|
||||||
|
})
|
||||||
|
body := fmt.Sprintf(`{"url":%q,"user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`, ciims.URL+"/")
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", body)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisallowedRequestURLReturns400AndDoesNotCallBackend(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
fmt.Fprint(w, testSendOK)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"url":"http://evil.example.com","user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.Equal(t, 0, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestURLWithQueryReturns400(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"url":"http://evil.example.com?x=1","user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTP500WithoutSOAPFaultReturnsStatusError(t *testing.T) {
|
||||||
|
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "backend failed", http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
w := performJSON(r, http.MethodPost, "/send", `{"user":"FIMS","pass":"x","event":"E1","msg":"<MSG/>"}`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "ciims returned 500")
|
||||||
|
}
|
||||||
@@ -1,8 +1,37 @@
|
|||||||
module gzzn.com/mini/ciimsproxy
|
module gzzn.com/mini/ciimsproxy
|
||||||
|
|
||||||
go 1.13
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.6.3
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/stretchr/testify v1.5.1
|
github.com/stretchr/testify v1.8.4
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/crypto v0.9.0 // indirect
|
||||||
|
golang.org/x/net v0.10.0 // indirect
|
||||||
|
golang.org/x/sys v0.15.0 // indirect
|
||||||
|
golang.org/x/text v0.9.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,48 +1,87 @@
|
|||||||
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
|
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||||
|
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||||
|
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||||
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||||
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
+44
-24
@@ -3,7 +3,7 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"regexp"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -82,51 +82,71 @@ const (
|
|||||||
|
|
||||||
</soap:Envelope>
|
</soap:Envelope>
|
||||||
`
|
`
|
||||||
|
|
||||||
msgExp = `<ns1:string>.*<\/ns1:string>`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateSend xml string for sending
|
// CreateSend xml string for sending
|
||||||
func CreateSend(user string, pass string, priority int, event string, valXML bool, message string) string {
|
func CreateSend(user string, pass string, priority int, event string, valXML bool, message string) string {
|
||||||
var mb bytes.Buffer
|
var mb bytes.Buffer
|
||||||
xml.Escape(&mb, []byte(message))
|
xml.Escape(&mb, []byte(message))
|
||||||
msg := strings.Replace(sendtpl, "##user##", user, -1)
|
msg := strings.Replace(sendtpl, "##user##", xmlEscape(user), -1)
|
||||||
msg = strings.Replace(msg, "##pass##", pass, -1)
|
msg = strings.Replace(msg, "##pass##", xmlEscape(pass), -1)
|
||||||
msg = strings.Replace(msg, "##event##", event, -1)
|
msg = strings.Replace(msg, "##event##", xmlEscape(event), -1)
|
||||||
msg = strings.Replace(msg, "##priority##", strconv.Itoa(priority), -1)
|
msg = strings.Replace(msg, "##priority##", strconv.Itoa(priority), -1)
|
||||||
msg = strings.Replace(msg, "##xml##", strconv.FormatBool(valXML), -1)
|
msg = strings.Replace(msg, "##xml##", strconv.FormatBool(valXML), -1)
|
||||||
msg = strings.Replace(msg, "##msg##", mb.String(), -1)
|
msg = strings.Replace(msg, "##msg##", mb.String(), -1)
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
//CreateReceive message
|
// CreateReceive message
|
||||||
func CreateReceive(user string, pass string, count int) string {
|
func CreateReceive(user string, pass string, count int) string {
|
||||||
msg := strings.Replace(receivetpl, "##user##", user, -1)
|
msg := strings.Replace(receivetpl, "##user##", xmlEscape(user), -1)
|
||||||
msg = strings.Replace(msg, "##pass##", pass, -1)
|
msg = strings.Replace(msg, "##pass##", xmlEscape(pass), -1)
|
||||||
msg = strings.Replace(msg, "##count##", strconv.Itoa(count), -1)
|
msg = strings.Replace(msg, "##count##", strconv.Itoa(count), -1)
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
func Map(vs []string, f func(string) string) []string {
|
func xmlEscape(s string) string {
|
||||||
vsm := make([]string, len(vs))
|
var buf bytes.Buffer
|
||||||
for i, v := range vs {
|
xml.Escape(&buf, []byte(s))
|
||||||
vsm[i] = f(v)
|
return buf.String()
|
||||||
}
|
|
||||||
return vsm
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//GetMsgs get message from soap response
|
// GetMsgs get message from soap response
|
||||||
func GetMsgs(soap string) []string {
|
func GetMsgs(soap string) []string {
|
||||||
r, _ := regexp.Compile(msgExp)
|
return xmlElementTexts(soap, "string")
|
||||||
msgs := r.FindAllString(soap, -1)
|
|
||||||
return Map(msgs, split)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func split(msg string) string {
|
// GetErrMsg get error message
|
||||||
if len(msg) > 12 {
|
func GetErrMsg(soap string) string {
|
||||||
return msg[12 : len(msg)-13]
|
matches := xmlElementTexts(soap, "errorMessage")
|
||||||
} else {
|
if len(matches) > 0 {
|
||||||
return msg
|
return matches[0]
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func xmlElementTexts(soap string, localName string) []string {
|
||||||
|
decoder := xml.NewDecoder(strings.NewReader(soap))
|
||||||
|
var result []string
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
start, ok := token.(xml.StartElement)
|
||||||
|
if !ok || start.Name.Local != localName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
if err := decoder.DecodeElement(&text, &start); err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result = append(result, text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ const (
|
|||||||
</soap:Envelope>`
|
</soap:Envelope>`
|
||||||
|
|
||||||
Msg string = `<?xml version="1.0" encoding="UTF-8"?> <msg xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="unisysaodbsis.xsd"> <meta> <sndr>AODB</sndr> <seqn>820830</seqn> <dttm>20180527124442</dttm> <type>FLOP</type> <styp>ESTT</styp> </meta> <flop> <flid>10725849</flid> <ffid>CA-CA4242-A-27MAY181955-D</ffid> <estt>27MAY181945</estt> </flop> </msg>`
|
Msg string = `<?xml version="1.0" encoding="UTF-8"?> <msg xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="unisysaodbsis.xsd"> <meta> <sndr>AODB</sndr> <seqn>820830</seqn> <dttm>20180527124442</dttm> <type>FLOP</type> <styp>ESTT</styp> </meta> <flop> <flid>10725849</flid> <ffid>CA-CA4242-A-27MAY181955-D</ffid> <estt>27MAY181945</estt> </flop> </msg>`
|
||||||
|
|
||||||
|
ErrMsg string = `<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>invalid route envent id </faultstring><detail><BHIAFault xmlns="http://ciims.bhia.itdcl.com"><code xmlns="http://msg.ciims.bhia.itdcl.com">20</code><errorMessage xmlns="http://msg.ciims.bhia.itdcl.com">Can not find the event [FLOP-ESTT-ATC-ALL1]</errorMessage></BHIAFault></detail></soap:Fault></soap:Body></soap:Envelope>`
|
||||||
|
|
||||||
|
SendOk string = `<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><soap:Body><ns1:sendResponse xmlns:ns1="http://ciims.bhia.itdcl.com/ExchangeService" /></soap:Body></soap:Envelope>`
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSend(t *testing.T) {
|
func TestSend(t *testing.T) {
|
||||||
@@ -118,3 +122,15 @@ func TestGetMsg(t *testing.T) {
|
|||||||
assert.Equal(2, len(msgs), "should get 2 message")
|
assert.Equal(2, len(msgs), "should get 2 message")
|
||||||
assert.Equal(Msg, msgs[0], "message")
|
assert.Equal(Msg, msgs[0], "message")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetErrMsg(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
errMsg := GetErrMsg(ErrMsg)
|
||||||
|
assert.Equal("Can not find the event [FLOP-ESTT-ATC-ALL1]", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendOk(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
errMsg := GetErrMsg(SendOk)
|
||||||
|
assert.Equal("", errMsg)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
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><MSG/></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">
|
||||||
|
<MSG>
|
||||||
|
<A>1</A>
|
||||||
|
</MSG>
|
||||||
|
</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<>&"'")
|
||||||
|
assert.Contains(t, result, "pass")
|
||||||
|
assert.Contains(t, result, "<MSG/>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateReceive_XMLEscapes(t *testing.T) {
|
||||||
|
result := CreateReceive("user<>&\"'", "pass>", 2)
|
||||||
|
assert.Contains(t, result, "user<>&"'")
|
||||||
|
assert.Contains(t, result, "pass>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+89
-20
@@ -1,8 +1,9 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"io/ioutil"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,39 +12,107 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultContentType string = "text/xml; charset=UTF-8"
|
defaultContentType string = "text/xml; charset=UTF-8"
|
||||||
defaultAgent string = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; XFire Client +http://xfire.codehaus.org)"
|
defaultAgent string = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; XFire Client +http://xfire.codehaus.org)"
|
||||||
|
maxResponseBytes = 64 << 20 // 64 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
//Post xml string to url
|
type Client struct {
|
||||||
func post(url string, contentType string, msg string) (*http.Response, error) {
|
httpClient *http.Client
|
||||||
return http.Post(url, contentType, bytes.NewBuffer([]byte(msg)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func postSim(url string, msg string) string {
|
type CIIMSResponse struct {
|
||||||
timeout := time.Duration(10 * time.Second)
|
RawXML string
|
||||||
client := http.Client{
|
}
|
||||||
Timeout: timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(msg))
|
type HTTPStatusError struct {
|
||||||
|
StatusCode int
|
||||||
|
Status string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResponseTooLargeError struct {
|
||||||
|
Limit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HTTPStatusError) Error() string {
|
||||||
|
body := e.Body
|
||||||
|
if len(body) > 512 {
|
||||||
|
body = body[:512] + "..."
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
return fmt.Sprintf("ciims returned %s", e.Status)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("ciims returned %s: %s", e.Status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ResponseTooLargeError) Error() string {
|
||||||
|
return fmt.Sprintf("ciims response exceeds %d bytes", e.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(timeoutSec int) (*Client, error) {
|
||||||
|
if timeoutSec <= 0 {
|
||||||
|
return nil, fmt.Errorf("timeout must be positive")
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: time.Duration(timeoutSec) * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CIIMSResponse) IsFault() bool {
|
||||||
|
return r != nil && r.ErrorMessage() != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CIIMSResponse) ErrorMessage() string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return GetErrMsg(r.RawXML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CIIMSResponse) Messages() []string {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return GetMsgs(r.RawXML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Send(ctx context.Context, url string, msg string) (*CIIMSResponse, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(msg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err.Error()
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", defaultContentType)
|
req.Header.Set("Content-Type", defaultContentType)
|
||||||
req.Header.Set("User-Agent", defaultAgent)
|
req.Header.Set("User-Agent", defaultAgent)
|
||||||
req.Header.Set("SOAPAction", "")
|
req.Header.Set("SOAPAction", "")
|
||||||
resp, err := client.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err.Error()
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
respBytes, err := ioutil.ReadAll(resp.Body)
|
body, err := readLimited(resp.Body, maxResponseBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err.Error()
|
return nil, err
|
||||||
}
|
}
|
||||||
return string(respBytes)
|
ciimsResp := &CIIMSResponse{RawXML: body}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return ciimsResp, &HTTPStatusError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Status: resp.Status,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ciimsResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//Send message
|
func readLimited(r io.Reader, limit int64) (string, error) {
|
||||||
func Send(url string, message string) string {
|
limited := io.LimitReader(r, limit+1)
|
||||||
return postSim(url, message)
|
respBytes, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if int64(len(respBytes)) > limit {
|
||||||
|
return "", &ResponseTooLargeError{Limit: limit}
|
||||||
|
}
|
||||||
|
return string(respBytes), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# CIIMS Proxy — Test Coverage Fix Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Close all test coverage gaps identified in the codebase analysis. The `internal` package has decent coverage (20 tests) but `cmd/main/main.go` has **zero tests** across 12 functions. This plan adds 49 new tests organized in 4 phases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: `internal` Package — Remaining Gaps (8 tests)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
Cover the few untested paths in the already well-tested `internal` package.
|
||||||
|
|
||||||
|
### Implementation Plan
|
||||||
|
|
||||||
|
- [x] 1.1 `TestNewClient_ZeroTimeout` — Call `NewClient(0)`, assert error returned and message contains "positive". Also test `NewClient(-1)`.
|
||||||
|
**Rationale:** The error path for invalid timeout values is untested. `loadConfig` depends on this to fail fast.
|
||||||
|
|
||||||
|
- [x] 1.2 `TestNewClient_ValidTimeout` — Call `NewClient(30)`, assert no error and the returned client is non-nil.
|
||||||
|
**Rationale:** Only the success path for `NewClient` is exercised implicitly by other tests; a direct test is cleaner.
|
||||||
|
|
||||||
|
- [x] 1.3 `TestHTTPStatusError_Truncation` — Create `HTTPStatusError` with a body of 600 'x' characters. Assert `Error()` returns a string ending in `"...` and not exceeding ~520 characters (512 + "ciims returned ...: " prefix).
|
||||||
|
**Rationale:** The truncation logic at `http.go:38-40` is untested and could silently break.
|
||||||
|
|
||||||
|
- [x] 1.4 `TestHTTPStatusError_EmptyBody` — Create `HTTPStatusError` with empty body. Assert `Error()` returns `"ciims returned <status>"` without a colon.
|
||||||
|
**Rationale:** The empty-body branch at `http.go:41-43` is untested.
|
||||||
|
|
||||||
|
- [x] 1.5 `TestResponseTooLargeError_Error` — Create `ResponseTooLargeError{Limit: 100}`. Assert `Error()` returns `"ciims response exceeds 100 bytes"`.
|
||||||
|
**Rationale:** Simple error type with no existing direct test.
|
||||||
|
|
||||||
|
- [x] 1.6 `TestCIIMSResponse_NilReceiver` — Call `(*CIIMSResponse)(nil).IsFault()`, `.ErrorMessage()`, `.Messages()`. Assert they return `false`, `""`, and `nil` respectively without panicking.
|
||||||
|
**Rationale:** The nil-guard branches at `http.go:63,67,74` are untested.
|
||||||
|
|
||||||
|
- [x] 1.7 `TestClientSend_SOAPFaultWithHTTP500` — Mock server returns HTTP 500 with a SOAP fault body. Assert `err` is `*HTTPStatusError`, `resp.IsFault()` is true, and `resp.ErrorMessage()` returns the fault text.
|
||||||
|
**Rationale:** `handleSendError` in `main.go` has a code path that extracts SOAP faults from `HTTPStatusError.Body` — this scenario must be tested at the transport layer first.
|
||||||
|
|
||||||
|
- [x] 1.8 `TestXmlElementTexts_MalformedXML` — Call `xmlElementTexts` with truncated XML like `"<soap><Body><string>incomplete"`. Assert it returns gracefully (empty slice) without panicking.
|
||||||
|
**Rationale:** The `decoder.Token()` error path at `codec.go:136-138` is untested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: `cmd/main` — Pure Functions (19 tests)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
Test all functions in `main.go` that have no Gin dependency: configuration loading, URL parsing, environment parsing, and error handling helpers.
|
||||||
|
|
||||||
|
### Implementation Plan
|
||||||
|
|
||||||
|
- [x] 2.1 `TestNormalizeBaseURL_ValidHTTP` — Input `"http://example.com/path/"`, assert returns `"http://example.com/path"` (trailing slash stripped, scheme lowercased).
|
||||||
|
**Rationale:** Happy path for the URL normalization function.
|
||||||
|
|
||||||
|
- [x] 2.2 `TestNormalizeBaseURL_ValidHTTPS` — Input `"https://EXAMPLE.COM:8443"`, assert host lowercased, scheme lowercased.
|
||||||
|
**Rationale:** Verifies case normalization and port preservation.
|
||||||
|
|
||||||
|
- [x] 2.3 `TestNormalizeBaseURL_Empty` — Input `""`, assert error contains "must not be empty".
|
||||||
|
**Rationale:** Empty input validation.
|
||||||
|
|
||||||
|
- [x] 2.4 `TestNormalizeBaseURL_WhitespaceOnly` — Input `" "`, assert error (after TrimSpace, it's empty).
|
||||||
|
**Rationale:** Edge case for whitespace handling.
|
||||||
|
|
||||||
|
- [x] 2.5 `TestNormalizeBaseURL_InvalidScheme` — Input `"ftp://example.com"`, assert error contains "scheme must be http or https".
|
||||||
|
**Rationale:** Scheme allowlist enforcement.
|
||||||
|
|
||||||
|
- [x] 2.6 `TestNormalizeBaseURL_MissingHost` — Input `"http:///path"`, assert error contains "host is required".
|
||||||
|
**Rationale:** `url.Parse` on `http:///path` sets Host to empty.
|
||||||
|
|
||||||
|
- [x] 2.7 `TestNormalizeBaseURL_QueryNotAllowed` — Input `"http://example.com?a=1"`, assert error contains "query and fragment are not allowed".
|
||||||
|
**Rationale:** Query/fragment rejection prevents URL smuggling.
|
||||||
|
|
||||||
|
- [x] 2.8 `TestNormalizeBaseURL_FragmentNotAllowed` — Input `"http://example.com#section"`, assert error.
|
||||||
|
**Rationale:** Same as above for fragments.
|
||||||
|
|
||||||
|
- [x] 2.9 `TestNormalizeBaseURL_InvalidURL` — Input `"://bad"`, assert error (from `url.Parse`).
|
||||||
|
**Rationale:** Tests the `url.Parse` error path.
|
||||||
|
|
||||||
|
- [x] 2.10 `TestParseAllowedServers_Empty` — Input `""`, assert returns nil slice and nil error.
|
||||||
|
**Rationale:** Empty env var is valid (means "no additional servers").
|
||||||
|
|
||||||
|
- [x] 2.11 `TestParseAllowedServers_WhitespaceOnly` — Input `" , "`, assert error (whitespace-only entries become empty and are rejected by `normalizeBaseURL`).
|
||||||
|
**Rationale:** Edge case for the split logic.
|
||||||
|
|
||||||
|
- [x] 2.12 `TestParseAllowedServers_ValidSingle` — Input `"http://other.example.com"`, assert returns `["http://other.example.com"]`.
|
||||||
|
**Rationale:** Happy path for single additional server.
|
||||||
|
|
||||||
|
- [x] 2.13 `TestParseAllowedServers_ValidMultiple` — Input `"http://a.com,https://b.com:8443"`, assert both are normalized and returned.
|
||||||
|
**Rationale:** Multi-server allowlist parsing.
|
||||||
|
|
||||||
|
- [x] 2.14 `TestParseAllowedServers_InvalidEntry` — Input `"http://good.com,ftp://bad.com"`, assert error references the bad entry and the env var name.
|
||||||
|
**Rationale:** Error propagation with context.
|
||||||
|
|
||||||
|
- [x] 2.15 `TestGetIntEnv_Unset` — Unset the env var, assert returns `(0, nil)`.
|
||||||
|
**Rationale:** Default behavior when env var is absent.
|
||||||
|
|
||||||
|
- [x] 2.16 `TestGetIntEnv_Valid` — Set env var to `"30"`, assert returns `(30, nil)`.
|
||||||
|
**Rationale:** Happy path.
|
||||||
|
|
||||||
|
- [x] 2.17 `TestGetIntEnv_NotAnInteger` — Set env var to `"abc"`, assert error contains "must be an integer".
|
||||||
|
**Rationale:** Parse error path.
|
||||||
|
|
||||||
|
- [x] 2.18 `TestGetIntEnv_Negative` — Set env var to `"-5"`, assert error contains "must be positive".
|
||||||
|
**Rationale:** Negative value rejection (new validation in current code).
|
||||||
|
|
||||||
|
- [x] 2.19 `TestGetIntEnv_Zero` — Set env var to `"0"`, assert error contains "must be positive".
|
||||||
|
**Rationale:** Zero value rejection.]]>
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: `cmd/main` — Handler-Level Tests (16 tests)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
Test the Gin HTTP handlers end-to-end using `httptest.NewServer` + a mock CIIMS backend. This is the most critical gap.
|
||||||
|
|
||||||
|
### Implementation Plan
|
||||||
|
|
||||||
|
- [x] 3.1 `TestSendSuccess` — POST to `/send` with valid JSON. Mock CIIMS returns `testSendOK`. Assert 200, `{"error":""}`.
|
||||||
|
**Rationale:** Core happy path for the send endpoint.
|
||||||
|
|
||||||
|
- [x] 3.2 `TestSendSOAPFault` — POST to `/send` with valid JSON. Mock CIIMS returns `testErrMsg`. Assert 500, response contains the fault message.
|
||||||
|
**Rationale:** SOAP fault handling in the handler (was the original nil-pointer bug).
|
||||||
|
|
||||||
|
- [x] 3.3 `TestSendNetworkError` — POST to `/send` with valid JSON. Mock CIIMS is closed before the request. Assert 500, response contains error.
|
||||||
|
**Rationale:** Network error path through `handleSendError`.
|
||||||
|
|
||||||
|
- [x] 3.4 `TestHTTP500WithoutSOAPFaultReturnsStatusError` — POST to `/send` with valid JSON. Mock CIIMS returns HTTP 500 with plain text body. Assert 500, response contains the status error.
|
||||||
|
**Rationale:** HTTP error path through `handleSendError` (non-SOAP-fault branch).
|
||||||
|
|
||||||
|
- [x] 3.5 `TestHTTP500WithSOAPFaultReturnsFaultText` — POST to `/send` with valid JSON. Mock CIIMS returns HTTP 500 with a SOAP fault body. Assert 500, response contains the extracted fault message (not the raw HTTP status).
|
||||||
|
**Rationale:** `handleSendError` has a branch that extracts SOAP faults from `HTTPStatusError.Body` — this is the most complex error path.
|
||||||
|
|
||||||
|
- [x] 3.6 `TestSendMissingRequiredField` — POST to `/send` with `{"user":"x"}` (missing pass, event, msg). Assert 400.
|
||||||
|
**Rationale:** Gin binding validation.
|
||||||
|
|
||||||
|
- [x] 3.7 `TestSendOversizedBody` — POST to `/send` with body > 1MB. Assert 413.
|
||||||
|
**Rationale:** `MaxBytesReader` enforcement.
|
||||||
|
|
||||||
|
- [x] 3.8 `TestSendInvalidJSON` — POST to `/send` with `not json`. Assert 400.
|
||||||
|
**Rationale:** Malformed JSON handling.
|
||||||
|
|
||||||
|
- [x] 3.9 `TestAllowedRequestURLSucceeds` — POST to `/send` with `{"url":"http://allowed.example.com",...}` where the URL is in `AllowedServers`. Assert 200 (request proxied).
|
||||||
|
**Rationale:** Allowlist pass-through.
|
||||||
|
|
||||||
|
- [x] 3.10 `TestDisallowedRequestURLReturns400AndDoesNotCallBackend` — POST to `/send` with `{"url":"http://evil.com",...}`. Assert 400, error contains "not allowed", and backend is never called.
|
||||||
|
**Rationale:** Allowlist rejection.
|
||||||
|
|
||||||
|
- [x] 3.11 `TestReceiveSuccess` — POST to `/receive` with valid JSON. Mock CIIMS returns `testReceiveResp`. Assert 200, response contains messages.
|
||||||
|
**Rationale:** Core happy path for receive.
|
||||||
|
|
||||||
|
- [x] 3.12 `TestReceiveSOAPFault` — POST to `/receive` with valid JSON. Mock CIIMS returns a SOAP fault. Assert 500.
|
||||||
|
**Rationale:** SOAP fault handling in receive (was previously missing — now fixed but untested).
|
||||||
|
|
||||||
|
- [x] 3.13 `TestReceiveInvalidCount` — POST to `/receive` with `"count":0`. Assert 400, error contains "between 1 and". Also test count=1001.
|
||||||
|
**Rationale:** Count lower-bound and upper-bound validation.
|
||||||
|
|
||||||
|
- [x] 3.14 (merged with 3.13) — Already covered by `TestReceiveInvalidCount`.
|
||||||
|
|
||||||
|
- [x] 3.15 `TestReceiveCountValidBoundary` — POST to `/receive` with `"count":1000`. Assert 200 (boundary value).
|
||||||
|
**Rationale:** Maximum allowed count should succeed.
|
||||||
|
|
||||||
|
- [x] 3.16 `TestPing` — GET `/ping`. Assert 200, `{"message":"pong"}`.
|
||||||
|
**Rationale:** Health check endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: `cmd/main` — Config & Router Tests (8 tests)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
Test `loadConfig`, `newServer`, and `newRouter` with environment variable manipulation.
|
||||||
|
|
||||||
|
### Implementation Plan
|
||||||
|
|
||||||
|
- [x] 4.1 `TestLoadConfig_Minimal` — Set only `CIIMS_SERVER=http://example.com`, unset others. Assert defaults: Listen=`":9090"`, Timeout=240, AllowedServers contains only the server URL.
|
||||||
|
**Rationale:** Default configuration path.
|
||||||
|
|
||||||
|
- [x] 4.2 `TestLoadConfig_Full` — Set all env vars. Assert all values are parsed correctly including `AllowedServers`.
|
||||||
|
**Rationale:** Full configuration path.
|
||||||
|
|
||||||
|
- [x] 4.3 `TestLoadConfigRequiresCIIMSServer` — Set `CIIMS_SERVER=`. Assert error contains "CIIMS_SERVER".
|
||||||
|
**Rationale:** Configuration validation propagates errors.
|
||||||
|
|
||||||
|
- [x] 4.4 `TestLoadConfigRejectsInvalidCIIMSServer` — Set `CIIMS_SERVER=ftp://bad`. Assert error contains "scheme".
|
||||||
|
**Rationale:** Invalid URL causes load failure.
|
||||||
|
|
||||||
|
- [x] 4.5 `TestLoadConfigRejectsNonPositiveTimeout` — Set `CIIMS_TIMEOUT=0`. Assert error contains "positive".
|
||||||
|
**Rationale:** Zero timeout rejection propagates through `NewClient`.
|
||||||
|
|
||||||
|
- [x] 4.6 `TestLoadConfigRejectsInvalidAllowedServer` — Set `CIIMS_ALLOWED_SERVERS=http://bad.example.com?x=1`. Assert error.
|
||||||
|
**Rationale:** Invalid allowed server entry causes load failure.
|
||||||
|
|
||||||
|
- [x] 4.7 `TestNewServerTimeouts` and `TestNewServerConfig` — Call `newServer(config)`. Assert `ReadHeaderTimeout`, `ReadTimeout`, `IdleTimeout` are set, `Addr` matches config.
|
||||||
|
**Rationale:** Server construction verification.
|
||||||
|
|
||||||
|
- [x] 4.8 `TestNewRouter_RoutesExist` — Call `newRouter(config)`. Use `httptest.NewServer` with the Gin engine. Assert GET `/ping`, POST `/send`, POST `/receive` all return non-404.
|
||||||
|
**Rationale:** Route registration verification.
|
||||||
|
|
||||||
|
### Additional Tests (beyond original plan)
|
||||||
|
|
||||||
|
- [x] `TestDefaultURLUsesCIIMSServer` — Verifies the service prefix is appended to the default URL.
|
||||||
|
- [x] `TestRequestURLWithQueryReturns400` — Verifies URLs with query params are rejected.
|
||||||
|
- [x] `TestLoadConfigAllowedServers` — Verifies case normalization and multiple allowed servers.
|
||||||
|
- [x] `TestLoadConfig_InvalidTimeout` — Verifies non-integer timeout produces error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Criteria
|
||||||
|
|
||||||
|
- All 49 new tests pass with `go test ./... -count=1`
|
||||||
|
- `go vet ./...` reports no issues
|
||||||
|
- Handler tests use `t.Setenv()` to avoid cross-test pollution
|
||||||
|
- Handler tests create a fresh `App` + mock CIIMS `httptest.Server` per sub-test
|
||||||
|
- `internal` tests remain in `package internal`
|
||||||
|
- `cmd/main` tests go in `cmd/main/main_test.go` (package `main`)
|
||||||
|
- No test depends on external network access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Risks and Mitigations
|
||||||
|
|
||||||
|
1. **Environment variable pollution between tests**
|
||||||
|
Mitigation: Use `t.Setenv()` which automatically restores the original value after the test. For `loadConfig` tests, set all relevant vars in each test case.
|
||||||
|
|
||||||
|
2. **Gin mode pollution (debug vs release logging)**
|
||||||
|
Mitigation: Call `gin.SetMode(gin.TestMode)` in `TestMain` or at the top of each test function.
|
||||||
|
|
||||||
|
3. **Port conflicts in parallel handler tests**
|
||||||
|
Mitigation: Use `httptest.NewServer` (which allocates a random port) for both the proxy and the mock CIIMS backend. Do not use `t.Parallel()` for handler tests that share mock servers.
|
||||||
|
|
||||||
|
4. **`getIntEnv` tests need `os.Setenv`**
|
||||||
|
Mitigation: Use `t.Setenv()` — this is the standard approach since Go 1.17.
|
||||||
|
|
||||||
|
5. **`loadConfig` reads multiple env vars**
|
||||||
|
Mitigation: Set all env vars explicitly in each test case to avoid inheriting values from the test environment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative Approaches
|
||||||
|
|
||||||
|
1. **Table-driven tests for handlers**: Group all send/receive scenarios into `[]struct{name, body, mockResponse, mockStatus, wantStatus, wantBody}` tables. This reduces boilerplate but makes individual test failure messages less clear. **Recommendation**: Use table-driven for `normalizeBaseURL` and `getIntEnv` (pure functions), keep handler tests as individual functions for clarity.
|
||||||
|
|
||||||
|
2. **Refactor handlers to accept `http.Handler` interface**: Extract handler logic from Gin to standard `http.Handler` for easier testing. **Recommendation**: Not necessary — `httptest.NewServer` with Gin works fine and tests the actual routing layer.
|
||||||
|
|
||||||
|
3. **Use `httptest.NewRecorder` directly on Gin**: Call `router.ServeHTTP(w, req)` instead of `httptest.NewServer`. **Recommendation**: Use `httptest.NewServer` for handler tests because `sendMessage` needs a real TCP connection to the mock CIIMS backend (it calls `client.Send` with a URL).
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# CIIMS Proxy Remediation Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix all correctness, security, maintainability, and testing issues identified in the code review so the proxy is reliable, safe, and maintainable. Expected outcomes:
|
||||||
|
|
||||||
|
- Request timeouts are honored and configurable via `CIIMS_TIMEOUT`.
|
||||||
|
- `sendMessage` no longer panics on SOAP faults and returns a proper error response.
|
||||||
|
- All user-supplied values are safely XML-escaped before being embedded in SOAP envelopes.
|
||||||
|
- SOAP response parsing is robust against namespace/formatting changes.
|
||||||
|
- Dead code and inconsistent logging are removed or unified.
|
||||||
|
- HTTP handlers have unit/integration tests.
|
||||||
|
- Dependencies and Go toolchain are upgraded to supported versions.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### 1. Fix Timeout Configuration and Request Handling
|
||||||
|
|
||||||
|
- [x] Remove the package-level `timeout` constant in `cmd/main/main.go` that always evaluates to `0`.
|
||||||
|
- [x] Introduce an application configuration struct (or closure) to hold the resolved timeout value so handlers receive the configured timeout instead of the global zero value.
|
||||||
|
- [x] Ensure `http.Client.Timeout` is set to the configured duration; treat `0` as a validation error or explicitly default to `defaultTimeout` before constructing the client.
|
||||||
|
- [x] Validate that `CIIMS_TIMEOUT`, when provided, parses as a positive integer and fail fast with a clear message on invalid input instead of panicking.
|
||||||
|
|
||||||
|
### 2. Fix `sendMessage` Error Path
|
||||||
|
|
||||||
|
- [x] In `cmd/main/main.go`, after calling `internal.GetErrMsg(resp)`, use the returned `errMsg` string for logging and the JSON error payload instead of `err.Error()`.
|
||||||
|
- [x] Add the missing `return` statement inside the `len(errMsg) > 0` branch so the handler does not return `200 OK {"error":""}` after detecting a SOAP fault.
|
||||||
|
- [x] Ensure consistent error response shape across all handler error paths (e.g., `{"error": "..."}`).
|
||||||
|
|
||||||
|
### 3. Harden SOAP Message Construction
|
||||||
|
|
||||||
|
- [x] XML-escape all user-provided fields inserted into the SOAP templates (`user`, `pass`, `event`, `message`) using `xml.EscapeText` or equivalent.
|
||||||
|
- [x] Audit the `sendtpl` and `receivetpl` templates in `internal/codec.go` to confirm no raw interpolation remains.
|
||||||
|
- [ ] Consider replacing string-template-based SOAP construction with typed `encoding/xml` structs for the envelope, header, and body, while still embedding the inner message as escaped text.
|
||||||
|
- [x] Add unit tests that verify payloads containing XML metacharacters (`<`, `>`, `&`, `"`, `'`) are escaped correctly.
|
||||||
|
|
||||||
|
### 4. Replace Fragile Response Parsing
|
||||||
|
|
||||||
|
- [ ] Replace regex-based extraction in `GetMsgs` and `GetErrMsg` with XML unmarshaling into properly typed structs, or at minimum use namespace-aware parsing.
|
||||||
|
- [x] If regex is retained as a short-term fix, switch to non-greedy patterns (e.g., `<ns1:string>(.*?)</ns1:string>`) and validate slice indices before substring operations.
|
||||||
|
- [x] Remove hardcoded magic numbers (`12`, `13`, `54`, `15`) from `split` and `GetErrMsg`.
|
||||||
|
- [x] Add tests covering responses with different namespace prefixes, extra attributes, whitespace variations, and missing elements.
|
||||||
|
|
||||||
|
### 5. Remove Dead Code and Unify Logging
|
||||||
|
|
||||||
|
- [x] Delete the unused `post` function in `internal/http.go`.
|
||||||
|
- [x] Remove commented-out default URLs and the commented `r.Run()` line in `cmd/main/main.go`.
|
||||||
|
- [x] Decide on a single logging approach: either use Gin's built-in logger and standard `log` package, or keep a structured logger consistently; remove the mixed use of `github.com/labstack/gommon/log` unless it provides required features.
|
||||||
|
- [x] Replace `println` startup messages with structured log calls or remove them.
|
||||||
|
|
||||||
|
### 6. Improve Error Handling
|
||||||
|
|
||||||
|
- [x] Handle errors from `regexp.Compile` explicitly; if regex remains, compile patterns once at package init and panic only on init failure, or prefer compile-time-safe approaches.
|
||||||
|
- [x] Refactor `getIntEnv` to return `(int, error)` instead of panicking, and let `main` decide how to report invalid configuration.
|
||||||
|
- [x] Ensure all HTTP client errors (network, timeout, non-2xx status) are logged and returned to the client without leaking internal details.
|
||||||
|
|
||||||
|
### 7. Add Security Hardening
|
||||||
|
|
||||||
|
- [ ] Add configurable authentication for the `/send` and `/receive` endpoints (e.g., API key header, basic auth, or TLS client certificates) if the proxy is exposed beyond localhost.
|
||||||
|
- [x] Document that the proxy should run behind TLS when handling credentials.
|
||||||
|
- [x] Avoid logging request bodies or credentials; if URL logging is required, log only the host or a sanitized version.
|
||||||
|
- [x] Add request body size limits and input validation (e.g., max `count`, max `msg` length) to prevent abuse.
|
||||||
|
|
||||||
|
### 8. Expand Test Coverage
|
||||||
|
|
||||||
|
- [x] Add HTTP handler tests for `/send` and `/receive` using `net/http/httptest` and a mock CIIMS backend.
|
||||||
|
- [x] Add tests for timeout behavior, including verification that `http.Client.Timeout` is set correctly.
|
||||||
|
- [x] Add tests for SOAP fault handling in `sendMessage` and `receiveMessage`.
|
||||||
|
- [ ] Add tests for malformed JSON, missing required fields, and invalid `count` values.
|
||||||
|
- [x] Ensure existing tests in `internal/codec_test.go` continue to pass after refactoring, updating expected strings only if the XML format changes intentionally.
|
||||||
|
|
||||||
|
### 9. Upgrade Toolchain and Dependencies
|
||||||
|
|
||||||
|
- [x] Update `go.mod` to a supported Go version (e.g., `1.22` or later) and run `go mod tidy`.
|
||||||
|
- [x] Upgrade `gin-gonic/gin`, `labstack/gommon`, and `stretchr/testify` to current stable versions.
|
||||||
|
- [x] Review release notes for breaking changes in Gin and adjust handler code if necessary.
|
||||||
|
- [x] Verify the build and all tests pass on the upgraded toolchain.
|
||||||
|
|
||||||
|
### 10. Documentation and Deployment Notes
|
||||||
|
|
||||||
|
- [x] Update `README.md` to document environment variables, optional fields, and security considerations (TLS, authentication).
|
||||||
|
- [x] Remove the committed `main` binary from the repository and add it to `.gitignore` if not already ignored.
|
||||||
|
- [ ] Add a `Makefile` or build script for consistent compilation and testing (optional but recommended).
|
||||||
|
|
||||||
|
## Verification Criteria
|
||||||
|
|
||||||
|
- `CIIMS_TIMEOUT=30` results in outbound requests timing out after 30 seconds; `CIIMS_TIMEOUT=0` falls back to the default `240` seconds or fails validation as designed.
|
||||||
|
- Sending a request that causes a CIIMS SOAP fault returns `500 Internal Server Error` with `{"error":"<fault text>"}` and does not panic.
|
||||||
|
- A `send` request with `user`, `pass`, `event`, or `msg` containing XML metacharacters produces a valid SOAP envelope without breaking XML structure.
|
||||||
|
- `receive` responses with different namespace prefixes or whitespace still return the correct decoded messages.
|
||||||
|
- All existing and new unit tests pass (`go test ./...`).
|
||||||
|
- `go vet ./...` and `go build ./...` produce no errors on the upgraded Go version.
|
||||||
|
- The committed `main` binary is removed from version control.
|
||||||
|
|
||||||
|
## Potential Risks and Mitigations
|
||||||
|
|
||||||
|
1. **Regression in SOAP format**
|
||||||
|
Mitigation: Keep the existing tests as a baseline and add new tests before refactoring. Compare generated XML with the current expected output to ensure backward compatibility with CIIMS.
|
||||||
|
|
||||||
|
2. **Namespace changes in CIIMS responses break parsing**
|
||||||
|
Mitigation: Move to XML unmarshaling or namespace-agnostic parsing. Add test fixtures covering multiple namespace prefix styles.
|
||||||
|
|
||||||
|
3. **Authentication requirement breaks existing clients**
|
||||||
|
Mitigation: Make authentication optional via environment variable, defaulting to disabled for local development, and document enablement for production.
|
||||||
|
|
||||||
|
4. **Go/dependency upgrade introduces breaking changes**
|
||||||
|
Mitigation: Upgrade dependencies incrementally, run the full test suite after each change, and review Gin migration guides.
|
||||||
|
|
||||||
|
5. **Timeout behavior change affects long-running CIIMS operations**
|
||||||
|
Mitigation: Set a sensible default (e.g., `240` seconds as currently intended) and allow operators to tune `CIIMS_TIMEOUT` based on observed backend latency.
|
||||||
|
|
||||||
|
## Alternative Approaches
|
||||||
|
|
||||||
|
1. **Template-based SOAP vs. struct-based XML marshaling**
|
||||||
|
- Template approach: Simpler to read and matches the current implementation, but requires careful escaping. Keep if escaping is added and tested.
|
||||||
|
- Struct approach: Type-safe and eliminates string-replacement bugs, but more verbose due to mixed namespaces. Recommended for long-term maintainability.
|
||||||
|
|
||||||
|
2. **Regex parsing vs. XML unmarshaling**
|
||||||
|
- Regex: Quick to implement and matches current behavior, but fragile. Acceptable only as a short-term fix with non-greedy patterns and bounds checks.
|
||||||
|
- XML unmarshaling: Robust and self-documenting. Recommended for production use.
|
||||||
|
|
||||||
|
3. **Global variables vs. dependency injection for configuration**
|
||||||
|
- Global variables: Minimal code change, but hard to test. Current code uses this pattern.
|
||||||
|
- Dependency injection: Pass a config/handler struct to route registration. Enables better testing and removes hidden state. Recommended during refactoring.
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# CIIMS Proxy — Codebase Analysis
|
||||||
|
|
||||||
|
> Generated 2026-07-08 · Go 1.22 · 4 source files + 2 test files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Source Code Analysis
|
||||||
|
|
||||||
|
### 1.1 Package Structure & Module Layout
|
||||||
|
|
||||||
|
The codebase is split across two packages:
|
||||||
|
|
||||||
|
| Package | Files | Lines | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `main` (`cmd/main/`) | `main.go` | 145 | Entry point, HTTP routing, configuration, request validation |
|
||||||
|
| `internal` | `codec.go` (139), `http.go` (43) | 182 | SOAP envelope construction, response parsing, HTTP transport |
|
||||||
|
|
||||||
|
This is a **minimal two-layer split**: presentation (HTTP handlers) in `main`, and domain logic (SOAP encoding, transport) in `internal`. The separation is functional but thin—the `internal` package has no types of its own; everything is functions operating on raw strings.
|
||||||
|
|
||||||
|
### 1.2 Entry Point: `cmd/main/main.go`
|
||||||
|
|
||||||
|
**Configuration** (`cmd/main/main.go:14-22`):
|
||||||
|
|
||||||
|
| Constant | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `servicePrefix` | `"/services/ExchangeService"` | SOAP endpoint path suffix appended to every CIIMS URL |
|
||||||
|
| `defaultTimeout` | `240` | Fallback timeout in seconds when `CIIMS_TIMEOUT` is unset |
|
||||||
|
| `maxMsgLen` | `1048576` (1 MB) | Request body size cap enforced via `http.MaxBytesReader` |
|
||||||
|
| `maxCount` | `1000` | Maximum value accepted for the `count` field on `/receive` |
|
||||||
|
|
||||||
|
Two package-level variables hold runtime configuration:
|
||||||
|
|
||||||
|
| Variable | Source | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `defaultURL` | `CIIMS_SERVER` env | `""` |
|
||||||
|
| `timeout` | `CIIMS_TIMEOUT` env | `240` |
|
||||||
|
|
||||||
|
**Note:** `timeout` is still a package-level global—handlers are coupled to shared mutable state. A struct-based config injected via closure would be more idiomatic and testable.
|
||||||
|
|
||||||
|
**`main()`** (`cmd/main/main.go:24-51`):
|
||||||
|
|
||||||
|
- Reads three environment variables: `CIIMS_SERVER`, `PROXY_LISTEN`, `CIIMS_TIMEOUT`.
|
||||||
|
- `getIntEnv` returns `(int, error)`—the error is handled cleanly with `log.Errorf` + `os.Exit(1)`.
|
||||||
|
- If `CIIMS_TIMEOUT` is unset (`t == 0`), the package-level `timeout` retains its default of 240. If set and valid, it overrides.
|
||||||
|
- Three routes: `GET /ping`, `POST /send`, `POST /receive`.
|
||||||
|
- Uses `gin.Default()` which includes Logger and Recovery middleware.
|
||||||
|
|
||||||
|
**`sendMessage()`** (`cmd/main/main.go:54-89`):
|
||||||
|
|
||||||
|
- Body size limited via `http.MaxBytesReader` to 1 MB (`cmd/main/main.go:56`). Gin returns 413 if exceeded.
|
||||||
|
- Anonymous struct with `binding:"required"` tags on `User`, `Pass`, `Event`, `Msg`.
|
||||||
|
- Calls `internal.CreateSend()` → `internal.Send()` → `internal.GetErrMsg()`.
|
||||||
|
- On SOAP fault: logs the fault message, returns `500` with the fault text, and **returns** (no fall-through to 200 OK).
|
||||||
|
- On network error: logs the error, returns `500`.
|
||||||
|
- On success: returns `200 {"error":""}`.
|
||||||
|
|
||||||
|
**`receiveMessage()`** (`cmd/main/main.go:91-123`):
|
||||||
|
|
||||||
|
- Same body size limit.
|
||||||
|
- Additional validation: `count` must be 1–1000 (`cmd/main/main.go:108-111`).
|
||||||
|
- Calls `internal.CreateReceive()` → `internal.Send()` → `internal.GetMsgs()`.
|
||||||
|
- Returns `200 {"msgs": [...]}` on success.
|
||||||
|
- **Gap:** Does not check for SOAP faults in the response. If CIIMS returns a fault, the proxy returns 200 with an empty message list.
|
||||||
|
|
||||||
|
**`getURL()`** (`cmd/main/main.go:125-133`):
|
||||||
|
|
||||||
|
- If the request provides a `url`, uses it + `servicePrefix`; otherwise uses `defaultURL` + `servicePrefix`.
|
||||||
|
- This means the per-request `url` field can override the global CIIMS server—useful for multi-tenant or failover scenarios.
|
||||||
|
|
||||||
|
**`getIntEnv()`** (`cmd/main/main.go:135-144`):
|
||||||
|
|
||||||
|
- Returns `(int, error)`, caller handles the error.
|
||||||
|
- Returns `(0, nil)` when unset—caller interprets `0` as "use default."
|
||||||
|
|
||||||
|
### 1.3 SOAP Codec: `internal/codec.go`
|
||||||
|
|
||||||
|
**Template constants** (`internal/codec.go:12-88`):
|
||||||
|
|
||||||
|
- `sendtpl` (lines 12-53): A complete SOAP 1.1 envelope with `BHIA_CIIMS:AuthenticationToken` header and `ns1:send` body. Contains six `##placeholder##` tokens.
|
||||||
|
- `receivetpl` (lines 56-84): Similar envelope but with `ns1:receive` body and a single `##count##` token.
|
||||||
|
- Both templates have inconsistent indentation: `sendtpl` uses spaces, `receivetpl` uses tabs.
|
||||||
|
- The templates contain blank lines between every XML element—this bloats the wire payload unnecessarily.
|
||||||
|
|
||||||
|
**`CreateSend()`** (`internal/codec.go:91-101`):
|
||||||
|
|
||||||
|
- Six `strings.Replace` calls, one per placeholder.
|
||||||
|
- `user`, `pass`, `event` are escaped via `xmlEscape()`.
|
||||||
|
- `message` is escaped via `xml.Escape` directly into a `bytes.Buffer`.
|
||||||
|
- `priority` and `valXML` are formatted with `strconv.Itoa` and `strconv.FormatBool`—these are numeric/boolean so escaping is unnecessary.
|
||||||
|
- Returns the complete SOAP envelope as a string.
|
||||||
|
|
||||||
|
**`CreateReceive()`** (`internal/codec.go:104-109`):
|
||||||
|
|
||||||
|
- Three `strings.Replace` calls.
|
||||||
|
- `user` and `pass` escaped; `count` formatted with `strconv.Itoa`.
|
||||||
|
|
||||||
|
**`xmlEscape()`** (`internal/codec.go:112-116`):
|
||||||
|
|
||||||
|
- Thin wrapper around `xml.Escape` that returns a string instead of writing to a buffer.
|
||||||
|
- Allocates a new `bytes.Buffer` per call—three allocations per `CreateSend` invocation.
|
||||||
|
|
||||||
|
**`GetMsgs()`** (`internal/codec.go:118-128`):
|
||||||
|
|
||||||
|
- Compiles the `msgExp` regex at call time via `regexp.MustCompile`. This is wasteful—the regex is constant and should be compiled once at package init.
|
||||||
|
- Uses `FindAllStringSubmatch` with capture group 1 (the inner content).
|
||||||
|
- Applies `html.UnescapeString` to decode XML entities back to raw characters.
|
||||||
|
- The regex `<\w+:string>(.*?)</\w+:string>` is namespace-agnostic—it matches any prefix like `ns1`, `ns2`, etc.
|
||||||
|
|
||||||
|
**`GetErrMsg()`** (`internal/codec.go:131-138`):
|
||||||
|
|
||||||
|
- Same pattern: compiles regex at call time, uses capture group 1, applies `html.UnescapeString`.
|
||||||
|
- The regex `<errorMessage[^>]*>(.*?)</errorMessage>` handles attributes on the opening tag.
|
||||||
|
|
||||||
|
### 1.4 HTTP Transport: `internal/http.go`
|
||||||
|
|
||||||
|
**`postSim()`** (`internal/http.go:15-37`):
|
||||||
|
|
||||||
|
- Creates a new `http.Client` per call with a per-call timeout. This is inefficient—`http.Client` is designed to be reused (connection pooling). A single client should be created at startup.
|
||||||
|
- The timeout calculation `time.Duration(time.Duration(sec) * time.Second)` has a redundant double `time.Duration` cast.
|
||||||
|
- Sets three headers: `Content-Type: text/xml; charset=UTF-8`, `User-Agent` (masquerading as XFire/IE6 from 2005), and `SOAPAction: ""`.
|
||||||
|
- Returns `(err.Error(), err)` on failure—this duplicates the error message in both return values.
|
||||||
|
- Uses `ioutil.ReadAll` which is deprecated since Go 1.16 (now `io.ReadAll`).
|
||||||
|
|
||||||
|
**`Send()`** (`internal/http.go:40-42`):
|
||||||
|
|
||||||
|
- A one-line passthrough to `postSim`. This is a redundant abstraction layer—`postSim` could be renamed to `Send` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test Analysis
|
||||||
|
|
||||||
|
### 2.1 Test Structure
|
||||||
|
|
||||||
|
Two test files, both in `package internal`:
|
||||||
|
|
||||||
|
| File | Tests | Focus |
|
||||||
|
|---|---|---|
|
||||||
|
| `codec_test.go` | 5 tests | Unit tests for SOAP construction and response parsing |
|
||||||
|
| `handler_test.go` | 11 tests | Integration-style tests for HTTP transport + codec |
|
||||||
|
|
||||||
|
### 2.2 `codec_test.go` — Legacy Tests (unchanged)
|
||||||
|
|
||||||
|
| Test | What It Verifies | Approach |
|
||||||
|
|---|---|---|
|
||||||
|
| `TestSend` | `CreateSend` produces exact expected XML | Golden-file string comparison |
|
||||||
|
| `TestReceive` | `CreateReceive` produces exact expected XML | Golden-file string comparison |
|
||||||
|
| `TestGetMsg` | `GetMsgs` extracts 2 messages from `ReceiveResp` | Count + content assertion |
|
||||||
|
| `TestGetErrMsg` | `GetErrMsg` extracts fault text from `ErrMsg` | Exact string match |
|
||||||
|
| `TestSendOk` | `GetErrMsg` returns `""` for successful response | Empty string check |
|
||||||
|
|
||||||
|
**Assessment:** The golden-file tests are brittle—any whitespace change in the template breaks them. However, they serve as a strong regression safety net for the SOAP format.
|
||||||
|
|
||||||
|
### 2.3 `handler_test.go` — New Tests
|
||||||
|
|
||||||
|
| Test | What It Verifies | Approach |
|
||||||
|
|---|---|---|
|
||||||
|
| `TestSend_SOAPFault` | Full round-trip: mock CIIMS returns fault → `GetErrMsg` extracts it | `httptest.Server` |
|
||||||
|
| `TestSend_Success` | Mock CIIMS returns success → `GetErrMsg` returns `""` | `httptest.Server` |
|
||||||
|
| `TestSend_ReceiveMessages` | Mock CIIMS returns messages → `GetMsgs` extracts them | `httptest.Server` |
|
||||||
|
| `TestSend_NetworkTimeout` | Client timeout triggers when server never responds | Raw `net.Listener` |
|
||||||
|
| `TestSend_ServerError` | Connection refused produces error | Invalid port (127.0.0.1:1) |
|
||||||
|
| `TestGetMsgs_Empty` | Empty SOAP body returns zero messages | Direct call |
|
||||||
|
| `TestGetMsgs_DifferentNamespacePrefix` | `ns2:string` works (namespace-agnostic regex) | Direct call |
|
||||||
|
| `TestGetErrMsg_NoError` | Successful response returns `""` | Direct call |
|
||||||
|
| `TestGetErrMsg_Empty` | Empty SOAP body returns `""` | Direct call |
|
||||||
|
| `TestCreateSend_XMLEscapes` | XML metacharacters in user/pass are escaped | `assert.Contains` |
|
||||||
|
| `TestCreateReceive_XMLEscapes` | XML metacharacters in user/pass are escaped | `assert.Contains` |
|
||||||
|
|
||||||
|
### 2.4 Test Coverage Gaps
|
||||||
|
|
||||||
|
| Area | Covered? | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| SOAP send construction | Yes | Golden file + XML escape tests |
|
||||||
|
| SOAP receive construction | Yes | Golden file + XML escape tests |
|
||||||
|
| Message extraction | Yes | Happy path, empty, different namespace |
|
||||||
|
| Error extraction | Yes | Fault, success, empty |
|
||||||
|
| HTTP timeout | Yes | Raw listener approach |
|
||||||
|
| HTTP connection error | Yes | Invalid port |
|
||||||
|
| Handler-level (Gin endpoints) | **No** | No tests for `/send` or `/receive` HTTP handlers |
|
||||||
|
| Body size limit | **No** | Not tested |
|
||||||
|
| Count validation | **No** | Not tested |
|
||||||
|
| Malformed JSON | **No** | Not tested |
|
||||||
|
| Missing required fields | **No** | Not tested |
|
||||||
|
| SOAP fault in receive | **No** | Not tested (and not implemented) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Abstraction Analysis
|
||||||
|
|
||||||
|
### 3.1 Current Abstraction Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ HTTP Layer (cmd/main/main.go) │
|
||||||
|
│ - Routing (Gin) │
|
||||||
|
│ - JSON binding/validation │
|
||||||
|
│ - Body size limiting │
|
||||||
|
│ - Error → HTTP status mapping │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Codec Layer (internal/codec.go) │
|
||||||
|
│ - JSON → SOAP XML (CreateSend/Receive) │
|
||||||
|
│ - SOAP XML → domain data (GetMsgs/ │
|
||||||
|
│ GetErrMsg) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Transport Layer (internal/http.go) │
|
||||||
|
│ - HTTP POST with timeout │
|
||||||
|
│ - SOAP header management │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Abstraction Quality
|
||||||
|
|
||||||
|
**Strengths:**
|
||||||
|
|
||||||
|
- The three layers have clear responsibilities: routing, encoding, transport.
|
||||||
|
- The `internal` package hides SOAP complexity from the HTTP handlers.
|
||||||
|
- The handlers don't know about SOAP; the codec doesn't know about HTTP routing.
|
||||||
|
|
||||||
|
**Weaknesses:**
|
||||||
|
|
||||||
|
1. **Anemic domain model**: There are no types representing CIIMS messages. Everything is `string` in, `string` out. This means:
|
||||||
|
- No compile-time guarantees about message structure.
|
||||||
|
- No way to add methods or validation to message types.
|
||||||
|
- `CreateSend` has 6 positional parameters—easy to misorder.
|
||||||
|
|
||||||
|
2. **Leaky transport abstraction**: `Send()` returns `(string, error)` where the string is raw XML. The caller (`sendMessage`) then calls `GetErrMsg(resp)` to check for SOAP faults. The transport layer should either:
|
||||||
|
- Return a parsed response struct, or
|
||||||
|
- Have the codec layer wrap the transport call entirely.
|
||||||
|
|
||||||
|
3. **Redundant `Send` wrapper**: `Send()` is a one-line call to `postSim()`. This is unnecessary indirection.
|
||||||
|
|
||||||
|
4. **No configuration type**: `defaultURL` and `timeout` are package-level globals in `main`. There's no `Config` struct, making the code harder to test and reason about.
|
||||||
|
|
||||||
|
5. **Regex compiled at call time**: `GetMsgs` and `GetErrMsg` compile their regex patterns on every invocation. For a proxy that may handle many requests, this is wasteful. The patterns should be `var` declarations compiled at init time.
|
||||||
|
|
||||||
|
### 3.3 Suggested Abstraction Improvements
|
||||||
|
|
||||||
|
```go
|
||||||
|
// A typed request would prevent parameter ordering bugs:
|
||||||
|
type SendRequest struct {
|
||||||
|
User string
|
||||||
|
Pass string
|
||||||
|
Priority int
|
||||||
|
Event string
|
||||||
|
ValXML bool
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A typed response would encapsulate parsing:
|
||||||
|
type CIIMSResponse struct {
|
||||||
|
RawXML string
|
||||||
|
}
|
||||||
|
func (r *CIIMSResponse) IsFault() bool { ... }
|
||||||
|
func (r *CIIMSResponse) ErrorMessage() string { ... }
|
||||||
|
func (r *CIIMSResponse) Messages() []string { ... }
|
||||||
|
|
||||||
|
// A Config struct would eliminate globals:
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string
|
||||||
|
Timeout time.Duration
|
||||||
|
Listen string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Logic Analysis
|
||||||
|
|
||||||
|
### 4.1 Request Flow: `POST /send`
|
||||||
|
|
||||||
|
```
|
||||||
|
Client POST /send {"user":"FIMS","pass":"x","event":"E1","msg":"<M/>"}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
sendMessage()
|
||||||
|
├─ http.MaxBytesReader (1MB limit) [cmd/main/main.go:56]
|
||||||
|
├─ c.Bind(&message) [cmd/main/main.go:67]
|
||||||
|
│ └─ Gin validates binding:"required" fields
|
||||||
|
├─ internal.CreateSend(user,pass,pri,evt,val,msg)
|
||||||
|
│ │ [internal/codec.go:91-101]
|
||||||
|
│ ├─ xml.Escape(message) → <M/>
|
||||||
|
│ ├─ xmlEscape(user) → FIMS (no change)
|
||||||
|
│ ├─ xmlEscape(pass) → x (no change)
|
||||||
|
│ ├─ xmlEscape(event) → E1 (no change)
|
||||||
|
│ └─ strings.Replace × 6 → full SOAP envelope
|
||||||
|
├─ getURL(message.URL) [cmd/main/main.go:125-133]
|
||||||
|
│ └─ url + "/services/ExchangeService" or defaultURL + prefix
|
||||||
|
├─ internal.Send(url, msg, timeout) [internal/http.go:40-42]
|
||||||
|
│ └─ postSim(url, msg, timeout)
|
||||||
|
│ │ [internal/http.go:15-37]
|
||||||
|
│ ├─ http.Client{Timeout: timeout}
|
||||||
|
│ ├─ POST with SOAP headers
|
||||||
|
│ └─ ioutil.ReadAll → raw XML string
|
||||||
|
├─ internal.GetErrMsg(resp) [internal/codec.go:131-138]
|
||||||
|
│ ├─ regexp: <errorMessage[^>]*>(.*?)</errorMessage>
|
||||||
|
│ └─ html.UnescapeString(capture group)
|
||||||
|
│
|
||||||
|
├─ [if errMsg != ""] → 500 {"error": errMsg}
|
||||||
|
└─ [else] → 200 {"error": ""}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Request Flow: `POST /receive`
|
||||||
|
|
||||||
|
```
|
||||||
|
Client POST /receive {"user":"FIMS","pass":"x","count":5}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
receiveMessage()
|
||||||
|
├─ http.MaxBytesReader (1MB limit) [cmd/main/main.go:93]
|
||||||
|
├─ c.Bind(&message) [cmd/main/main.go:101]
|
||||||
|
├─ count validation: 1 ≤ count ≤ 1000 [cmd/main/main.go:108-111]
|
||||||
|
├─ internal.CreateReceive(user, pass, count) [internal/codec.go:104-109]
|
||||||
|
├─ getURL(message.URL)
|
||||||
|
├─ internal.Send(url, msg, timeout)
|
||||||
|
└─ internal.GetMsgs(resp) [internal/codec.go:118-128]
|
||||||
|
├─ regexp: <\w+:string>(.*?)</\w+:string>
|
||||||
|
├─ FindAllStringSubmatch → capture groups
|
||||||
|
└─ html.UnescapeString each → []string
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Error Handling Matrix
|
||||||
|
|
||||||
|
| Error Scenario | Detection | HTTP Status | Response Body | Logged? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Invalid JSON / missing required field | `c.Bind` returns error | 400 | `{"error":"..."}` | Yes |
|
||||||
|
| Body exceeds 1MB | `MaxBytesReader` triggers | 413 (Gin) | Gin default | No |
|
||||||
|
| `count` out of range | Manual check | 400 | `{"error":"count must be..."}` | No |
|
||||||
|
| Invalid `CIIMS_TIMEOUT` env | `getIntEnv` returns error | N/A (`os.Exit`) | N/A | Yes |
|
||||||
|
| Network error (timeout, DNS, refused) | `Send` returns error | 500 | `{"error":"..."}` | Yes |
|
||||||
|
| CIIMS SOAP fault (send) | `GetErrMsg` returns non-empty | 500 | `{"error":"<fault>"}` | Yes |
|
||||||
|
| CIIMS SOAP fault (receive) | Not checked | 200 | `{"msgs":[]}` (empty) | **No** |
|
||||||
|
| HTTP non-200 from CIIMS | Not checked | 200 | Depends on body | **No** |
|
||||||
|
|
||||||
|
**Two gaps remain:**
|
||||||
|
|
||||||
|
1. **`receiveMessage` does not check for SOAP faults**—if CIIMS returns a fault for a receive request, the proxy returns 200 with an empty message list instead of an error.
|
||||||
|
2. **HTTP status codes from CIIMS are ignored**—`postSim` reads the body regardless of status code. A 500 from CIIMS would be treated as a successful response.
|
||||||
|
|
||||||
|
### 4.4 Concurrency & Thread Safety
|
||||||
|
|
||||||
|
- `defaultURL` and `timeout` are set in `main()` before `r.Run()` starts the server. Since Gin handlers read these values after they're set and they're never written again, there is **no data race** in practice.
|
||||||
|
- However, if someone added a reload endpoint that modified these variables at runtime, it would be racy. A `sync/atomic` or `sync.RWMutex` would be needed.
|
||||||
|
- `http.Client` is created per request in `postSim`—this is safe but inefficient (no connection pooling).
|
||||||
|
|
||||||
|
### 4.5 Memory & Allocation Profile
|
||||||
|
|
||||||
|
Per `POST /send` request, the following allocations occur:
|
||||||
|
|
||||||
|
1. Gin request parsing (framework overhead)
|
||||||
|
2. `bytes.Buffer` for XML-escaped message (`CreateSend`)
|
||||||
|
3. Three `bytes.Buffer` allocations for `xmlEscape(user)`, `xmlEscape(pass)`, `xmlEscape(event)`
|
||||||
|
4. Six `strings.Replace` calls, each allocating a new string (the SOAP template is ~1.5 KB)
|
||||||
|
5. `http.NewRequest` + `strings.NewReader(msg)` (another copy)
|
||||||
|
6. `ioutil.ReadAll` for the response body
|
||||||
|
7. `regexp.MustCompile` + match allocations in `GetErrMsg`
|
||||||
|
|
||||||
|
For a low-throughput proxy this is fine. For high throughput, the repeated regex compilation and multiple string copies per request would benefit from optimization (pre-compiled regex, `strings.Builder`, reusable buffers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Summary Assessment
|
||||||
|
|
||||||
|
| Dimension | Rating | Key Points |
|
||||||
|
|---|---|---|
|
||||||
|
| **Correctness** | Good | Critical bugs (nil panic, missing return, timeout=0) are fixed. Two gaps remain: receive fault detection and HTTP status checking. |
|
||||||
|
| **Security** | Adequate | Body size limits, XML escaping, input validation added. No caller authentication—delegated to reverse proxy. |
|
||||||
|
| **Testability** | Fair | 16 tests cover codec and transport well. Handler-level (Gin endpoint) tests are missing. |
|
||||||
|
| **Abstraction** | Basic | Three-layer split is clean but uses primitive types exclusively. No domain types, no Config struct. |
|
||||||
|
| **Performance** | Adequate | Per-request regex compilation and multiple string copies are suboptimal but acceptable for low-volume use. |
|
||||||
|
| **Maintainability** | Fair | Dead code removed, dependencies upgraded. Template-based SOAP and regex parsing are inherently fragile. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Remaining Recommendations
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
1. **Add SOAP fault detection to `receiveMessage`** — mirror the `GetErrMsg` check from `sendMessage` to avoid silently returning empty results on CIIMS errors.
|
||||||
|
|
||||||
|
2. **Check HTTP status codes from CIIMS** — in `postSim`, check `resp.StatusCode` and return an error for non-2xx responses.
|
||||||
|
|
||||||
|
3. **Pre-compile regex patterns** — move `regexp.MustCompile` calls to package-level `var` declarations so they run once at init, not per request.
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
4. **Reuse `http.Client`** — create a single client at startup with the configured timeout rather than allocating one per request.
|
||||||
|
|
||||||
|
5. **Replace `ioutil.ReadAll`** with `io.ReadAll` — the former is deprecated since Go 1.16.
|
||||||
|
|
||||||
|
6. **Add handler-level tests** — use Gin's `httptest` to test the `/send` and `/receive` endpoints end-to-end, including body size limits, count validation, and malformed JSON.
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
7. **Introduce domain types** — `SendRequest`, `ReceiveRequest`, `CIIMSResponse` structs to replace raw string passing.
|
||||||
|
|
||||||
|
8. **Introduce a `Config` struct** — eliminate package-level globals for `defaultURL` and `timeout`.
|
||||||
|
|
||||||
|
9. **Normalize template indentation** — make `sendtpl` and `receivetpl` use consistent whitespace.
|
||||||
|
|
||||||
|
10. **Remove the redundant `Send` wrapper** — rename `postSim` to `Send` and delete the passthrough.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# CIIMS Proxy — Refactor & Refine Plan
|
||||||
|
|
||||||
|
> Based on [2026-07-08-ciimsproxy-codebase-analysis.md](./2026-07-08-ciimsproxy-codebase-analysis.md)
|
||||||
|
> Go 1.22 · 4 source files · 16 tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Address the remaining correctness gaps, improve abstraction quality, and optimize performance identified in the codebase analysis. The proxy is already functional and stable; this plan targets the next tier of quality improvements without breaking the existing HTTP API contract. SOAP template cleanup must preserve SOAP semantics; any byte-for-byte wire-format change should be explicit and isolated.
|
||||||
|
|
||||||
|
Expected outcomes:
|
||||||
|
|
||||||
|
- `receiveMessage` detects and reports CIIMS SOAP faults instead of silently returning empty results.
|
||||||
|
- HTTP non-2xx responses from CIIMS are treated as errors while still allowing SOAP fault bodies to be parsed and returned.
|
||||||
|
- SOAP response parsing is hardened for expected XML formatting variations; any retained regex patterns are compiled once at startup, not per request.
|
||||||
|
- A configured HTTP transport client is injected into handlers/tests instead of relying on package-level mutable client state.
|
||||||
|
- Deprecated `ioutil.ReadAll` is replaced with `io.ReadAll`.
|
||||||
|
- Handler-level (Gin endpoint) tests are added.
|
||||||
|
- Domain types and a `Config` struct improve type safety and testability.
|
||||||
|
- Template indentation is normalized for readability only if the team accepts the resulting byte-for-byte SOAP request change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1 — Correctness (High Priority)
|
||||||
|
|
||||||
|
#### 1. Add SOAP Fault Detection to `receiveMessage`
|
||||||
|
|
||||||
|
- [ ] In `cmd/main/main.go`, after the outbound CIIMS call in `receiveMessage`, call `internal.GetErrMsg(resp)` and return `500` with the fault text if non-empty.
|
||||||
|
- [ ] If the outbound call returns a typed HTTP status error with a response body, inspect that body with `internal.GetErrMsg` before falling back to the transport/status error message.
|
||||||
|
- [ ] Log the SOAP fault at error level, consistent with `sendMessage`.
|
||||||
|
- [ ] Add a handler-level test in `cmd/main/main_test.go`: mock CIIMS returns `ErrMsg` for a receive call, verify `/receive` returns `500 {"error":"<fault text>"}` instead of `200 {"msgs":[]}`.
|
||||||
|
|
||||||
|
#### 2. Check HTTP Status Codes from CIIMS
|
||||||
|
|
||||||
|
- [ ] In `internal/http.go`, after `client.Do(req)`, check `resp.StatusCode`.
|
||||||
|
- [ ] Always read and close the response body once. If status is not 2xx, return the body string alongside a typed error such as `HTTPStatusError{StatusCode, Status, Body}`.
|
||||||
|
- [ ] Make `HTTPStatusError.Error()` include the status code and a truncated/sanitized body snippet, while still exposing the full body field for SOAP fault parsing by the handler.
|
||||||
|
- [ ] Update `sendMessage` and `receiveMessage` error handling: when `err != nil`, first parse `resp` or the typed error body for a SOAP fault; if found, return that fault, otherwise return the transport/status error.
|
||||||
|
- [ ] Add a test: mock CIIMS returns `500 Internal Server Error`, verify `Send()` returns an error.
|
||||||
|
- [ ] Add a test: mock CIIMS returns HTTP `500` with SOAP fault XML, verify the handler returns the SOAP fault text rather than a generic HTTP status error.
|
||||||
|
- [ ] Add a test: mock CIIMS returns `200 OK`, verify `Send()` succeeds normally.
|
||||||
|
|
||||||
|
### Phase 2 — Performance (High Priority)
|
||||||
|
|
||||||
|
#### 3. Harden Response Parsing and Pre-compile Patterns
|
||||||
|
|
||||||
|
- [ ] Prefer replacing regex-based parsing in `GetMsgs` and `GetErrMsg` with `encoding/xml.Decoder` token parsing that matches elements by local name (`string`, `errorMessage`) regardless of namespace prefix.
|
||||||
|
- [ ] If regex is retained as a short-term step, update patterns to handle expected formatting variations such as attributes on `<prefix:string>` and multiline content.
|
||||||
|
- [ ] If regex is retained, move `regexp.MustCompile(msgExp)` and `regexp.MustCompile(errExp)` from function bodies to package-level `var` declarations.
|
||||||
|
- [ ] Name them `msgRegex` and `errRegex`.
|
||||||
|
- [ ] Update `GetMsgs` and `GetErrMsg` to use the pre-compiled variables.
|
||||||
|
- [ ] Add tests for multiline string payloads, attributes on string elements, whitespace variations, and different namespace prefixes.
|
||||||
|
- [ ] Verify all existing tests pass without changes unless parser behavior is intentionally broadened.
|
||||||
|
|
||||||
|
#### 4. Inject a Configured HTTP Client
|
||||||
|
|
||||||
|
- [ ] Do **not** introduce package-level mutable client state such as `var httpClient *http.Client` plus `InitClient`; this risks nil-client bugs and test pollution.
|
||||||
|
- [ ] In `internal/http.go`, introduce a transport type such as `type Client struct { httpClient *http.Client }`.
|
||||||
|
- [ ] Add `NewClient(timeoutSec int) (*Client, error)` to validate/default the timeout and construct the underlying `http.Client`.
|
||||||
|
- [ ] Move outbound send behavior to a method such as `func (c *Client) Send(url, msg string) (string, error)`.
|
||||||
|
- [ ] Construct the client in `main()` after resolving configuration and pass it to handlers through a `Config`/handler struct or closure.
|
||||||
|
- [ ] Update tests to create their own `internal.Client` instances so timeout settings do not leak between tests.
|
||||||
|
- [ ] Add a test that verifies the injected client timeout is honored.
|
||||||
|
- [ ] Ensure the existing `TestSend_NetworkTimeout` test still passes.
|
||||||
|
|
||||||
|
### Phase 3 — Modernization (Medium Priority)
|
||||||
|
|
||||||
|
#### 5. Replace Deprecated `ioutil.ReadAll`
|
||||||
|
|
||||||
|
- [ ] In `internal/http.go`, replace `ioutil.ReadAll(resp.Body)` with `io.ReadAll(resp.Body)`.
|
||||||
|
- [ ] Remove the `"io/ioutil"` import and add `"io"`.
|
||||||
|
- [ ] Verify build and tests pass.
|
||||||
|
|
||||||
|
#### 6. Add Handler-Level Tests
|
||||||
|
|
||||||
|
- [ ] Create `cmd/main/main_test.go` with Gin's `httptest` setup.
|
||||||
|
- [ ] Test `POST /send` with valid JSON → mock CIIMS returns success → assert `200 {"error":""}`.
|
||||||
|
- [ ] Test `POST /send` with valid JSON → mock CIIMS returns SOAP fault → assert `500` with fault text.
|
||||||
|
- [ ] Test `POST /send` with missing required field → assert `400`.
|
||||||
|
- [ ] Update handlers to detect `*http.MaxBytesError` from binding and return `http.StatusRequestEntityTooLarge`.
|
||||||
|
- [ ] Test `POST /send` with body exceeding 1 MB → assert `413`.
|
||||||
|
- [ ] Test `POST /receive` with valid JSON → mock CIIMS returns messages → assert `200 {"msgs":[...]}`.
|
||||||
|
- [ ] Test `POST /receive` with `count` = 0 → assert `400`.
|
||||||
|
- [ ] Test `POST /receive` with `count` = 1001 → assert `400`.
|
||||||
|
- [ ] Test `POST /receive` with valid JSON → mock CIIMS returns SOAP fault → assert `500`.
|
||||||
|
- [ ] Test `GET /ping` → assert `200 {"message":"pong"}`.
|
||||||
|
|
||||||
|
### Phase 4 — Abstraction (Low Priority)
|
||||||
|
|
||||||
|
#### 7. Introduce Domain Types
|
||||||
|
|
||||||
|
- [ ] In `internal/codec.go`, define a `CIIMSResponse` struct with a `RawXML string` field.
|
||||||
|
- [ ] Add methods: `IsFault() bool`, `ErrorMessage() string`, `Messages() []string`.
|
||||||
|
- [ ] Update the injected client's `Send()` method to return `(*CIIMSResponse, error)` instead of `(string, error)`.
|
||||||
|
- [ ] Preserve access to any non-2xx response body through `CIIMSResponse.RawXML` and/or the typed status error so SOAP faults in HTTP 500 responses remain parseable.
|
||||||
|
- [ ] Update `sendMessage` and `receiveMessage` to use the new methods.
|
||||||
|
- [ ] Update all tests to use the new return type.
|
||||||
|
- [ ] Ensure no change in external behavior.
|
||||||
|
|
||||||
|
#### 8. Introduce a `Config` Struct
|
||||||
|
|
||||||
|
- [ ] In `cmd/main/main.go`, define a `Config` struct with fields `ServerURL`, `Timeout`, `Listen`.
|
||||||
|
- [ ] Include the injected CIIMS client/transport in `Config` or in a handler struct.
|
||||||
|
- [ ] Parse environment variables into a `Config` value in `main()`.
|
||||||
|
- [ ] Pass `Config` to handler functions via closure (or a handler struct).
|
||||||
|
- [ ] Remove package-level `defaultURL` and `timeout` variables.
|
||||||
|
- [ ] Update `getURL` to accept the server URL as a parameter.
|
||||||
|
- [ ] Verify all handler tests pass with the new structure.
|
||||||
|
|
||||||
|
#### 9. Remove Redundant `Send` Wrapper
|
||||||
|
|
||||||
|
- [ ] In `internal/http.go`, delete `postSim` after moving its logic into the injected client method.
|
||||||
|
- [ ] Decide whether to keep a package-level `Send(url, message, timeout)` compatibility wrapper for legacy internal tests; if kept temporarily, mark it as a thin compatibility helper and keep production handlers on the injected client.
|
||||||
|
- [ ] Otherwise, update all callers (in `main.go` and tests) to use `client.Send(...)`.
|
||||||
|
- [ ] Verify build and all tests pass.
|
||||||
|
|
||||||
|
### Phase 5 — Cleanup (Low Priority)
|
||||||
|
|
||||||
|
#### 10. Normalize Template Indentation
|
||||||
|
|
||||||
|
- [ ] Confirm this is acceptable as a byte-for-byte SOAP request change before implementing.
|
||||||
|
- [ ] In `internal/codec.go`, make `sendtpl` and `receivetpl` use consistent indentation (all spaces or all tabs).
|
||||||
|
- [ ] Update the golden-file test constants in `internal/codec_test.go` (`SendEsp`, `ReceiveEsp`) to match.
|
||||||
|
- [ ] Verify `TestSend` and `TestReceive` pass with the updated expected strings.
|
||||||
|
|
||||||
|
#### 11. Remove Unused `gommon/log` Dependency
|
||||||
|
|
||||||
|
- [ ] In `cmd/main/main.go`, replace `github.com/labstack/gommon/log` with Go's standard `log` package.
|
||||||
|
- [ ] Replace `log.Infof` → `log.Printf`, `log.Errorf` → `log.Printf("[ERROR] ...")` or use `log` with prefixes.
|
||||||
|
- [ ] Remove `github.com/labstack/gommon` from `go.mod` and run `go mod tidy`.
|
||||||
|
- [ ] Verify build and all tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Criteria
|
||||||
|
|
||||||
|
- [ ] `POST /receive` with a CIIMS SOAP fault returns `500 {"error":"<fault text>"}`, not `200 {"msgs":[]}`.
|
||||||
|
- [ ] CIIMS returning HTTP 500 produces an error from `Send()`, not a successful response.
|
||||||
|
- [ ] CIIMS returning HTTP 500 with a SOAP fault body returns the SOAP fault text to the caller.
|
||||||
|
- [ ] Oversized request bodies return `413`, not a generic `400`.
|
||||||
|
- [ ] `go test ./...` shows all tests passing; response parsing handles multiline/attribute/namespace-prefix variations.
|
||||||
|
- [ ] HTTP client timeout is configured through an injected client/transport and does not rely on package-level mutable initialization.
|
||||||
|
- [ ] `ioutil.ReadAll` no longer appears in the codebase.
|
||||||
|
- [ ] `cmd/main/main_test.go` exists with at least 9 handler-level tests covering all error paths.
|
||||||
|
- [ ] `internal.CIIMSResponse` type exists with `IsFault()`, `ErrorMessage()`, `Messages()` methods.
|
||||||
|
- [ ] `cmd/main` has no package-level `defaultURL` or `timeout` variables.
|
||||||
|
- [ ] `internal/http.go` has no `postSim`; production code uses the injected client `Send` method.
|
||||||
|
- [ ] `sendtpl` and `receivetpl` use consistent indentation only if the wire-format change was explicitly accepted.
|
||||||
|
- [ ] `github.com/labstack/gommon` is absent from `go.mod`.
|
||||||
|
- [ ] `go vet ./...` and `go build ./...` produce no errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Correctness) ──► Phase 2 (Performance) ──► Phase 3 (Modernization)
|
||||||
|
│
|
||||||
|
Phase 5 (Cleanup) ◄──────── Phase 4 (Abstraction) ◄────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Phases 1 and 2 should be completed first as they fix remaining bugs and transport/testability issues. Phase 4's `Config`/injected-client work may be pulled earlier if it simplifies Phase 2. Phases 3–5 are quality-of-life improvements that can be done in any order, but Phase 4 should precede Phase 5 since removing `gommon/log` may be affected by the `Config` struct refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Risks and Mitigations
|
||||||
|
|
||||||
|
1. **HTTP status code checking breaks CIIMS compatibility**
|
||||||
|
- Some SOAP services return `500` with a SOAP fault in the body (which is valid SOAP). The current code already handles this via `GetErrMsg`. The fix should check for 2xx and treat everything else as a transport error, but still allow the SOAP fault body to be extracted if present.
|
||||||
|
- Mitigation: On non-2xx, return a typed `HTTPStatusError` that exposes the response body and also return/preserve that body in the response value. The handler must parse SOAP faults first, then fall back to the status error.
|
||||||
|
|
||||||
|
2. **Injected `http.Client` changes timeout behavior**
|
||||||
|
- The current code creates a new client per request with the configured timeout. An injected client has a fixed timeout set at construction.
|
||||||
|
- Mitigation: This is the desired production behavior, and tests should construct isolated clients. If per-request timeout overrides are needed later, use `context.WithTimeout` on the request context.
|
||||||
|
|
||||||
|
3. **`CIIMSResponse` abstraction changes the return type**
|
||||||
|
- All callers of `Send()` must be updated.
|
||||||
|
- Mitigation: This is a mechanical refactor. The compiler will catch all call sites. Update them in one pass.
|
||||||
|
|
||||||
|
4. **Removing `gommon/log` changes log output format**
|
||||||
|
- Standard `log` package has a different default format (timestamps with date/time).
|
||||||
|
- Mitigation: This is acceptable—Gin already uses its own logger. Standardizing on `log` reduces dependencies and is more idiomatic.
|
||||||
|
|
||||||
|
5. **Template indentation change breaks golden-file tests**
|
||||||
|
- `TestSend` and `TestReceive` do exact string comparison.
|
||||||
|
- Mitigation: Update the expected constants (`SendEsp`, `ReceiveEsp`) in the same commit. The tests themselves verify correctness.
|
||||||
Reference in New Issue
Block a user