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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user