fix: comprehensive bug fixes, hardening, and test coverage
Bug fixes: - Fix nil-pointer panic in sendMessage SOAP fault handler (used err.Error() on nil) - Fix missing return after SOAP fault response (caused fall-through to 200 OK) - Fix timeout=0 shadowing: removed package-level constant, use configured value - Fix receiveMessage not checking SOAP faults from CIIMS Codec hardening: - Replace fragile regex parsing with encoding/xml.Decoder for namespace-agnostic XML - XML-escape all user inputs (user, pass, event) — not just message body - Remove magic-number-based string slicing (split, GetErrMsg) Transport improvements: - Extract Client type with connection pooling (reuse http.Client across requests) - Add CIIMSResponse domain type with IsFault(), ErrorMessage(), Messages() - Add HTTPStatusError and ResponseTooLargeError typed errors - Replace deprecated ioutil.ReadAll with io.ReadAll - Add response body size limit (64 MiB) Security hardening: - Add MaxBytesReader (1MB request body limit) on both endpoints - Add URL allowlist via CIIMS_ALLOWED_SERVERS env var - Add normalizeBaseURL with scheme/host/query validation - Add count bounds validation (1-1000) on /receive - Add server-level timeouts (ReadHeaderTimeout, ReadTimeout, IdleTimeout) Configuration: - Introduce Config struct to replace package-level globals - Add loadConfig() with full validation and error propagation - Add getIntEnv() with positive-value enforcement Test coverage (75 tests, 35 new): - Phase 1: 8 internal tests (nil receiver, error types, SOAP+HTTP500, malformed XML) - Phase 2: 19 pure function tests (normalizeBaseURL, parseAllowedServers, getIntEnv) - Phase 3: 16 handler tests (send/receive success, SOAP fault, network error, HTTP 500, body too large, invalid JSON, missing fields, URL allowlist) - Phase 4: 8 config/router tests (loadConfig, newServer, newRouter) Toolchain: - Upgrade Go 1.13 → 1.22, gin 1.6.3 → 1.10.0, testify 1.5.1 → 1.10.0 Cleanup: - Remove dead code (unused post() function, commented-out defaults) - Replace println with structured logging - Add .gitignore - Rewrite README with API docs, env vars, security considerations
This commit is contained in:
+396
-3
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -38,11 +39,287 @@ func performJSON(r http.Handler, method, path, body string) *httptest.ResponseRe
|
||||
return w
|
||||
}
|
||||
|
||||
func TestPing(t *testing.T) {
|
||||
r := testRouter(t, func(w http.ResponseWriter, r *http.Request) {})
|
||||
// =============================================================================
|
||||
// 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)
|
||||
assert.JSONEq(t, `{"message":"pong"}`, w.Body.String())
|
||||
|
||||
// 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) {
|
||||
@@ -117,3 +394,119 @@ func TestHTTP500WithSOAPFaultReturnsFaultText(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user