package httpapi import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "card-to-iban/internal/audit" "card-to-iban/internal/card" "card-to-iban/internal/service" "card-to-iban/internal/zarinhub" ) type fakeConverter struct { result service.ConversionResult err error called bool } func (f *fakeConverter) ConvertCardToIBAN( ctx context.Context, cardNumber string, ) (service.ConversionResult, error) { f.called = true return f.result, f.err } type fakeAuditSaver struct { records []audit.Record err error } func (f *fakeAuditSaver) Save( ctx context.Context, record audit.Record, ) error { f.records = append(f.records, record) return f.err } func TestHandlerConvert(t *testing.T) { tests := []struct { name string method string body string result service.ConversionResult convertErr error wantHTTPStatus int wantErrorCode string wantAuditStatus audit.Status wantMaskedCard string wantCalled bool }{ { name: "success", method: http.MethodPost, body: `{"card":"6037997512345678"}`, result: service.ConversionResult{ IBAN: "IR820540102680020817909002", UpstreamHTTPStatus: http.StatusOK, RawResponse: []byte( `{"data":{"card":"6037997512345678","iban":"IR820540102680020817909002"}}`, ), }, wantHTTPStatus: http.StatusOK, wantAuditStatus: audit.StatusSuccess, wantMaskedCard: "603799******5678", wantCalled: true, }, { name: "malformed json", method: http.MethodPost, body: `{not-json}`, wantHTTPStatus: http.StatusBadRequest, wantErrorCode: "invalid_card", wantAuditStatus: audit.StatusValidationFailed, }, { name: "missing card", method: http.MethodPost, body: `{}`, convertErr: card.ErrCardRequired, wantHTTPStatus: http.StatusBadRequest, wantErrorCode: "invalid_card", wantAuditStatus: audit.StatusValidationFailed, wantCalled: true, }, { name: "wrong card length", method: http.MethodPost, body: `{"card":"123"}`, convertErr: card.ErrCardLength, wantHTTPStatus: http.StatusBadRequest, wantErrorCode: "invalid_card", wantAuditStatus: audit.StatusValidationFailed, wantCalled: true, }, { name: "non numeric card", method: http.MethodPost, body: `{"card":"603799ABC2345678"}`, convertErr: card.ErrCardNumeric, wantHTTPStatus: http.StatusBadRequest, wantErrorCode: "invalid_card", wantAuditStatus: audit.StatusValidationFailed, wantCalled: true, }, { name: "upstream rejection", method: http.MethodPost, body: `{"card":"6037997512345678"}`, convertErr: zarinhub.ErrRejected, wantHTTPStatus: http.StatusUnprocessableEntity, wantErrorCode: "conversion_rejected", wantAuditStatus: audit.StatusUpstreamRejected, wantMaskedCard: "603799******5678", wantCalled: true, }, { name: "upstream authentication failure", method: http.MethodPost, body: `{"card":"6037997512345678"}`, convertErr: zarinhub.ErrAuthentication, wantHTTPStatus: http.StatusBadGateway, wantErrorCode: "upstream_failure", wantAuditStatus: audit.StatusAuthenticationError, wantMaskedCard: "603799******5678", wantCalled: true, }, { name: "upstream timeout", method: http.MethodPost, body: `{"card":"6037997512345678"}`, convertErr: zarinhub.ErrTimeout, wantHTTPStatus: http.StatusGatewayTimeout, wantErrorCode: "upstream_timeout", wantAuditStatus: audit.StatusUpstreamTimeout, wantMaskedCard: "603799******5678", wantCalled: true, }, { name: "upstream unavailable", method: http.MethodPost, body: `{"card":"6037997512345678"}`, convertErr: zarinhub.ErrUnavailable, wantHTTPStatus: http.StatusServiceUnavailable, wantErrorCode: "upstream_unavailable", wantAuditStatus: audit.StatusUpstreamUnavailable, wantMaskedCard: "603799******5678", wantCalled: true, }, { name: "invalid upstream response", method: http.MethodPost, body: `{"card":"6037997512345678"}`, convertErr: zarinhub.ErrInvalidResponse, wantHTTPStatus: http.StatusBadGateway, wantErrorCode: "upstream_failure", wantAuditStatus: audit.StatusUpstreamInvalid, wantMaskedCard: "603799******5678", wantCalled: true, }, { name: "method not allowed", method: http.MethodGet, wantHTTPStatus: http.StatusMethodNotAllowed, wantErrorCode: "method_not_allowed", wantAuditStatus: audit.StatusValidationFailed, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { converter := &fakeConverter{ result: tt.result, err: tt.convertErr, } audits := &fakeAuditSaver{} handler := NewHandler( converter, audits, slog.New(slog.NewTextHandler(io.Discard, nil)), ) handler.newRequestID = func() string { return "request-test" } request := httptest.NewRequest( tt.method, "/api/v1/card-to-iban", strings.NewReader(tt.body), ) response := httptest.NewRecorder() handler.Convert(response, request) if response.Code != tt.wantHTTPStatus { t.Fatalf("status = %d, want %d; body = %s", response.Code, tt.wantHTTPStatus, response.Body.String()) } if got := response.Header().Get("X-Request-ID"); got != "request-test" { t.Errorf("X-Request-ID = %q, want request-test", got) } var payload struct { Data struct { IBAN string `json:"iban"` } `json:"data"` Error struct { Code string `json:"code"` } `json:"error"` RequestID string `json:"requestId"` } if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.RequestID != "request-test" { t.Errorf("requestId = %q, want request-test", payload.RequestID) } if payload.Error.Code != tt.wantErrorCode { t.Errorf("error code = %q, want %q", payload.Error.Code, tt.wantErrorCode) } if tt.wantHTTPStatus == http.StatusOK && payload.Data.IBAN != tt.result.IBAN { t.Errorf("iban = %q, want %q", payload.Data.IBAN, tt.result.IBAN) } if converter.called != tt.wantCalled { t.Errorf("converter called = %v, want %v", converter.called, tt.wantCalled) } if len(audits.records) != 1 { t.Fatalf("audit records = %d, want 1", len(audits.records)) } record := audits.records[0] if record.Status != tt.wantAuditStatus { t.Errorf("audit status = %q, want %q", record.Status, tt.wantAuditStatus) } if record.MaskedCard != tt.wantMaskedCard { t.Errorf("masked card = %q, want %q", record.MaskedCard, tt.wantMaskedCard) } if record.ClientIP == nil || *record.ClientIP != "192.0.2.1" { t.Errorf("client IP = %v, want 192.0.2.1", record.ClientIP) } if strings.Contains(string(record.ResponseJSON), "6037997512345678") { t.Error("audit response contains the full card number") } }) } } func TestHandlerFailsClosedWhenAuditSaveFails(t *testing.T) { converter := &fakeConverter{ result: service.ConversionResult{ IBAN: "IR820540102680020817909002", UpstreamHTTPStatus: http.StatusOK, }, } audits := &fakeAuditSaver{err: errors.New("database unavailable")} handler := NewHandler( converter, audits, slog.New(slog.NewTextHandler(io.Discard, nil)), ) handler.newRequestID = func() string { return "request-test" } request := httptest.NewRequest( http.MethodPost, "/api/v1/card-to-iban", strings.NewReader(`{"card":"6037997512345678"}`), ) response := httptest.NewRecorder() handler.Convert(response, request) if response.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", response.Code) } if strings.Contains(response.Body.String(), "IR820540102680020817909002") { t.Error("response exposes iban after audit save failure") } if !strings.Contains(response.Body.String(), `"code":"internal_error"`) { t.Errorf("response body = %s, want internal_error", response.Body.String()) } } func TestHandlerExtractsClientIP(t *testing.T) { tests := []struct { name string headerKey string headerVal string remoteAddr string wantIP string }{ { name: "prefers CF-Connecting-IP", headerKey: "CF-Connecting-IP", headerVal: "203.0.113.88", remoteAddr: "10.0.0.1:1234", wantIP: "203.0.113.88", }, { name: "prefers X-Forwarded-For over RemoteAddr", headerKey: "X-Forwarded-For", headerVal: "198.51.100.77, 10.0.0.2", remoteAddr: "10.0.0.1:1234", wantIP: "198.51.100.77", }, { name: "uses RemoteAddr host without port", remoteAddr: "192.168.10.5:9999", wantIP: "192.168.10.5", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { audits := &fakeAuditSaver{} handler := NewHandler( &fakeConverter{ result: service.ConversionResult{ IBAN: "IR820540102680020817909002", UpstreamHTTPStatus: http.StatusOK, }, }, audits, slog.New(slog.NewTextHandler(io.Discard, nil)), ) req := httptest.NewRequest( http.MethodPost, "/api/v1/card-to-iban", strings.NewReader(`{"card":"6037997512345678"}`), ) if tt.headerKey != "" { req.Header.Set(tt.headerKey, tt.headerVal) } req.RemoteAddr = tt.remoteAddr rec := httptest.NewRecorder() handler.Convert(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if len(audits.records) != 1 { t.Fatalf("audit records count = %d, want 1", len(audits.records)) } recIP := audits.records[0].ClientIP if recIP == nil || *recIP != tt.wantIP { t.Errorf("ClientIP = %v, want %q", recIP, tt.wantIP) } }) } }