diff --git a/backend/internal/httpapi/cors.go b/backend/internal/httpapi/cors.go new file mode 100644 index 0000000..4730209 --- /dev/null +++ b/backend/internal/httpapi/cors.go @@ -0,0 +1,25 @@ +package httpapi + +import "net/http" + +func AllowOrigin( + origin string, + next http.Handler, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Vary", "Origin") + + if r.Header.Get("Origin") == origin { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/httpapi/cors_test.go b/backend/internal/httpapi/cors_test.go new file mode 100644 index 0000000..6d20488 --- /dev/null +++ b/backend/internal/httpapi/cors_test.go @@ -0,0 +1,77 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAllowOrigin(t *testing.T) { + const allowedOrigin = "http://127.0.0.1:3000" + + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusNoContent) + }) + handler := AllowOrigin(allowedOrigin, next) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/card-to-iban", nil) + request.Header.Set("Origin", allowedOrigin) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if !nextCalled { + t.Fatal("next handler was not called") + } + if got := response.Header().Get("Access-Control-Allow-Origin"); got != allowedOrigin { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, allowedOrigin) + } +} + +func TestAllowOriginDoesNotReflectUnknownOrigin(t *testing.T) { + handler := AllowOrigin( + "http://127.0.0.1:3000", + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/card-to-iban", nil) + request.Header.Set("Origin", "https://example.com") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if got := response.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty", got) + } +} + +func TestAllowOriginHandlesPreflight(t *testing.T) { + nextCalled := false + handler := AllowOrigin( + "http://127.0.0.1:3000", + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + }), + ) + + request := httptest.NewRequest(http.MethodOptions, "/api/v1/card-to-iban", nil) + request.Header.Set("Origin", "http://127.0.0.1:3000") + request.Header.Set("Access-Control-Request-Method", http.MethodPost) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", response.Code) + } + if nextCalled { + t.Error("preflight request reached the next handler") + } + if got := response.Header().Get("Access-Control-Allow-Methods"); got != "POST, OPTIONS" { + t.Errorf("Access-Control-Allow-Methods = %q", got) + } +} diff --git a/backend/internal/httpapi/handler.go b/backend/internal/httpapi/handler.go new file mode 100644 index 0000000..74936d7 --- /dev/null +++ b/backend/internal/httpapi/handler.go @@ -0,0 +1,308 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "card-to-iban/internal/audit" + "card-to-iban/internal/card" + "card-to-iban/internal/service" + "card-to-iban/internal/zarinhub" +) + +const maxRequestBodySize = 1024 + +var ( + errInvalidRequest = errors.New("invalid request body") + errMethodNotAllowed = errors.New("method not allowed") +) + +type Converter interface { + ConvertCardToIBAN( + ctx context.Context, + cardNumber string, + ) (service.ConversionResult, error) +} + +type AuditSaver interface { + Save(ctx context.Context, record audit.Record) error +} + +type Handler struct { + converter Converter + audits AuditSaver + logger *slog.Logger + now func() time.Time + newRequestID func() string +} + +func NewHandler( + converter Converter, + audits AuditSaver, + logger *slog.Logger, +) *Handler { + return &Handler{ + converter: converter, + audits: audits, + logger: logger, + now: time.Now, + newRequestID: rand.Text, + } +} + +func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) { + startedAt := h.now() + requestID := h.newRequestID() + + w.Header().Set("X-Request-ID", requestID) + w.Header().Set("X-Content-Type-Options", "nosniff") + + var ( + cardNumber string + result service.ConversionResult + convertErr error + ) + + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + convertErr = errMethodNotAllowed + } else { + cardNumber, convertErr = decodeCard(w, r) + if convertErr == nil { + result, convertErr = h.converter.ConvertCardToIBAN( + r.Context(), + cardNumber, + ) + } + } + + outcome := outcomeFor(convertErr) + duration := h.now().Sub(startedAt) + record := audit.Record{ + RequestID: requestID, + CreatedAt: startedAt, + Duration: duration, + Status: outcome.auditStatus, + MaskedCard: card.Mask(cardNumber), + ResponseJSON: audit.SanitizeResponse( + result.RawResponse, + card.Mask(cardNumber), + ), + } + + if result.UpstreamHTTPStatus != 0 { + upstreamStatus := result.UpstreamHTTPStatus + record.UpstreamHTTPStatus = &upstreamStatus + } + + if err := h.audits.Save(r.Context(), record); err != nil { + h.logger.ErrorContext( + r.Context(), + "request audit failed", + "request_id", requestID, + "route", "/api/v1/card-to-iban", + "http_status", http.StatusInternalServerError, + "audit_status", audit.StatusInternalError, + "duration_ms", duration.Milliseconds(), + "error", err, + ) + + h.writeError( + w, + requestID, + http.StatusInternalServerError, + "internal_error", + "خطایی در پردازش درخواست رخ داد.", + ) + return + } + + h.logger.InfoContext( + r.Context(), + "request completed", + "request_id", requestID, + "route", "/api/v1/card-to-iban", + "http_status", outcome.httpStatus, + "audit_status", outcome.auditStatus, + "duration_ms", duration.Milliseconds(), + ) + + if convertErr != nil { + h.writeError( + w, + requestID, + outcome.httpStatus, + outcome.code, + outcome.message, + ) + return + } + + h.writeJSON(w, http.StatusOK, successResponse{ + Data: successData{ + IBAN: result.IBAN, + }, + RequestID: requestID, + }) +} + +func decodeCard( + w http.ResponseWriter, + r *http.Request, +) (string, error) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + var request convertRequest + if err := decoder.Decode(&request); err != nil { + return "", fmt.Errorf("%w: %v", errInvalidRequest, err) + } + + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return "", errInvalidRequest + } + + return request.Card, nil +} + +type outcome struct { + httpStatus int + code string + message string + auditStatus audit.Status +} + +func outcomeFor(err error) outcome { + switch { + case err == nil: + return outcome{ + httpStatus: http.StatusOK, + auditStatus: audit.StatusSuccess, + } + case errors.Is(err, errMethodNotAllowed): + return outcome{ + httpStatus: http.StatusMethodNotAllowed, + code: "method_not_allowed", + message: "این روش برای این مسیر پشتیبانی نمی‌شود.", + auditStatus: audit.StatusValidationFailed, + } + case errors.Is(err, errInvalidRequest), + errors.Is(err, card.ErrCardRequired), + errors.Is(err, card.ErrCardLength), + errors.Is(err, card.ErrCardNumeric): + return outcome{ + httpStatus: http.StatusBadRequest, + code: "invalid_card", + message: "شماره کارت باید شامل ۱۶ رقم باشد.", + auditStatus: audit.StatusValidationFailed, + } + case errors.Is(err, zarinhub.ErrRejected): + return outcome{ + httpStatus: http.StatusUnprocessableEntity, + code: "conversion_rejected", + message: "تبدیل این شماره کارت انجام نشد.", + auditStatus: audit.StatusUpstreamRejected, + } + case errors.Is(err, zarinhub.ErrAuthentication): + return outcome{ + httpStatus: http.StatusBadGateway, + code: "upstream_failure", + message: "ارتباط با سرویس بانکی با خطا روبه‌رو شد.", + auditStatus: audit.StatusAuthenticationError, + } + case errors.Is(err, zarinhub.ErrTimeout): + return outcome{ + httpStatus: http.StatusGatewayTimeout, + code: "upstream_timeout", + message: "پاسخ سرویس بانکی بیش از حد طول کشید.", + auditStatus: audit.StatusUpstreamTimeout, + } + case errors.Is(err, zarinhub.ErrUnavailable): + return outcome{ + httpStatus: http.StatusServiceUnavailable, + code: "upstream_unavailable", + message: "سرویس بانکی موقتاً در دسترس نیست.", + auditStatus: audit.StatusUpstreamUnavailable, + } + case errors.Is(err, zarinhub.ErrInvalidResponse): + return outcome{ + httpStatus: http.StatusBadGateway, + code: "upstream_failure", + message: "پاسخ سرویس بانکی قابل پردازش نبود.", + auditStatus: audit.StatusUpstreamInvalid, + } + default: + return outcome{ + httpStatus: http.StatusInternalServerError, + code: "internal_error", + message: "خطایی در پردازش درخواست رخ داد.", + auditStatus: audit.StatusInternalError, + } + } +} + +type convertRequest struct { + Card string `json:"card"` +} + +type successResponse struct { + Data successData `json:"data"` + RequestID string `json:"requestId"` +} + +type successData struct { + IBAN string `json:"iban"` +} + +type errorResponse struct { + Error responseError `json:"error"` + RequestID string `json:"requestId"` +} + +type responseError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (h *Handler) writeError( + w http.ResponseWriter, + requestID string, + status int, + code string, + message string, +) { + h.writeJSON(w, status, errorResponse{ + Error: responseError{ + Code: code, + Message: message, + }, + RequestID: requestID, + }) +} + +func (h *Handler) writeJSON( + w http.ResponseWriter, + status int, + payload any, +) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + if err := json.NewEncoder(w).Encode(payload); err != nil { + h.logger.Error( + "response encoding failed", + "http_status", status, + "error", err, + ) + } +} diff --git a/backend/internal/httpapi/handler_test.go b/backend/internal/httpapi/handler_test.go new file mode 100644 index 0000000..0dbc52d --- /dev/null +++ b/backend/internal/httpapi/handler_test.go @@ -0,0 +1,293 @@ +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 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()) + } +}