feat(api): add card-to-iban HTTP endpoint
This commit is contained in:
25
backend/internal/httpapi/cors.go
Normal file
25
backend/internal/httpapi/cors.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
77
backend/internal/httpapi/cors_test.go
Normal file
77
backend/internal/httpapi/cors_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
308
backend/internal/httpapi/handler.go
Normal file
308
backend/internal/httpapi/handler.go
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
293
backend/internal/httpapi/handler_test.go
Normal file
293
backend/internal/httpapi/handler_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user