This commit is contained in:
Stephan D
2026-03-10 12:31:09 +01:00
parent d87e709f43
commit e77d1ab793
287 changed files with 2089 additions and 1550 deletions

View File

@@ -0,0 +1,47 @@
version: "2"
linters:
default: none
enable:
- bodyclose
- canonicalheader
- copyloopvar
- durationcheck
- errcheck
- errchkjson
- errname
- errorlint
- gosec
- govet
- ineffassign
- nilerr
- nilnesserr
- nilnil
- noctx
- rowserrcheck
- sqlclosecheck
- staticcheck
- unconvert
- wastedassign
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gochecknoinits
- gomoddirectives
- wrapcheck
- cyclop
- dupl
- funlen
- gocognit
- gocyclo
- ireturn
- lll
- mnd
- nestif
- nlreturn
- noinlineerr
- paralleltest
- tagliatelle
- testpackage
- varnamelen
- wsl_v5

View File

@@ -87,7 +87,7 @@ func (g *gatewayClient) callContext(ctx context.Context, method string) (context
}
g.logger.Info("Mntx gateway client call timeout applied", fields...)
}
return context.WithTimeout(ctx, timeout)
return context.WithTimeout(ctx, timeout) //nolint:gosec // cancel func is always invoked by call sites
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {

View File

@@ -410,7 +410,7 @@ func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits {
if bucket == "" {
continue
}
limits.VelocityLimit[bucket] = int32(value)
limits.VelocityLimit[bucket] = int32(value) //nolint:gosec // velocity limits are validated config values
}
}
@@ -426,7 +426,7 @@ func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits {
MinAmount: strings.TrimSpace(override.MinAmount),
MaxAmount: strings.TrimSpace(override.MaxAmount),
MaxFee: strings.TrimSpace(override.MaxFee),
MaxOps: int32(override.MaxOps),
MaxOps: int32(override.MaxOps), //nolint:gosec // max ops is a validated config value
}
}
}
@@ -522,11 +522,12 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
})
server := &http.Server{
Addr: cfg.Address,
Handler: router,
Addr: cfg.Address,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
}
ln, err := net.Listen("tcp", cfg.Address)
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", cfg.Address)
if err != nil {
return err
}

View File

@@ -53,7 +53,7 @@ func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*
return v, nil
}
}
return nil, nil
return nil, nil //nolint:nilnil // test store: payout not found by idempotency key
}
func (s *cardPayoutStore) FindByOperationRef(_ context.Context, ref string) (*model.CardPayout, error) {
@@ -64,7 +64,7 @@ func (s *cardPayoutStore) FindByOperationRef(_ context.Context, ref string) (*mo
return v, nil
}
}
return nil, nil
return nil, nil //nolint:nilnil // test store: payout not found by operation ref
}
func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) {
@@ -75,7 +75,7 @@ func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.
return v, nil
}
}
return nil, nil
return nil, nil //nolint:nilnil // test store: payout not found by payment id
}
func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error {

View File

@@ -102,7 +102,7 @@ func findOperationRef(operationRef, payoutID string) string {
func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) {
if p == nil || state == nil {
return nil, nil
return nil, nil //nolint:nilnil // nil processor/state means there is no existing payout state to load
}
if opRef := strings.TrimSpace(state.OperationRef); opRef != "" {
existing, err := p.store.Payouts().FindByOperationRef(ctx, opRef)
@@ -117,12 +117,12 @@ func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state
}
}
}
return nil, nil
return nil, nil //nolint:nilnil // nil means no payout state exists for the operation reference
}
func (p *cardPayoutProcessor) findAndMergePayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) {
if p == nil || state == nil {
return nil, nil
return nil, nil //nolint:nilnil // nil processor/state means there is no existing payout state to merge
}
existing, err := p.findExistingPayoutState(ctx, state)
if err != nil {
@@ -828,7 +828,7 @@ func (p *cardPayoutProcessor) retryContext() (context.Context, context.CancelFun
if timeout <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, timeout)
return context.WithTimeout(ctx, timeout) //nolint:gosec // cancel func is always invoked by caller
}
func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest, attempt uint32, maxAttempts uint32) {
@@ -1349,8 +1349,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
)
cardInput, err := validateCardTokenizeRequest(req, p.config)
if err != nil {
if _, err := validateCardTokenizeRequest(req, p.config); err != nil {
p.logger.Warn("Card tokenization validation failed",
zap.String("request_id", req.GetRequestId()),
zap.String("customer_id", req.GetCustomerId()),
@@ -1359,14 +1358,15 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
return nil, err
}
// validateCardTokenizeRequest was called above; here we normalize and map fields for provider payload.
req = sanitizeCardTokenizeRequest(req)
cardInput := extractTokenizeCard(req)
projectID, err := p.resolveProjectID(req.GetProjectId(), "request_id", req.GetRequestId())
if err != nil {
return nil, err
}
req = sanitizeCardTokenizeRequest(req)
cardInput = extractTokenizeCard(req)
client := monetix.NewClient(p.config, p.httpClient, p.logger)
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
@@ -1493,7 +1493,8 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
}
retryScheduled := false
if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled || state.Status == model.PayoutStatusNeedsAttention {
switch state.Status {
case model.PayoutStatusFailed, model.PayoutStatusCancelled, model.PayoutStatusNeedsAttention:
decision := p.retryPolicy.decideProviderFailure(state.ProviderCode)
attemptsUsed := p.currentDispatchAttempt(operationRef)
maxAttempts := p.maxDispatchAttempts()
@@ -1546,7 +1547,7 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
if !retryScheduled && strings.TrimSpace(state.FailureReason) == "" {
state.FailureReason = payoutFailureReason(state.ProviderCode, state.ProviderMessage)
}
} else if state.Status == model.PayoutStatusSuccess {
case model.PayoutStatusSuccess:
state.FailureReason = ""
}

View File

@@ -36,6 +36,15 @@ func (s staticClock) Now() time.Time {
return s.now
}
func mustMarshalJSON(t *testing.T, value any) []byte {
t.Helper()
body, err := json.Marshal(value)
if err != nil {
t.Fatalf("json marshal failed: %v", err)
}
return body
}
func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
@@ -57,7 +66,7 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp := monetix.APIResponse{}
resp.Operation.RequestID = "req-123"
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -117,7 +126,7 @@ func TestCardPayoutProcessor_Submit_AcceptedBodyErrorRemainsWaiting(t *testing.T
Code: "3062",
Message: "Payment details not received",
}
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -309,7 +318,7 @@ func TestCardPayoutProcessor_Submit_SameParentDifferentOperationsStoredSeparatel
callN++
resp := monetix.APIResponse{}
resp.Operation.RequestID = fmt.Sprintf("req-%d", callN)
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -397,7 +406,7 @@ func TestCardPayoutProcessor_StrictMode_BlocksSecondOperationUntilFirstFinalCall
n := callN.Add(1)
resp := monetix.APIResponse{}
resp.Operation.RequestID = fmt.Sprintf("req-%d", n)
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -589,7 +598,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
if n == 1 {
resp.Code = "10101"
resp.Message = "Decline due to amount or frequency limit"
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -597,7 +606,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *t
}, nil
}
resp.Operation.RequestID = "req-retry-success"
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -658,7 +667,7 @@ func TestCardPayoutProcessor_Submit_ProviderRetryUsesDelayedStrategy(t *testing.
Code: "10101",
Message: "Decline due to amount or frequency limit",
}
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -711,7 +720,7 @@ func TestCardPayoutProcessor_Submit_StatusRefreshRetryUsesStatusRefreshStrategy(
Code: "3061",
Message: "Transaction not found",
}
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -810,7 +819,7 @@ func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenNeedsAttentio
Code: "10101",
Message: "Decline due to amount or frequency limit",
}
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -874,7 +883,7 @@ func TestCardPayoutProcessor_Submit_NonRetryProviderDeclineRemainsFailed(t *test
Code: "10003",
Message: "Decline by anti-fraud policy",
}
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(bytes.NewReader(body)),
@@ -933,7 +942,7 @@ func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *t
} else {
resp.Operation.RequestID = "req-after-callback-retry"
}
body, _ := json.Marshal(resp)
body := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),

View File

@@ -40,8 +40,8 @@ func TestValidateCardTokenizeRequest_Expired(t *testing.T) {
cfg := testMonetixConfig()
req := validCardTokenizeRequest()
now := time.Now().UTC()
req.CardExpMonth = uint32(now.Month())
req.CardExpYear = uint32(now.Year() - 1)
req.CardExpMonth = uint32(now.Month()) //nolint:gosec // month value is bounded by time.Time
req.CardExpYear = uint32(now.Year() - 1) //nolint:gosec // test value intentionally uses previous year
_, err := validateCardTokenizeRequest(req, cfg)
requireReason(t, err, "expired_card")

View File

@@ -251,8 +251,8 @@ func buildCardPayoutRequestFromParams(reader params.Reader,
AmountMinor: amountMinor,
Currency: currency,
CardPan: strings.TrimSpace(reader.String("card_pan")),
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
CardExpYear: uint32(readerInt64(reader, "card_exp_year")), //nolint:gosec // values are validated by request validators
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")), //nolint:gosec // values are validated by request validators
CardHolder: strings.TrimSpace(reader.String("card_holder")),
Metadata: metadataFromReader(reader),
OperationRef: operationRef,

View File

@@ -128,12 +128,13 @@ func (m *strictIsolatedPayoutExecutionMode) tryAcquire(operationRef string) (<-c
return nil, false, errPayoutExecutionModeStopped
}
switch owner := strings.TrimSpace(m.activeOperation); {
case owner == "":
owner := strings.TrimSpace(m.activeOperation)
switch owner {
case "":
m.activeOperation = operationRef
m.signalLocked()
return nil, true, nil
case owner == operationRef:
case operationRef:
return nil, true, nil
default:
return m.waitCh, false, nil

View File

@@ -73,7 +73,6 @@ func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Helper()
got := policy.decideProviderFailure(tc.code)
@@ -121,7 +120,6 @@ func TestPayoutFailurePolicy_DocumentRetryCoverage(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.code, func(t *testing.T) {
t.Helper()
got := policy.decideProviderFailure(tc.code)
@@ -272,7 +270,6 @@ func TestRetryDelayForAttempt_ByStrategy(t *testing.T) {
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Helper()
if got := retryDelayForAttempt(tc.attempt, tc.strategy); got != tc.wantDelay {

View File

@@ -174,7 +174,7 @@ func (s *Service) startDiscoveryAnnouncer() {
if strings.TrimSpace(announce.ID) == "" {
announce.ID = discovery.StablePaymentGatewayID(discovery.RailCardPayout)
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.MntxGateway, announce)
s.announcer.Start()
}

View File

@@ -18,8 +18,8 @@ func requireReason(t *testing.T, err error, reason string) {
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error, got %v", err)
}
reasoned, ok := err.(payoutFailure)
if !ok {
var reasoned payoutFailure
if !errors.As(err, &reasoned) {
t.Fatalf("expected payout failure reason, got %T", err)
}
if reasoned.Reason() != reason {
@@ -82,5 +82,5 @@ func validCardTokenizeRequest() *mntxv1.CardTokenizeRequest {
func futureExpiry() (uint32, uint32) {
now := time.Now().UTC()
return uint32(now.Month()), uint32(now.Year() + 1)
return uint32(now.Month()), uint32(now.Year() + 1) //nolint:gosec // month/year values are bounded by time.Time
}

View File

@@ -67,7 +67,7 @@ func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *mod
return nil, emitErr
}
}
return nil, nil
return struct{}{}, nil
})
if err != nil {
p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID),

View File

@@ -129,7 +129,9 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
c.logger.Warn("Monetix tokenization request failed", fields...)
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
body, _ := io.ReadAll(resp.Body)
outcome := outcomeAccepted
@@ -252,7 +254,9 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
c.logger.Warn("Monetix request failed", fields...)
return nil, merrors.Internal("monetix request failed: " + err.Error())
}
defer resp.Body.Close()
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {

View File

@@ -21,6 +21,15 @@ func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func mustMarshalJSON(t *testing.T, value any) []byte {
t.Helper()
payload, err := json.Marshal(value)
if err != nil {
t.Fatalf("json marshal failed: %v", err)
}
return payload
}
func TestSendCardPayout_SignsPayload(t *testing.T) {
secret := "secret"
var captured CardPayoutRequest
@@ -36,7 +45,7 @@ func TestSendCardPayout_SignsPayload(t *testing.T) {
}
resp := APIResponse{}
resp.Operation.RequestID = "req-1"
payload, _ := json.Marshal(resp)
payload := mustMarshalJSON(t, resp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(payload)),
@@ -94,7 +103,7 @@ func TestSendCardPayout_NormalizesTwoDigitYearBeforeSend(t *testing.T) {
if err := json.Unmarshal(body, &captured); err != nil {
t.Fatalf("failed to decode request: %v", err)
}
payload, _ := json.Marshal(APIResponse{})
payload := mustMarshalJSON(t, APIResponse{})
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(payload)),
@@ -512,7 +521,7 @@ func TestSendCardTokenization_NormalizesTwoDigitYearBeforeSend(t *testing.T) {
if err := json.Unmarshal(body, &captured); err != nil {
t.Fatalf("failed to decode request: %v", err)
}
payload, _ := json.Marshal(APIResponse{})
payload := mustMarshalJSON(t, APIResponse{})
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(payload)),