fix: harden terminal-scoped analytics contracts
Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
This commit is contained in:
108
internal/analytics/cursor.go
Normal file
108
internal/analytics/cursor.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidCursor = errors.New("analytics: invalid cursor")
|
||||
|
||||
type detailCursorPosition struct {
|
||||
CreatedAt time.Time
|
||||
SessionID int64
|
||||
}
|
||||
|
||||
type detailCursorPayload struct {
|
||||
Version int `json:"v"`
|
||||
Binding string `json:"b"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
SessionID int64 `json:"sessionId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type detailCursorBinding struct {
|
||||
MerchantID int64 `json:"merchantId"`
|
||||
PeriodID string `json:"periodId"`
|
||||
View string `json:"view"`
|
||||
FailureStage string `json:"failureStage"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
Status string `json:"status"`
|
||||
Minimum *int64 `json:"minimum"`
|
||||
Maximum *int64 `json:"maximum"`
|
||||
}
|
||||
|
||||
func (s *Service) encodeDetailCursor(merchantID int64, period Period, filter DetailFilter, createdAt time.Time, sessionID int64) (string, error) {
|
||||
payload := detailCursorPayload{
|
||||
Version: 1,
|
||||
Binding: detailCursorBindingDigest(merchantID, period, filter),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
SessionID: sessionID,
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aead, err := s.cursorAEAD()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := aead.Seal(nonce, nonce, encoded, nil)
|
||||
return base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func (s *Service) decodeDetailCursor(merchantID int64, period Period, filter DetailFilter, cursor string) (detailCursorPosition, error) {
|
||||
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
aead, err := s.cursorAEAD()
|
||||
if err != nil || len(sealed) < aead.NonceSize() {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
nonce, ciphertext := sealed[:aead.NonceSize()], sealed[aead.NonceSize():]
|
||||
payloadBytes, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
var payload detailCursorPayload
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil || payload.Version != 1 || payload.SessionID <= 0 || payload.CreatedAt.IsZero() || time.Now().UTC().After(payload.ExpiresAt) {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
wantBinding := detailCursorBindingDigest(merchantID, period, filter)
|
||||
if payload.Binding != wantBinding {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
return detailCursorPosition{CreatedAt: payload.CreatedAt.UTC(), SessionID: payload.SessionID}, nil
|
||||
}
|
||||
|
||||
func (s *Service) cursorAEAD() (cipher.AEAD, error) {
|
||||
key := sha256.Sum256(s.cursorSecret)
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func detailCursorBindingDigest(merchantID int64, period Period, filter DetailFilter) string {
|
||||
binding := detailCursorBinding{
|
||||
MerchantID: merchantID, PeriodID: period.PeriodID, View: filter.View,
|
||||
FailureStage: filter.FailureStage, TerminalID: filter.TerminalID, Status: filter.Status,
|
||||
Minimum: filter.MinAmountRial, Maximum: filter.MaxAmountRial,
|
||||
}
|
||||
encoded, _ := json.Marshal(binding)
|
||||
digest := sha256.Sum256(encoded)
|
||||
return base64.RawURLEncoding.EncodeToString(digest[:])
|
||||
}
|
||||
@@ -2,16 +2,33 @@ package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const MaxExportRows = 100_000
|
||||
|
||||
var ErrExportTooLarge = errors.New("analytics: export exceeds row limit")
|
||||
|
||||
const detailFilterPredicate = `
|
||||
ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3
|
||||
AND ($4='ALL'
|
||||
OR ($4='UNVERIFIED_PAID' AND ps.status='Paid')
|
||||
OR ($4='FAILED' AND ps.status='Failed')
|
||||
OR ($4='RETRIED' AND (SELECT count(*) FROM payment_attempts retry_attempts WHERE retry_attempts.session_id=ps.id)>1))
|
||||
AND ($5='' OR t.source_key=$5)
|
||||
AND ($6='' OR
|
||||
($6='NO_ATTEMPT' AND NOT EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id)) OR
|
||||
($6='REACHED_BANK' AND EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id AND failure_attempts.status='InBank')) OR
|
||||
($6='OTHER_OBSERVED' AND EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id) AND NOT EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id AND failure_attempts.status='InBank')))
|
||||
AND ($7='' OR ps.status=$7)
|
||||
AND ($8::bigint IS NULL OR ps.amount_rial >= $8)
|
||||
AND ($9::bigint IS NULL OR ps.amount_rial <= $9)`
|
||||
|
||||
type DetailFilter struct {
|
||||
View string
|
||||
FailureStage string
|
||||
@@ -70,6 +87,12 @@ type Detail struct {
|
||||
Attempts []Attempt `json:"attempts"`
|
||||
}
|
||||
|
||||
type ExportDetail struct {
|
||||
Detail
|
||||
LastAttemptStatus *string
|
||||
LastAttemptAt *time.Time
|
||||
}
|
||||
|
||||
func (s *Service) Details(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]Detail, string, error) {
|
||||
if filter.Limit <= 0 {
|
||||
filter.Limit = 25
|
||||
@@ -77,39 +100,22 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
|
||||
if err := filter.Validate(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
offset := 0
|
||||
var cursorCreatedAt *time.Time
|
||||
var cursorSessionID int64
|
||||
if filter.Cursor != "" {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(filter.Cursor)
|
||||
position, err := s.decodeDetailCursor(merchantID, period, filter, filter.Cursor)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("analytics: invalid cursor")
|
||||
return nil, "", err
|
||||
}
|
||||
offset, err = strconv.Atoi(string(decoded))
|
||||
if err != nil || offset < 0 {
|
||||
return nil, "", errors.New("analytics: invalid cursor")
|
||||
}
|
||||
}
|
||||
statusPredicate := "TRUE"
|
||||
switch filter.View {
|
||||
case "ALL":
|
||||
statusPredicate = "TRUE"
|
||||
case "UNVERIFIED_PAID":
|
||||
statusPredicate = "ps.status='Paid'"
|
||||
case "FAILED":
|
||||
statusPredicate = "ps.status='Failed'"
|
||||
case "RETRIED":
|
||||
statusPredicate = "(SELECT count(*) FROM payment_attempts x WHERE x.session_id=ps.id)>1"
|
||||
cursorCreatedAt = &position.CreatedAt
|
||||
cursorSessionID = position.SessionID
|
||||
}
|
||||
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
|
||||
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
|
||||
WHERE ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3 AND ` + statusPredicate + `
|
||||
AND ($4='' OR t.source_key=$4)
|
||||
AND ($5='' OR
|
||||
($5='NO_ATTEMPT' AND NOT EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id)) OR
|
||||
($5='REACHED_BANK' AND EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id AND fa.status='InBank')) OR
|
||||
($5='OTHER_OBSERVED' AND EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id) AND NOT EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id AND fa.status='InBank')))
|
||||
AND ($6='' OR ps.status=$6) AND ($7::bigint IS NULL OR ps.amount_rial >= $7) AND ($8::bigint IS NULL OR ps.amount_rial <= $8)
|
||||
GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $9 OFFSET $10`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, filter.Limit+1, offset)
|
||||
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
|
||||
WHERE ` + detailFilterPredicate + `
|
||||
AND ($10::timestamptz IS NULL OR ps.created_at < $10 OR (ps.created_at=$10 AND ps.id<$11))
|
||||
GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $12`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.View, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, cursorCreatedAt, cursorSessionID, filter.Limit+1)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("querying session details: %w", err)
|
||||
}
|
||||
@@ -146,11 +152,53 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
|
||||
}
|
||||
next := ""
|
||||
if hasMore {
|
||||
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
|
||||
next, err = s.encodeDetailCursor(merchantID, period, filter, details[len(details)-1].CreatedAt, ids[len(ids)-1])
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("encoding detail cursor: %w", err)
|
||||
}
|
||||
}
|
||||
return details, next, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExportDetails(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]ExportDetail, error) {
|
||||
filter.Cursor = ""
|
||||
filter.Limit = 100
|
||||
if err := filter.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
|
||||
(SELECT count(*)::int FROM payment_attempts attempts WHERE attempts.session_id=ps.id),
|
||||
EXISTS(SELECT 1 FROM payment_attempts attempts WHERE attempts.session_id=ps.id AND attempts.status='InBank'),
|
||||
last_attempt.status,last_attempt.created_at
|
||||
FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id
|
||||
LEFT JOIN LATERAL (SELECT status,created_at FROM payment_attempts WHERE session_id=ps.id ORDER BY sequence DESC LIMIT 1) last_attempt ON true
|
||||
WHERE ` + detailFilterPredicate + ` ORDER BY ps.created_at DESC,ps.id DESC LIMIT $10`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.View, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, MaxExportRows+1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying session export: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]ExportDetail, 0, 1024)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var reachedBank bool
|
||||
var item ExportDetail
|
||||
if err := rows.Scan(&id, &item.SessionID, &item.TerminalID, &item.SessionStatus, &item.AmountRial, &item.FeeAmountRial, &item.CreatedAt, &item.ExpiresAt, &item.VerifiedAt, &item.SettledAt, &item.AttemptCount, &reachedBank, &item.LastAttemptStatus, &item.LastAttemptAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning session export: %w", err)
|
||||
}
|
||||
item.SessionID = "ses_" + item.SessionID
|
||||
setFailureStage(&item.Detail, reachedBank)
|
||||
items = append(items, item)
|
||||
if len(items) > MaxExportRows {
|
||||
return nil, ErrExportTooLarge
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating session export: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Session(ctx context.Context, merchantID int64, sessionID string) (Detail, bool, error) {
|
||||
sourceKey, valid := sessionSourceKey(sessionID)
|
||||
if !valid {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package analytics
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDetailFilterValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -59,3 +64,53 @@ func TestTerminalDisplayName(t *testing.T) {
|
||||
t.Fatalf("terminalDisplayName() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailCursorRejectsMerchantAndFilterReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := &Service{cursorSecret: []byte("test-cursor-secret-that-is-at-least-32-bytes")}
|
||||
period := Period{PeriodID: "jm-1405-03"}
|
||||
minimum := int64(1000)
|
||||
filter := DetailFilter{View: "ALL", TerminalID: "T318", Status: "Verified", MinAmountRial: &minimum, Limit: 25}
|
||||
createdAt := time.Date(2026, time.June, 20, 8, 30, 0, 0, time.UTC)
|
||||
|
||||
cursor, err := service.encodeDetailCursor(42, period, filter, createdAt, 99)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position, err := service.decodeDetailCursor(42, period, filter, cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeDetailCursor() error = %v", err)
|
||||
}
|
||||
if !position.CreatedAt.Equal(createdAt) || position.SessionID != 99 {
|
||||
t.Fatalf("decodeDetailCursor() = %+v", position)
|
||||
}
|
||||
|
||||
if _, err := service.decodeDetailCursor(43, period, filter, cursor); !errors.Is(err, ErrInvalidCursor) {
|
||||
t.Fatalf("merchant replay error = %v, want ErrInvalidCursor", err)
|
||||
}
|
||||
changed := filter
|
||||
changed.Status = "Paid"
|
||||
if _, err := service.decodeDetailCursor(42, period, changed, cursor); !errors.Is(err, ErrInvalidCursor) {
|
||||
t.Fatalf("filter replay error = %v, want ErrInvalidCursor", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailCursorRejectsTampering(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := &Service{cursorSecret: []byte("test-cursor-secret-that-is-at-least-32-bytes")}
|
||||
period := Period{PeriodID: "jm-1405-03"}
|
||||
filter := DetailFilter{View: "ALL", Limit: 25}
|
||||
cursor, err := service.encodeDetailCursor(42, period, filter, time.Now().UTC(), 99)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed[len(sealed)-1] ^= 1
|
||||
tampered := base64.RawURLEncoding.EncodeToString(sealed)
|
||||
if _, err := service.decodeDetailCursor(42, period, filter, tampered); !errors.Is(err, ErrInvalidCursor) {
|
||||
t.Fatalf("tampered cursor error = %v, want ErrInvalidCursor", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,10 @@ type PaymentHistoryPoint struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
mu sync.RWMutex
|
||||
runs map[string]storedRun
|
||||
pool *pgxpool.Pool
|
||||
cursorSecret []byte
|
||||
mu sync.RWMutex
|
||||
runs map[string]storedRun
|
||||
}
|
||||
|
||||
type storedRun struct {
|
||||
@@ -59,7 +60,9 @@ type storedRun struct {
|
||||
run Run
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, runs: map[string]storedRun{}} }
|
||||
func New(pool *pgxpool.Pool, cursorSecret string) *Service {
|
||||
return &Service{pool: pool, cursorSecret: []byte(cursorSecret), runs: map[string]storedRun{}}
|
||||
}
|
||||
|
||||
func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
||||
var start, end time.Time
|
||||
@@ -105,21 +108,27 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
|
||||
run.Sections["overview"] = map[string]any{"sectionId": "overview", "state": "READY", "metadata": metadata, "data": metrics.overview(period, history)}
|
||||
run.Sections["paymentPerformance"] = map[string]any{"sectionId": "payment_performance", "state": "READY", "metadata": metadata, "data": metrics.performance()}
|
||||
run.Sections["fees"] = map[string]any{"sectionId": "fees", "state": "READY", "metadata": metadata, "data": metrics.fees(period)}
|
||||
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if peerCount < 10 || peerSessions < 1000 {
|
||||
if !peerComparisonSupported(terminalID) {
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
|
||||
"reason": "INSUFFICIENT_COHORT", "eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions,
|
||||
"reason": "TERMINAL_SCOPE_UNSUPPORTED", "eligiblePeerCount": 0, "combinedPeerSessionCount": 0,
|
||||
"requiredPeerCount": 10, "requiredPeerSessionCount": 1000}
|
||||
} else {
|
||||
peerMetrics, err := s.peerMetrics(ctx, merchantID, period)
|
||||
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "READY", "metadata": metadata,
|
||||
"data": map[string]any{"eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions, "metrics": peerMetrics}}
|
||||
if peerCount < 10 || peerSessions < 1000 {
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
|
||||
"reason": "INSUFFICIENT_COHORT", "eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions,
|
||||
"requiredPeerCount": 10, "requiredPeerSessionCount": 1000}
|
||||
} else {
|
||||
peerMetrics, err := s.peerMetrics(ctx, merchantID, period)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "READY", "metadata": metadata,
|
||||
"data": map[string]any{"eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions, "metrics": peerMetrics}}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.runs[key] = storedRun{merchantID: merchantID, run: run}
|
||||
@@ -128,6 +137,8 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func peerComparisonSupported(terminalID string) bool { return terminalID == "" }
|
||||
|
||||
func (s *Service) Run(merchantID int64, runID string) (Run, bool) {
|
||||
s.mu.RLock()
|
||||
stored, ok := s.runs[runID]
|
||||
|
||||
13
internal/analytics/service_test.go
Normal file
13
internal/analytics/service_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package analytics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPeerComparisonSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
if peerComparisonSupported("T318") {
|
||||
t.Fatal("peerComparisonSupported() = true for terminal scope")
|
||||
}
|
||||
if !peerComparisonSupported("") {
|
||||
t.Fatal("peerComparisonSupported() = false for merchant scope")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
@@ -8,16 +9,22 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
||||
)
|
||||
|
||||
const maxExportBytes = 50 << 20
|
||||
|
||||
var errExportBytesExceeded = errors.New("export exceeds byte limit")
|
||||
|
||||
type Server struct {
|
||||
identity *identity.Service
|
||||
analytics *analytics.Service
|
||||
@@ -150,6 +157,11 @@ func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
periods, err = filterPeriodsByType(periods, r.URL.Query().Get("periodType"))
|
||||
if err != nil {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid reporting period type")
|
||||
return
|
||||
}
|
||||
if len(periods) == 0 {
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
@@ -257,23 +269,16 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
minAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
|
||||
if !valid {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid minimum amount")
|
||||
filter, err := detailFilterFromRequest(r, limit)
|
||||
if err != nil {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
|
||||
return
|
||||
}
|
||||
maxAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
|
||||
if !valid {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid maximum amount")
|
||||
return
|
||||
}
|
||||
filter := analytics.DetailFilter{
|
||||
View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"),
|
||||
TerminalID: r.URL.Query().Get("terminalId"), Status: r.URL.Query().Get("status"),
|
||||
MinAmountRial: minAmount, MaxAmountRial: maxAmount,
|
||||
Cursor: r.URL.Query().Get("cursor"), Limit: limit,
|
||||
}
|
||||
items, next, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
||||
if errors.Is(err, analytics.ErrInvalidCursor) {
|
||||
s.problem(w, r, 400, "INVALID_CURSOR", "Invalid detail cursor")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("detail query failed", "error", err)
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
|
||||
@@ -309,30 +314,99 @@ func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Limit: 100}
|
||||
items, _, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
||||
filter, err := detailFilterFromRequest(r, 100)
|
||||
if err != nil {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid export request")
|
||||
return
|
||||
}
|
||||
exportContext, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
items, err := s.analytics.ExportDetails(exportContext, sessionFrom(r.Context()).MerchantID, period, filter)
|
||||
if errors.Is(err, analytics.ErrExportTooLarge) {
|
||||
s.problem(w, r, 413, "EXPORT_TOO_LARGE", "Export exceeds the allowed row limit")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
s.problem(w, r, 504, "EXPORT_TIMEOUT", "Export timed out")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("session export failed", "error", err)
|
||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||
return
|
||||
}
|
||||
encoded, err := renderExportCSV(items, maxExportBytes)
|
||||
if errors.Is(err, errExportBytesExceeded) {
|
||||
s.problem(w, r, 413, "EXPORT_TOO_LARGE", "Export exceeds the allowed byte limit")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("session export encoding failed", "error", err)
|
||||
s.problem(w, r, 500, "INTERNAL_ERROR", "Export could not be encoded")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", strings.ToLower(filter.View)+"-"+period.PeriodID+".csv"))
|
||||
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
writer := csv.NewWriter(w)
|
||||
filename := fmt.Sprintf("%s-%s-%s.csv", strings.ToLower(filter.View), period.PeriodID, time.Now().UTC().Format("20060102T150405Z"))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", filename, filename))
|
||||
if _, err := w.Write(encoded); err != nil {
|
||||
s.logger.Warn("session export response interrupted", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderExportCSV(items []analytics.ExportDetail, maxBytes int64) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
limited := &limitedWriter{writer: &buffer, remaining: maxBytes}
|
||||
if _, err := limited.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writer := csv.NewWriter(limited)
|
||||
writer.UseCRLF = true
|
||||
_ = writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"})
|
||||
if err := writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
lastStatus, lastAt := "", ""
|
||||
if len(item.Attempts) > 0 {
|
||||
last := item.Attempts[len(item.Attempts)-1]
|
||||
lastStatus = persianStatus(last.Status)
|
||||
if last.CreatedAt != nil {
|
||||
lastAt = last.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if item.LastAttemptStatus != nil {
|
||||
lastStatus = persianStatus(*item.LastAttemptStatus)
|
||||
}
|
||||
if item.LastAttemptAt != nil {
|
||||
lastAt = item.LastAttemptAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if err := writer.Write([]string{spreadsheetSafeText(item.SessionID), spreadsheetSafeText(item.TerminalID), spreadsheetSafeText(persianStatus(item.SessionStatus)), strconv.FormatInt(item.AmountRial, 10), strconv.FormatInt(item.FeeAmountRial, 10), item.CreatedAt.UTC().Format(time.RFC3339), formatTime(item.ExpiresAt), strconv.Itoa(item.AttemptCount), spreadsheetSafeText(lastStatus), lastAt, formatTime(item.VerifiedAt), formatTime(item.SettledAt), spreadsheetSafeText(stringValue(item.FailureStage))}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = writer.Write([]string{item.SessionID, item.TerminalID, persianStatus(item.SessionStatus), strconv.FormatInt(item.AmountRial, 10), strconv.FormatInt(item.FeeAmountRial, 10), item.CreatedAt.UTC().Format(time.RFC3339), formatTime(item.ExpiresAt), strconv.Itoa(item.AttemptCount), lastStatus, lastAt, formatTime(item.VerifiedAt), formatTime(item.SettledAt), stringValue(item.FailureStage)})
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
type limitedWriter struct {
|
||||
writer io.Writer
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (w *limitedWriter) Write(data []byte) (int, error) {
|
||||
if int64(len(data)) > w.remaining {
|
||||
return 0, errExportBytesExceeded
|
||||
}
|
||||
written, err := w.writer.Write(data)
|
||||
w.remaining -= int64(written)
|
||||
return written, err
|
||||
}
|
||||
|
||||
func spreadsheetSafeText(value string) string {
|
||||
trimmed := strings.TrimLeftFunc(value, unicode.IsSpace)
|
||||
if value == "" || trimmed == "" {
|
||||
return value
|
||||
}
|
||||
first := trimmed[0]
|
||||
if first == '=' || first == '+' || first == '-' || first == '@' || value[0] == '\t' || value[0] == '\r' || value[0] == '\n' {
|
||||
return "'" + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Server) requestPeriod(r *http.Request) (analytics.Period, bool) {
|
||||
@@ -364,6 +438,45 @@ func optionalNonNegativeInt64(value string) (*int64, bool) {
|
||||
}
|
||||
return &parsed, true
|
||||
}
|
||||
|
||||
func detailFilterFromRequest(r *http.Request, limit int) (analytics.DetailFilter, error) {
|
||||
minimum, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
|
||||
if !valid {
|
||||
return analytics.DetailFilter{}, errors.New("invalid minimum amount")
|
||||
}
|
||||
maximum, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
|
||||
if !valid {
|
||||
return analytics.DetailFilter{}, errors.New("invalid maximum amount")
|
||||
}
|
||||
filter := analytics.DetailFilter{
|
||||
View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"),
|
||||
TerminalID: r.URL.Query().Get("terminalId"), Status: r.URL.Query().Get("status"),
|
||||
MinAmountRial: minimum, MaxAmountRial: maximum, Cursor: r.URL.Query().Get("cursor"), Limit: limit,
|
||||
}
|
||||
if err := filter.Validate(); err != nil {
|
||||
return analytics.DetailFilter{}, err
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func filterPeriodsByType(periods []analytics.Period, requested string) ([]analytics.Period, error) {
|
||||
if requested == "" {
|
||||
return periods, nil
|
||||
}
|
||||
allowed := map[string]bool{"DAY": true, "WEEK": true, "JALALI_MONTH": true, "PERSIAN_SEASON": true}
|
||||
if !allowed[requested] {
|
||||
return nil, errors.New("invalid reporting period type")
|
||||
}
|
||||
filtered := make([]analytics.Period, 0, 1)
|
||||
for _, period := range periods {
|
||||
if period.PeriodType == requested {
|
||||
period.IsDefault = len(filtered) == 0
|
||||
filtered = append(filtered, period)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func formatTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
||||
)
|
||||
|
||||
func TestServerCookieName(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -22,6 +30,25 @@ func TestServerCookieName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderExportCSVIsBoundedAndFormulaSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := analytics.ExportDetail{Detail: analytics.Detail{
|
||||
SessionID: "=session", TerminalID: "+terminal", SessionStatus: "Verified",
|
||||
AmountRial: 1000, FeeAmountRial: 10, CreatedAt: time.Date(2026, time.June, 20, 8, 0, 0, 0, time.UTC),
|
||||
}}
|
||||
encoded, err := renderExportCSV([]analytics.ExportDetail{item}, 4096)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
if !strings.Contains(text, "'=session") || !strings.Contains(text, "'+terminal") {
|
||||
t.Fatalf("renderExportCSV() did not neutralize formulas: %q", text)
|
||||
}
|
||||
if _, err := renderExportCSV([]analytics.ExportDetail{item}, 10); !errors.Is(err, errExportBytesExceeded) {
|
||||
t.Fatalf("renderExportCSV() error = %v, want errExportBytesExceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersianStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := persianStatus("Verified"); got != "تأیید شده" {
|
||||
@@ -60,3 +87,39 @@ func equalInt64Pointers(left, right *int64) bool {
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func TestDetailFilterFromRequestParsesCanonicalFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest("GET", "/?view=ALL&failureStage=&terminalId=T318&status=Verified&minAmountRial=1000&maxAmountRial=9000&cursor=opaque", nil)
|
||||
|
||||
filter, err := detailFilterFromRequest(request, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.View != "ALL" || filter.TerminalID != "T318" || filter.Status != "Verified" || filter.Cursor != "opaque" || filter.Limit != 25 {
|
||||
t.Fatalf("detailFilterFromRequest() = %+v", filter)
|
||||
}
|
||||
if filter.MinAmountRial == nil || *filter.MinAmountRial != 1000 || filter.MaxAmountRial == nil || *filter.MaxAmountRial != 9000 {
|
||||
t.Fatalf("detailFilterFromRequest() amount bounds = %v/%v", filter.MinAmountRial, filter.MaxAmountRial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailFilterFromRequestRejectsDescendingAmountRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest("GET", "/?view=ALL&minAmountRial=9000&maxAmountRial=1000", nil)
|
||||
if _, err := detailFilterFromRequest(request, 25); err == nil {
|
||||
t.Fatal("detailFilterFromRequest() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeriodsByType(t *testing.T) {
|
||||
t.Parallel()
|
||||
periods := []analytics.Period{{PeriodType: "DAY"}, {PeriodType: "WEEK"}}
|
||||
filtered, err := filterPeriodsByType(periods, "DAY")
|
||||
if err != nil || len(filtered) != 1 || filtered[0].PeriodType != "DAY" || !filtered[0].IsDefault {
|
||||
t.Fatalf("filterPeriodsByType() = %+v, %v", filtered, err)
|
||||
}
|
||||
if _, err := filterPeriodsByType(periods, "MONTH"); err == nil {
|
||||
t.Fatal("filterPeriodsByType() accepted invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
DemoUsername string
|
||||
DemoPassword string
|
||||
SessionSecret string
|
||||
CursorSecret string
|
||||
FrontendOrigin string
|
||||
CookieSecure bool
|
||||
}
|
||||
@@ -30,6 +31,7 @@ func Load() (Config, error) {
|
||||
DemoUsername: value("DEMO_USERNAME", "merchant-0001"),
|
||||
DemoPassword: os.Getenv("DEMO_PASSWORD"),
|
||||
SessionSecret: os.Getenv("SESSION_SECRET"),
|
||||
CursorSecret: os.Getenv("CURSOR_SECRET"),
|
||||
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
|
||||
}
|
||||
|
||||
@@ -48,6 +50,12 @@ func (c Config) ValidateAPI() error {
|
||||
if len(c.SessionSecret) < 32 {
|
||||
return errors.New("config: SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
if len(c.CursorSecret) < 32 {
|
||||
return errors.New("config: CURSOR_SECRET must contain at least 32 characters")
|
||||
}
|
||||
if c.CursorSecret == c.SessionSecret {
|
||||
return errors.New("config: CURSOR_SECRET must differ from SESSION_SECRET")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,25 @@ func TestLoadRequiresDatabaseURL(t *testing.T) {
|
||||
|
||||
func TestValidateAPIRequiresLongSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{SessionSecret: "short"}
|
||||
cfg := Config{SessionSecret: "short", CursorSecret: "cursor-secret-that-is-at-least-32-bytes"}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want secret length error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIRequiresLongCursorSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{SessionSecret: "session-secret-that-is-at-least-32-bytes", CursorSecret: "short"}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want cursor secret length error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIRequiresIndependentCursorSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
secret := "shared-secret-that-is-at-least-32-characters"
|
||||
cfg := Config{SessionSecret: secret, CursorSecret: secret}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want independent secrets error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user