diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4b7ae20 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.agents +.codex + +**/.env +**/.env.* +!**/.env.example + +coverage.out +*.log +*.tmp + +tasks.md +README.md +e2e diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f42e470 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +ENVIRONMENT=development +LOG_LEVEL=debug + +HTTP_PORT=8080 +HTTP_READ_HEADER_TIMEOUT=5s +HTTP_READ_TIMEOUT=10s +HTTP_WRITE_TIMEOUT=15s +HTTP_IDLE_TIMEOUT=60s +HTTP_SHUTDOWN_TIMEOUT=10s + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=postgres_bd +POSTGRES_USER=admin +POSTGRES_PASSWORD=change-me +POSTGRES_SSLMODE=disable +POSTGRES_MAX_CONNS=10 +POSTGRES_MAX_IDLE_CONNS=5 +POSTGRES_CONN_MAX_LIFETIME=30m +POSTGRES_CONN_MAX_IDLE_TIME=5m + +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_USER= +REDIS_PASSWORD= +REDIS_DB=0 + +ACCESS_TOKEN_SECRET=replace-with-a-long-random-access-secret +REFRESH_TOKEN_SECRET=replace-with-a-different-long-random-refresh-secret +ACCESS_TOKEN_TTL=15m +REFRESH_TOKEN_TTL=720h +ISSUER=processing-local + +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_PER_HOUR=1000 +RATE_LIMIT_PER_DAY=10000 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..b062fec --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,56 @@ +name: Check +on: [push, pull_request] +jobs: + golangci-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.12" + - name: install deps + run: go mod download + - name: Lint + run: go vet ./... + test: + needs: [golangci-lint] + runs-on: ${{ matrix.os }} + strategy: + matrix: + go-version: ['1.25.12'] + os: ['ubuntu-latest', 'windows-latest'] + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup GO + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + - name: install deps + run: go mod download + - name: Run tests + run: go test ./internal/... -coverprofile=coverage.out + - name: Check coverage threshold + if: runner.os == 'Linux' + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') + echo "Coverage: $COVERAGE%" + if (( $(echo "$COVERAGE < 25" | bc -l) )); then + echo "Coverage is below 25%" + exit 1 + fi + + e2e: + needs: [golangci-lint] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.12" + - name: Run e2e tests + run: go test -count=1 ./e2e/... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..210c717 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +coverage.out +*.test + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e59462 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +ARG GO_VERSION=1.25.12 +ARG BUILDER_ALPINE_VERSION=3.24 +ARG ALPINE_VERSION=3.22.5 + +FROM golang:${GO_VERSION}-alpine${BUILDER_ALPINE_VERSION} AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd ./cmd +COPY internal ./internal + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/processing \ + ./cmd/server + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/migrate \ + ./cmd/migrate + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/seed \ + ./cmd/seed + +FROM alpine:${ALPINE_VERSION} AS runtime-base + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S app \ + && adduser -S -G app app + +WORKDIR /app + +USER app + +FROM runtime-base AS migrator + +COPY --from=builder --chown=app:app /out/migrate /app/migrate +COPY --chown=app:app migrations /app/migrations + +ENTRYPOINT ["/app/migrate"] + +FROM runtime-base AS seeder + +COPY --from=builder --chown=app:app /out/seed /app/seed + +ENTRYPOINT ["/app/seed"] + +FROM runtime-base AS app + +COPY --from=builder --chown=app:app /out/processing /app/processing + +EXPOSE 8080 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1 + +ENTRYPOINT ["/app/processing"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d3d4c2 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Payment Processing + +Это простой backend для внутренних переводов между счетами. + +Что тут есть: +- регистрация и логин +- JWT auth +- refresh / logout / logout-all +- получение аккаунта и истории операций +- перевод денег между счетами +- rate limiting через Redis +- PostgreSQL для балансов и идемпотентности +- миграции и seed для development + +## Быстрый запуск + +Нужны Docker и Docker Compose: + +```bash +docker compose up --build +``` + +После запуска сервис будет доступен по адресу: + +```text +http://localhost:8080 +``` + +Проверка готовности: + +```bash +make api-health-test +``` + +## Demo-пользователи + +В `development` режиме уже есть два пользователя: + +- sender@example.test / DemoPass123! +- receiver@example.test / DemoPass123! + +Их счета и стартовые балансы уже созданы через seed. + +## Примеры + +Готовые smoke-проверки уже есть в make: + +```bash +make api-auth-test +make api-accounts-test +``` + +Если хочешь вручную сделать запрос на перевод, можно так: + +```bash +curl -X POST http://localhost:8080/transactions \ + -H "Authorization: Bearer " \ + -H "Idempotency-Key: demo-transfer-1" \ + -H "Content-Type: application/json" \ + -d '{"receiver_id":"22222222-2222-2222-2222-222222222222","amount":"125.50"}' +``` + +Важно: +- одинаковый `Idempotency-Key` + тот же payload = тот же результат +- тот же `Idempotency-Key` + другой payload = `409 Conflict` + +## Основные роуты + +- `POST /auth/register` +- `POST /auth/login` +- `POST /auth/refresh` +- `POST /auth/logout` +- `POST /auth/logout-all` +- `GET /accounts/{id}` +- `GET /accounts/{id}/transactions` +- `POST /transactions` +- `GET /transactions/{id}` +- `GET /health/live` +- `GET /health/ready` + +## Что стоит знать + +- PostgreSQL — источник правды для балансов и операций +- Redis — для rate limiting +- переводы делаются внутри одной SQL-транзакции +- суммы проверяются до записи, чтобы не было некорректных значений + +## Проверки + +```bash +go test ./internal/... ./cmd/server/... +``` + +E2E тесты требуют Docker: + +```bash +go test ./e2e/... +``` + +## Структура + +- `cmd/server` — запуск API +- `cmd/migrate` — миграции +- `cmd/seed` — demo seed +- `internal/delivery/http` — HTTP слой +- `internal/usecase` — бизнес-логика +- `internal/domain` — сущности и интерфейсы +- `internal/infrastructure` — PostgreSQL, Redis, config, logger +- `migrations` — SQL миграции +- `e2e` — end-to-end тесты + +Это не продакшн-платёжка, а учебный проект, который показывает, как можно сделать нормальный backend с безопасными переводами и базовой архитектурой. diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..4593c87 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "processing/internal/infrastructure/config" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +const defaultMigrationsDir = "migrations" + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + cfg, err := config.LoadPostgres() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + db, err := sql.Open("pgx", cfg.PostgresDSN()) + if err != nil { + return fmt.Errorf("open postgres: %w", err) + } + defer db.Close() + + pingCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(pingCtx); err != nil { + return fmt.Errorf("ping postgres: %w", err) + } + + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("set goose dialect: %w", err) + } + if err := goose.UpContext(context.Background(), db, migrationsDir()); err != nil { + return fmt.Errorf("apply migrations: %w", err) + } + + log.Println("database migrations are up to date") + return nil +} + +func migrationsDir() string { + if dir := os.Getenv("MIGRATIONS_DIR"); dir != "" { + return dir + } + return defaultMigrationsDir +} diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..9e9bf25 --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "time" + + "processing/internal/infrastructure/config" + + _ "github.com/jackc/pgx/v5/stdlib" + "golang.org/x/crypto/bcrypt" +) + +const demoPassword = "DemoPass123!" + +var demoAccounts = []struct { + id string + name string + email string + balance string +}{ + {id: "11111111-1111-1111-1111-111111111111", name: "Demo Sender", email: "sender@example.test", balance: "1000.00"}, + {id: "22222222-2222-2222-2222-222222222222", name: "Demo Receiver", email: "receiver@example.test", balance: "1000.00"}, +} + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + if os.Getenv("ENVIRONMENT") != config.EnvironmentDevelopment { + return errors.New("demo seed is allowed only with ENVIRONMENT=development") + } + cfg, err := config.LoadPostgres() + if err != nil { + return fmt.Errorf("load postgres config: %w", err) + } + db, err := sql.Open("pgx", cfg.PostgresDSN()) + if err != nil { + return fmt.Errorf("open postgres: %w", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("ping postgres: %w", err) + } + hash, err := bcrypt.GenerateFromPassword([]byte(demoPassword), 12) + if err != nil { + return fmt.Errorf("hash demo password: %w", err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin seed transaction: %w", err) + } + defer tx.Rollback() + for _, account := range demoAccounts { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO accounts(id, name, email, password_hash, role, balance) + VALUES($1, $2, $3, $4, 'user', $5) + ON CONFLICT DO NOTHING + `, account.id, account.name, account.email, string(hash), account.balance); err != nil { + return fmt.Errorf("seed %s: %w", account.email, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit demo seed: %w", err) + } + return nil +} diff --git a/cmd/server/main.go b/cmd/server/main.go index a5f6c35..9ed9898 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,76 +1,15 @@ package main import ( - "database/sql" - "io" - "log/slog" - "net/http" + "fmt" "os" - "path/filepath" - handlers "processing/internal/delivery/http" - "processing/internal/infrastructure/cache" - "processing/internal/infrastructure/storage" - "processing/internal/usecase" - - _ "github.com/jackc/pgx/v5/stdlib" -) - -const ( - db_url = "postgres://admin:secret@localhost:5432/postgres_bd" - redis_url = "localhost:6379" + app "processing/internal/pkg/app" ) func main() { - if err := run(); err != nil { + if err := app.Run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } } - -func run() error { - app, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - panic(err) - } - defer app.Close() - - stor, err := os.OpenFile("storage.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - panic(err) - } - defer stor.Close() - - redis, err := os.OpenFile("redis.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - panic(err) - } - defer redis.Close() - - applog := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, app), nil)) - storagelog := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, stor), nil)) - redislog := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, redis), nil)) - - db, err := sql.Open("pgx", db_url) - if err != nil { - applog.Error("не получилось подключиться к бд", "err", err) - return err - } - if err := db.Ping(); err != nil { - applog.Error("не получилось пингануть бд", "err", err) - return err - } - slog.Info("Успешное подключение к бд!") - tx := storage.NewUoWFactory(db, storagelog) - cache := cache.NewRedis(redis_url, redislog) - path := filepath.Join("service.log") // возможно нужно по другому - transferService := usecase.NewService(tx, cache, path) - handler := handlers.NewHandler(transferService) - - mux := http.NewServeMux() - mux.HandleFunc("POST /transactions", handler.Transfer) - mux.HandleFunc("GET /transactions/{id}", handler.GetTransaction) - slog.Info("сервер запущен на :8080") - http.ListenAndServe(":8080", mux) - - return nil -} diff --git a/docker-compose.yml b/docker-compose.yml index be32ae3..235f67e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,139 @@ services: + app: + build: + context: . + dockerfile: Dockerfile + target: app + environment: + ENVIRONMENT: ${ENVIRONMENT:-development} + LOG_LEVEL: ${LOG_LEVEL:-info} + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB:-postgres_bd} + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret} + POSTGRES_SSLMODE: disable + POSTGRES_MAX_CONNS: "10" + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_USER: "" + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + RATE_LIMIT_PER_MINUTE: ${RATE_LIMIT_PER_MINUTE:-60} + RATE_LIMIT_PER_HOUR: ${RATE_LIMIT_PER_HOUR:-1000} + RATE_LIMIT_PER_DAY: ${RATE_LIMIT_PER_DAY:-10000} + ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET:-development-access-secret-change-me} + REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET:-development-refresh-secret-change-me} + ports: + - "${APP_PORT:-8080}:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + migrate: + condition: service_completed_successfully + seed: + condition: service_completed_successfully + healthcheck: + test: + - CMD + - wget + - -q + - -O + - /dev/null + - http://127.0.0.1:8080/health + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + init: true + restart: unless-stopped + + migrate: + build: + context: . + dockerfile: Dockerfile + target: migrator + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB:-postgres_bd} + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret} + POSTGRES_SSLMODE: disable + POSTGRES_MAX_CONNS: "10" + MIGRATIONS_DIR: /app/migrations + depends_on: + postgres: + condition: service_healthy + restart: "no" + + seed: + build: + context: . + dockerfile: Dockerfile + target: seeder + environment: + ENVIRONMENT: development + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB:-postgres_bd} + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret} + POSTGRES_SSLMODE: disable + POSTGRES_MAX_CONNS: "10" + depends_on: + migrate: + condition: service_completed_successfully + restart: "no" + postgres: - image: postgres:16 - container_name: postgres_bd + image: postgres:16.14-alpine3.23 environment: - POSTGRES_DB: postgres_bd - POSTGRES_USER: admin - POSTGRES_PASSWORD: secret + POSTGRES_DB: ${POSTGRES_DB:-postgres_bd} + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret} ports: - - "5432:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + restart: unless-stopped redis: - image: redis:latest + image: redis:7.4.10-alpine3.21 + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + command: + - /bin/sh + - -c + - | + if [ -n "$${REDIS_PASSWORD}" ]; then + exec redis-server --appendonly yes --requirepass "$${REDIS_PASSWORD}" + else + exec redis-server --appendonly yes + fi ports: - - "6379:6379" + - "${REDIS_PORT:-6379}:6379" volumes: - redis_data:/data - - kafka: - image: apache/kafka:latest - ports: - - "9092:9092" - volumes: - - kafka_data:/var/lib/kafka/data + healthcheck: + test: + - CMD-SHELL + - if [ -n "$${REDIS_PASSWORD}" ]; then redis-cli --no-auth-warning -a "$${REDIS_PASSWORD}" ping; else redis-cli ping; fi + interval: 5s + timeout: 3s + retries: 10 + start_period: 3s + restart: unless-stopped volumes: postgres_data: redis_data: - kafka_data: diff --git a/e2e/accounts_test.go b/e2e/accounts_test.go new file mode 100644 index 0000000..894ff64 --- /dev/null +++ b/e2e/accounts_test.go @@ -0,0 +1,67 @@ +package e2e + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAccount(t *testing.T) { + ts := SetupTestServer(t) + + user := createTestUser(t, ts, "getaccount@example.com", "password123", "AccountUser") + + t.Run("получение своего аккаунта", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, user.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение чужого аккаунта", func(t *testing.T) { + otherUser := createTestUser(t, ts, "other@example.com", "password123", "OtherUser") + + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, otherUser.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user.AccessToken) // токен user, запрашиваем otherUser + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение несуществующего аккаунта", func(t *testing.T) { + fakeID := uuid.New().String() + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, fakeID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение аккаунта без токена", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, user.AccountID) + req, _ := http.NewRequest("GET", url, nil) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} diff --git a/e2e/auth_test.go b/e2e/auth_test.go new file mode 100644 index 0000000..2314e6f --- /dev/null +++ b/e2e/auth_test.go @@ -0,0 +1,312 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthFlow(t *testing.T) { + ts := SetupTestServer(t) + + t.Run("успешная регистрация и логин", func(t *testing.T) { + registerPayload := map[string]string{ + "email": "test@example.com", + "password": "password123", + "username": "TestUser", + } + body, _ := json.Marshal(registerPayload) + + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var registerResp struct { + Account struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + } `json:"account"` + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + err = json.NewDecoder(resp.Body).Decode(®isterResp) + require.NoError(t, err) + + assert.NotEmpty(t, registerResp.Account.ID) + assert.Equal(t, "test@example.com", registerResp.Account.Email) + assert.Equal(t, "TestUser", registerResp.Account.Username) + assert.NotEmpty(t, registerResp.Tokens.AccessToken) + assert.NotEmpty(t, registerResp.Tokens.RefreshToken) + + var count int + err = ts.DB.QueryRow("SELECT COUNT(*) FROM accounts WHERE email = $1", "test@example.com").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 1, count) + }) + + t.Run("дублирующая регистрация", func(t *testing.T) { + email := "duplicate@example.com" + + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "User1", + } + body, _ := json.Marshal(registerPayload) + resp1, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp1.Body.Close() + assert.Equal(t, http.StatusCreated, resp1.StatusCode) + + resp2, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp2.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp2.StatusCode) + }) + + t.Run("логин с корректными данными", func(t *testing.T) { + email := "login@example.com" + password := "mypassword" + + registerPayload := map[string]string{ + "email": email, + "password": password, + "username": "LoginUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp.Body.Close() + + loginPayload := map[string]string{ + "email": email, + "password": password, + } + body, _ = json.Marshal(loginPayload) + resp, err = http.Post(ts.Server.URL+"/auth/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var loginResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + err = json.NewDecoder(resp.Body).Decode(&loginResp) + require.NoError(t, err) + + assert.NotEmpty(t, loginResp.AccessToken) + assert.NotEmpty(t, loginResp.RefreshToken) + }) + + t.Run("логин с неверным паролем", func(t *testing.T) { + email := "wrongpass@example.com" + + registerPayload := map[string]string{ + "email": email, + "password": "correctpassword", + "username": "WrongPassUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp.Body.Close() + + loginPayload := map[string]string{ + "email": email, + "password": "wrongpassword", + } + body, _ = json.Marshal(loginPayload) + resp, err = http.Post(ts.Server.URL+"/auth/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("refresh token", func(t *testing.T) { + email := "refresh@example.com" + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "RefreshUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var registerResp struct { + Tokens struct { + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + json.NewDecoder(resp.Body).Decode(®isterResp) + resp.Body.Close() + + refreshToken := registerResp.Tokens.RefreshToken + + refreshPayload := map[string]string{ + "refresh_token": refreshToken, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var refreshResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + err = json.NewDecoder(resp.Body).Decode(&refreshResp) + require.NoError(t, err) + + assert.NotEmpty(t, refreshResp.AccessToken) + assert.NotEmpty(t, refreshResp.RefreshToken) + }) + + t.Run("logout", func(t *testing.T) { + email := "logout@example.com" + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "LogoutUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var registerResp struct { + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + json.NewDecoder(resp.Body).Decode(®isterResp) + resp.Body.Close() + + logoutPayload := map[string]string{ + "refresh_token": registerResp.Tokens.RefreshToken, + } + body, _ = json.Marshal(logoutPayload) + req, _ := http.NewRequest("POST", ts.Server.URL+"/auth/logout", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+registerResp.Tokens.AccessToken) + + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + refreshPayload := map[string]string{ + "refresh_token": registerResp.Tokens.RefreshToken, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) +} + +func TestLogoutAll(t *testing.T) { + ts := SetupTestServer(t) + + email := "logoutall@example.com" + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "LogoutAllUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var registerResp struct { + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + json.NewDecoder(resp.Body).Decode(®isterResp) + resp.Body.Close() + + firstRefreshToken := registerResp.Tokens.RefreshToken + + refreshPayload := map[string]string{ + "refresh_token": firstRefreshToken, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var refreshResp struct { + RefreshToken string `json:"refresh_token"` + } + json.NewDecoder(resp.Body).Decode(&refreshResp) + resp.Body.Close() + + secondRefreshToken := refreshResp.RefreshToken + + req, _ := http.NewRequest("POST", ts.Server.URL+"/auth/logout-all", nil) + req.Header.Set("Authorization", "Bearer "+registerResp.Tokens.AccessToken) + + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + for _, token := range []string{firstRefreshToken, secondRefreshToken} { + refreshPayload := map[string]string{ + "refresh_token": token, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, "токен %s должен быть невалидным", token) + } +} + +func TestInvalidToken(t *testing.T) { + ts := SetupTestServer(t) + + accountID := uuid.New().String() + req, _ := http.NewRequest("GET", ts.Server.URL+"/accounts/"+accountID, nil) + req.Header.Set("Authorization", "Bearer invalid_token_here") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestMissingToken(t *testing.T) { + ts := SetupTestServer(t) + + accountID := uuid.New().String() + req, _ := http.NewRequest("GET", ts.Server.URL+"/accounts/"+accountID, nil) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} diff --git a/e2e/setup_test.go b/e2e/setup_test.go new file mode 100644 index 0000000..a50717a --- /dev/null +++ b/e2e/setup_test.go @@ -0,0 +1,220 @@ +package e2e + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "testing" + "time" + + httpapp "processing/internal/delivery/http/app" + jwtLayer "processing/internal/delivery/http/jwt" + "processing/internal/delivery/http/middleware" + httprouter "processing/internal/delivery/http/router" + "processing/internal/infrastructure/cache" + "processing/internal/infrastructure/config" + "processing/internal/infrastructure/logger" + "processing/internal/infrastructure/storage" + "processing/internal/usecase" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/modules/redis" + "github.com/testcontainers/testcontainers-go/wait" +) + +type TestServer struct { + Server *httptest.Server + DB *sql.DB + Logger *slog.Logger + Postgres *postgres.PostgresContainer + Redis *redis.RedisContainer +} + +func SetupTestServer(t *testing.T) *TestServer { + ctx := context.Background() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("e2e tests require Docker (Docker Desktop / Docker daemon) to be installed and running") + } + + t.Setenv("accessSecretKey", "test-access-secret-key-for-testing-only") + t.Setenv("refreshSecretKey", "test-refresh-secret-key-for-testing-only") + + postgresContainer, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("testdb"), + postgres.WithUsername("testuser"), + postgres.WithPassword("testpass"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second)), + ) + if err != nil { + t.Skipf("e2e tests require Docker to be running. Failed to start postgres container: %v", err) + } + + connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("не удалось получить connection string: %v", err) + } + + db, err := sql.Open("pgx", connStr) + if err != nil { + t.Fatalf("не удалось подключиться к БД: %v", err) + } + + if err := applyMigrations(db); err != nil { + t.Fatalf("не удалось применить миграции: %v", err) + } + + redisContainer, err := redis.Run(ctx, + "redis:7-alpine", + redis.WithSnapshotting(10, 1), + redis.WithLogLevel(redis.LogLevelVerbose), + ) + if err != nil { + t.Fatalf("не удалось запустить redis контейнер: %v", err) + } + + redisAddr, err := redisContainer.ConnectionString(ctx) + if err != nil { + t.Fatalf("не удалось получить redis connection string: %v", err) + } + + if len(redisAddr) > 8 && redisAddr[:8] == "redis://" { + redisAddr = redisAddr[8:] + } + + testCache := cache.NewRedis(cache.NewRedisOptions{ + Addr: redisAddr, + RateLimitMin: 10000, + RateLimitHour: 100000, + RateLimitDay: 1000000, + }) + + testLogger, err := logger.NewLogger("debug", "test") + if err != nil { + t.Fatalf("не удалось создать логгер: %v", err) + } + + tx := storage.NewUoWFactory(db) + jwtManager := jwtLayer.NewManager(config.JWTConfig{ + AccessSecret: "test-access-secret-key-for-testing-only", + RefreshSecret: "test-refresh-secret-key-for-testing-only", + AccessTTL: 15 * time.Minute, + RefreshTTL: 24 * time.Hour, + Issuer: "processing-e2e", + }) + transactionService := usecase.NewTransactionsService(tx, testCache, testLogger) + accountsService := usecase.NewAccountService(tx, testCache, testLogger) + authService := usecase.NewAuthService(tx, testCache, testLogger, jwtManager) + + app := httpapp.NewApp(transactionService, accountsService, authService, testLogger) + auth := middleware.NewAuth(jwtManager) + + router := httprouter.New(app, auth) + + server := httptest.NewServer(router) + + t.Cleanup(func() { + server.Close() + _ = testCache.Close() + db.Close() + if err := postgresContainer.Terminate(ctx); err != nil { + t.Logf("не удалось остановить postgres контейнер: %v", err) + } + if err := redisContainer.Terminate(ctx); err != nil { + t.Logf("не удалось остановить redis контейнер: %v", err) + } + }) + + return &TestServer{ + Server: server, + DB: db, + Logger: testLogger, + Postgres: postgresContainer, + Redis: redisContainer, + } +} + +func applyMigrations(db *sql.DB) error { + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("не удалось установить диалект postgres: %w", err) + } + + migrationDir := filepath.Join("..", "migrations") + + if err := goose.Up(db, migrationDir); err != nil { + return fmt.Errorf("не удалось применить миграции: %w", err) + } + + return nil +} + +type TestUser struct { + AccountID string + Email string + Username string + AccessToken string + RefreshToken string +} + +func createTestUser(t *testing.T, ts *TestServer, email, password, username string) *TestUser { + t.Helper() + + registerPayload := map[string]string{ + "email": email, + "password": password, + "username": username, + } + + body, err := json.Marshal(registerPayload) + if err != nil { + t.Fatalf("не удалось сериализовать payload: %v", err) + } + + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + if err != nil { + t.Fatalf("не удалось выполнить запрос регистрации: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("регистрация не удалась, статус: %d", resp.StatusCode) + } + + var registerResp struct { + Account struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + } `json:"account"` + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + + if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil { + t.Fatalf("не удалось декодировать ответ: %v", err) + } + + return &TestUser{ + AccountID: registerResp.Account.ID, + Email: registerResp.Account.Email, + Username: registerResp.Account.Username, + AccessToken: registerResp.Tokens.AccessToken, + RefreshToken: registerResp.Tokens.RefreshToken, + } +} diff --git a/e2e/transactions_test.go b/e2e/transactions_test.go new file mode 100644 index 0000000..5dff804 --- /dev/null +++ b/e2e/transactions_test.go @@ -0,0 +1,436 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransactionFlow(t *testing.T) { + ts := SetupTestServer(t) + + // Создаем двух пользователей + sender := createTestUser(t, ts, "sender@example.com", "password123", "Sender") + receiver := createTestUser(t, ts, "receiver@example.com", "password123", "Receiver") + + // Добавляем баланс отправителю напрямую в БД + _, err := ts.DB.Exec("UPDATE accounts SET balance = 1000 WHERE id = $1", sender.AccountID) + require.NoError(t, err) + + t.Run("успешный перевод", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "100.50", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + req.Header.Set("Idempotency-Key", "transaction-flow-success") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var transactionResp struct { + TransactionID string `json:"transaction_id"` + } + err = json.NewDecoder(resp.Body).Decode(&transactionResp) + require.NoError(t, err) + assert.NotEmpty(t, transactionResp.TransactionID) + + // Проверяем балансы в БД + var senderBalance, receiverBalance float64 + err = ts.DB.QueryRow("SELECT balance FROM accounts WHERE id = $1", sender.AccountID).Scan(&senderBalance) + require.NoError(t, err) + assert.InDelta(t, 899.50, senderBalance, 0.01) + + err = ts.DB.QueryRow("SELECT balance FROM accounts WHERE id = $1", receiver.AccountID).Scan(&receiverBalance) + require.NoError(t, err) + assert.InDelta(t, 100.50, receiverBalance, 0.01) + + // Проверяем, что транзакция записалась в БД + var status string + err = ts.DB.QueryRow("SELECT status FROM transactions WHERE id = $1", transactionResp.TransactionID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "completed", status) + }) + + t.Run("перевод с недостаточным балансом", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "10000.00", // Больше, чем есть на счету + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + req.Header.Set("Idempotency-Key", "transaction-flow-insufficient-funds") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) + + t.Run("перевод самому себе", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": sender.AccountID, // Отправитель = получатель + "amount": "50.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + req.Header.Set("Idempotency-Key", "transaction-flow-same-account") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) + + t.Run("перевод с отрицательной суммой", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "-10.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + req.Header.Set("Idempotency-Key", "transaction-flow-negative-amount") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) + + t.Run("перевод несуществующему получателю", func(t *testing.T) { + fakeReceiverID := uuid.New().String() + transferPayload := map[string]interface{}{ + "receiver_id": fakeReceiverID, + "amount": "10.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + req.Header.Set("Idempotency-Key", "transaction-flow-missing-receiver") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) +} + +func TestConcurrentTransfersPreserveMoneyAndIdempotency(t *testing.T) { + ts := SetupTestServer(t) + accountA := createTestUser(t, ts, "concurrent-a@example.com", "password123", "ConcurrentA") + accountB := createTestUser(t, ts, "concurrent-b@example.com", "password123", "ConcurrentB") + _, err := ts.DB.Exec("UPDATE accounts SET balance = 1000 WHERE id IN ($1, $2)", accountA.AccountID, accountB.AccountID) + require.NoError(t, err) + + t.Run("parallel and opposing transfers", func(t *testing.T) { + const transfersEachWay = 12 + errs := make(chan error, transfersEachWay*2) + var wg sync.WaitGroup + for i := 0; i < transfersEachWay; i++ { + for _, direction := range []struct { + sender *TestUser + receiver *TestUser + key string + }{ + {sender: accountA, receiver: accountB, key: fmt.Sprintf("opposing-a-b-%d", i)}, + {sender: accountB, receiver: accountA, key: fmt.Sprintf("opposing-b-a-%d", i)}, + } { + wg.Add(1) + go func(direction struct { + sender *TestUser + receiver *TestUser + key string + }) { + defer wg.Done() + _, status, err := postTransfer(ts, direction.sender, direction.receiver.AccountID, "10.00", direction.key) + if err == nil && status != http.StatusCreated { + err = fmt.Errorf("unexpected transfer status %d", status) + } + errs <- err + }(direction) + } + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + assertTotalBalance(t, ts, "2000") + }) + + t.Run("same idempotency key is charged once", func(t *testing.T) { + const repeats = 8 + ids := make(chan string, repeats) + errs := make(chan error, repeats) + var wg sync.WaitGroup + for i := 0; i < repeats; i++ { + wg.Add(1) + go func() { + defer wg.Done() + id, status, err := postTransfer(ts, accountA, accountB.AccountID, "5.00", "parallel-same-key") + if err == nil && status != http.StatusCreated { + err = fmt.Errorf("unexpected replay status %d", status) + } + ids <- id + errs <- err + }() + } + wg.Wait() + close(ids) + close(errs) + for err := range errs { + require.NoError(t, err) + } + var transactionID string + for id := range ids { + if transactionID == "" { + transactionID = id + } + assert.Equal(t, transactionID, id) + } + var count int + require.NoError(t, ts.DB.QueryRow("SELECT COUNT(*) FROM transactions WHERE id = $1", transactionID).Scan(&count)) + assert.Equal(t, 1, count) + assertTotalBalance(t, ts, "2000") + }) +} + +func postTransfer(ts *TestServer, sender *TestUser, receiverID, amount, key string) (string, int, error) { + body, err := json.Marshal(map[string]string{"receiver_id": receiverID, "amount": amount}) + if err != nil { + return "", 0, err + } + req, err := http.NewRequest(http.MethodPost, ts.Server.URL+"/transactions", bytes.NewReader(body)) + if err != nil { + return "", 0, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + req.Header.Set("Idempotency-Key", key) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + var result struct { + TransactionID string `json:"transaction_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", resp.StatusCode, err + } + return result.TransactionID, resp.StatusCode, nil +} + +func assertTotalBalance(t *testing.T, ts *TestServer, expected string) { + t.Helper() + var conserved bool + require.NoError(t, ts.DB.QueryRow("SELECT SUM(balance) = $1::numeric FROM accounts", expected).Scan(&conserved)) + assert.True(t, conserved) +} + +func TestGetTransaction(t *testing.T) { + ts := SetupTestServer(t) + + // Создаем пользователей и делаем транзакцию + sender := createTestUser(t, ts, "getsender@example.com", "password123", "GetSender") + receiver := createTestUser(t, ts, "getreceiver@example.com", "password123", "GetReceiver") + + // Добавляем баланс + _, err := ts.DB.Exec("UPDATE accounts SET balance = 500 WHERE id = $1", sender.AccountID) + require.NoError(t, err) + + // Создаем транзакцию + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "50.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + req.Header.Set("Idempotency-Key", "get-transaction-setup") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + var transactionResp struct { + TransactionID string `json:"transaction_id"` + } + json.NewDecoder(resp.Body).Decode(&transactionResp) + resp.Body.Close() + + transactionID := transactionResp.TransactionID + + t.Run("получение транзакции отправителем", func(t *testing.T) { + req, _ := http.NewRequest("GET", ts.Server.URL+"/transactions/"+transactionID, nil) + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var tx struct { + Amount string `json:"amount"` + SenderID string `json:"sender_id"` + ReceiverID string `json:"receiver_id"` + Status string `json:"status"` + } + err = json.NewDecoder(resp.Body).Decode(&tx) + require.NoError(t, err) + + assert.Equal(t, "50", tx.Amount) + assert.Equal(t, sender.AccountID, tx.SenderID) + assert.Equal(t, receiver.AccountID, tx.ReceiverID) + assert.Equal(t, "completed", tx.Status) + }) + + t.Run("получение транзакции получателем", func(t *testing.T) { + req, _ := http.NewRequest("GET", ts.Server.URL+"/transactions/"+transactionID, nil) + req.Header.Set("Authorization", "Bearer "+receiver.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение чужой транзакции", func(t *testing.T) { + stranger := createTestUser(t, ts, "stranger@example.com", "password123", "Stranger") + + req, _ := http.NewRequest("GET", ts.Server.URL+"/transactions/"+transactionID, nil) + req.Header.Set("Authorization", "Bearer "+stranger.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) +} + +func TestAccountTransactionHistory(t *testing.T) { + ts := SetupTestServer(t) + + user1 := createTestUser(t, ts, "history1@example.com", "password123", "HistoryUser1") + user2 := createTestUser(t, ts, "history2@example.com", "password123", "HistoryUser2") + user3 := createTestUser(t, ts, "history3@example.com", "password123", "HistoryUser3") + + // Добавляем баланс + _, err := ts.DB.Exec("UPDATE accounts SET balance = 1000 WHERE id = $1", user1.AccountID) + require.NoError(t, err) + + // Создаем несколько транзакций + for i := 0; i < 5; i++ { + receiver := user2.AccountID + if i%2 == 0 { + receiver = user3.AccountID + } + + transferPayload := map[string]interface{}{ + "receiver_id": receiver, + "amount": fmt.Sprintf("%d.00", 10*(i+1)), + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) + req.Header.Set("Idempotency-Key", fmt.Sprintf("test-transaction-%d", i)) // уникальный ключ для каждого запроса + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + } + + t.Run("получение истории транзакций", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s/transactions?limit=10&offset=0", ts.Server.URL, user1.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var historyResp struct { + Total int `json:"total"` + Transactions []struct { + Amount string `json:"amount"` + SenderID string `json:"sender_id"` + ReceiverID string `json:"receiver_id"` + Status string `json:"status"` + } `json:"transactions"` + } + err = json.NewDecoder(resp.Body).Decode(&historyResp) + require.NoError(t, err) + + assert.Equal(t, 5, historyResp.Total) + assert.Len(t, historyResp.Transactions, 5) + }) + + t.Run("пагинация истории транзакций", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s/transactions?limit=2&offset=0", ts.Server.URL, user1.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + var historyResp struct { + Total int `json:"total"` + Transactions []interface{} `json:"transactions"` + } + err = json.NewDecoder(resp.Body).Decode(&historyResp) + require.NoError(t, err) + + assert.Equal(t, 5, historyResp.Total) + assert.Len(t, historyResp.Transactions, 2) + }) + + t.Run("получение истории чужого аккаунта", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s/transactions?limit=10&offset=0", ts.Server.URL, user2.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) // токен user1, но запрашиваем историю user2 + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) +} diff --git a/endpoints.png b/endpoints.png deleted file mode 100644 index eb7ca68..0000000 Binary files a/endpoints.png and /dev/null differ diff --git a/go.mod b/go.mod index fb84584..989ac23 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,26 @@ module processing -go 1.25.5 +go 1.25.12 + +require ( + github.com/alicebob/miniredis/v2 v2.38.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/joho/godotenv v1.5.1 + github.com/pressly/goose/v3 v3.27.1 + github.com/redis/go-redis/v9 v9.20.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.43.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 + github.com/testcontainers/testcontainers-go/modules/redis v0.43.0 + golang.org/x/crypto v0.53.0 +) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/alicebob/miniredis/v2 v2.38.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -14,29 +28,27 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-redis/redis v6.15.9+incompatible // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx v3.6.2+incompatible // indirect - github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/moby/api v1.54.1 // indirect - github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect @@ -44,32 +56,25 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pierrec/lz4/v4 v4.1.15 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/pressly/goose v2.7.0+incompatible // indirect - github.com/redis/go-redis v6.15.9+incompatible // indirect - github.com/redis/go-redis/v9 v9.20.0 // indirect - github.com/segmentio/kafka-go v0.4.51 // indirect - github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/testcontainers/testcontainers-go v0.42.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c952332..3c8474d 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -20,15 +26,19 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -40,37 +50,51 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= -github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= -github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= -github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= -github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -81,39 +105,44 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= -github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/pressly/goose v2.7.0+incompatible h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ= -github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8= -github.com/redis/go-redis v6.15.9+incompatible h1:F+tnlesQSl3h9V8DdmtcYFdvkHLhbb7AgcLW6UJxnC4= -github.com/redis/go-redis v6.15.9+incompatible/go.mod h1:ic6dLmR0d9rkHSzaa0Ab3QVRZcjopJ9hSSPCrecj/+s= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= -github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= -github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= -github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= -github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= -github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= -github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 h1:ShNOFYAF4lKHvdIG258hi69bSxC88uXnxJkJvNs/IVs= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0/go.mod h1:vdq5/RqmGfWeefzyfcVI/pID1rzmc1TDvqXa15bPJks= +github.com/testcontainers/testcontainers-go/modules/redis v0.43.0 h1:qzATMhrltLr07KcGl/d674ouqI0AFtf6wnQb3VnqP7M= +github.com/testcontainers/testcontainers-go/modules/redis v0.43.0/go.mod h1:ygEcEUIZzmIlOKpjBfnPn/lUIRNorr1kPj3XfFPTQXM= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -122,31 +151,55 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/decimal/decimal.go b/internal/decimal/decimal.go index aaac483..551a565 100644 --- a/internal/decimal/decimal.go +++ b/internal/decimal/decimal.go @@ -1,6 +1,7 @@ package decimal import ( + "encoding/json" "fmt" "math" "math/big" @@ -8,6 +9,8 @@ import ( "strings" ) +const MaxAbsExponent = 1000 + var zeroInt = big.NewInt(0) var tenInt = big.NewInt(10) @@ -87,6 +90,9 @@ func NewFromString(value string) (Decimal, error) { // NOTE(vadim): I doubt a string could realistically be this long return Decimal{}, fmt.Errorf("can't convert %s to decimal: fractional part too long", originalInput) } + if exp < -MaxAbsExponent || exp > MaxAbsExponent { + return Decimal{}, fmt.Errorf("can't convert %s to decimal: exponent exceeds %d", originalInput, MaxAbsExponent) + } return Decimal{ value: dValue, @@ -94,6 +100,35 @@ func NewFromString(value string) (Decimal, error) { }, nil } +// FitsNumeric reports whether d can be stored exactly in NUMERIC(precision, scale). +// It avoids rendering the decimal, so exponent notation cannot trigger a large allocation. +func (d Decimal) FitsNumeric(precision, scale int32) bool { + if precision <= 0 || scale < 0 || scale > precision { + return false + } + if d.Sign() == 0 { + return true + } + + digits := new(big.Int).Abs(d.getValue()).String() + exp := d.exp + for len(digits) > 1 && exp < 0 && digits[len(digits)-1] == '0' { + digits = digits[:len(digits)-1] + exp++ + } + + fractionalDigits := int32(0) + if exp < 0 { + fractionalDigits = -exp + } + integerDigits := int32(len(digits)) + exp + if integerDigits < 0 { + integerDigits = 0 + } + + return fractionalDigits <= scale && integerDigits <= precision-scale +} + func (d Decimal) String() string { return d.string(true, false) } @@ -270,3 +305,33 @@ func (d Decimal) Sign() int { func (d Decimal) IsPositive() bool { return d.Sign() == 1 } + +func Zero() Decimal { + return Decimal{ + value: big.NewInt(0), + exp: 0, + } +} + +// MarshalJSON implements json.Marshaler +func (d Decimal) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +// UnmarshalJSON implements json.Unmarshaler +func (d *Decimal) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + // If it's not a string, try to unmarshal as number and convert to string + var n float64 + if err2 := json.Unmarshal(data, &n); err2 == nil { + s = strconv.FormatFloat(n, 'f', -1, 64) + } else { + return err + } + } + + var err error + *d, err = NewFromString(s) + return err +} diff --git a/internal/decimal/decimal_test.go b/internal/decimal/decimal_test.go new file mode 100644 index 0000000..a9c78c4 --- /dev/null +++ b/internal/decimal/decimal_test.go @@ -0,0 +1,73 @@ +package decimal + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFitsNumeric36Scale18(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + fits bool + }{ + {name: "integer boundary", value: "999999999999999999", fits: true}, + {name: "integer overflow", value: "1000000000000000000", fits: false}, + {name: "scale boundary", value: "0.123456789012345678", fits: true}, + {name: "scale overflow", value: "0.1234567890123456789", fits: false}, + {name: "trailing fractional zeros are exact", value: "1.2300000000000000000", fits: true}, + {name: "scientific notation", value: "1e2", fits: true}, + {name: "smallest scale", value: "1e-18", fits: true}, + {name: "scale exponent overflow", value: "1e-19", fits: false}, + {name: "zero", value: "0", fits: true}, + {name: "negative", value: "-1.25", fits: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d, err := NewFromString(tt.value) + require.NoError(t, err) + assert.Equal(t, tt.fits, d.FitsNumeric(36, 18)) + }) + } +} + +func TestNewFromStringRejectsExcessiveExponent(t *testing.T) { + t.Parallel() + + _, err := NewFromString("1e1001") + require.Error(t, err) + assert.Contains(t, err.Error(), "exponent exceeds") + + _, err = NewFromString("1e-1001") + require.Error(t, err) +} + +func TestNewFromStringRejectsInvalidInputs(t *testing.T) { + t.Parallel() + + for _, value := range []string{"", ".", "1.2.3", "1e2e3", "not-a-number", strings.Repeat("9", 128) + "x"} { + _, err := NewFromString(value) + require.Error(t, err, value) + } +} + +func FuzzNewFromString(f *testing.F) { + for _, seed := range []string{"0", "1.25", "-3", "1e18", "1e-18", "bad"} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, value string) { + if len(value) > 1024 { + t.Skip() + } + d, err := NewFromString(value) + if err == nil { + _ = d.FitsNumeric(36, 18) + } + }) +} diff --git a/internal/decimal/sql.go b/internal/decimal/sql.go index 858ca2a..2ce1da2 100644 --- a/internal/decimal/sql.go +++ b/internal/decimal/sql.go @@ -1,39 +1,39 @@ -package decimal - -import ( - "database/sql/driver" - "fmt" -) - -// Scan implements the sql.Scanner interface for database deserialization. -func (d *Decimal) Scan(value interface{}) error { - // first try to see if the data is stored in database as a Numeric datatype - switch v := value.(type) { - case string: - var err error - *d, err = NewFromString(unquoteIfQuoted(v)) - return err - - case []byte: - var err error - *d, err = NewFromString(unquoteIfQuoted(string(v))) - return err - - default: - return fmt.Errorf("could not convert value '%+v' to any known type", value) - } -} - -// Value implements the driver.Valuer interface for database serialization. -func (d Decimal) Value() (driver.Value, error) { - return d.String(), nil -} - -func unquoteIfQuoted(value string) string { - // If the amount is quoted, strip the quotes - if len(value) > 2 && value[0] == '"' && value[len(value)-1] == '"' { - return value[1 : len(value)-1] - } - - return value -} +package decimal + +import ( + "database/sql/driver" + "fmt" +) + +// Scan implements the sql.Scanner interface for database deserialization. +func (d *Decimal) Scan(value interface{}) error { + // first try to see if the data is stored in database as a Numeric datatype + switch v := value.(type) { + case string: + var err error + *d, err = NewFromString(unquoteIfQuoted(v)) + return err + + case []byte: + var err error + *d, err = NewFromString(unquoteIfQuoted(string(v))) + return err + + default: + return fmt.Errorf("could not convert value '%+v' to any known type", value) + } +} + +// Value implements the driver.Valuer interface for database serialization. +func (d Decimal) Value() (driver.Value, error) { + return d.String(), nil +} + +func unquoteIfQuoted(value string) string { + // If the amount is quoted, strip the quotes + if len(value) > 2 && value[0] == '"' && value[len(value)-1] == '"' { + return value[1 : len(value)-1] + } + + return value +} diff --git a/internal/delivery/http/app/app.go b/internal/delivery/http/app/app.go new file mode 100644 index 0000000..e913c61 --- /dev/null +++ b/internal/delivery/http/app/app.go @@ -0,0 +1,27 @@ +package app + +import ( + "log/slog" + "processing/internal/domain" +) + +type App struct { + TransactionUsecase domain.TransactionUsecase + AccountsUsecase domain.AccountsUsecase + AuthUseCase domain.AuthUseCase + Log *slog.Logger +} + +func NewApp( + ts domain.TransactionUsecase, + as domain.AccountsUsecase, + auth domain.AuthUseCase, + log *slog.Logger, +) *App { + return &App{ + TransactionUsecase: ts, + AccountsUsecase: as, + AuthUseCase: auth, + Log: log, + } +} diff --git a/internal/delivery/http/error.go b/internal/delivery/http/error.go deleted file mode 100644 index 3b000af..0000000 --- a/internal/delivery/http/error.go +++ /dev/null @@ -1,30 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" -) - -func status(id int) string { - m := map[int]string{ - 200: "OK", - 400: "StatusBadRequest", - 401: "Unauthorized", - 403: "Forbidden", - 404: "StatusNotFound", - 429: "too many requests", - 500: "internal server error", - } - value, _ := m[id] - return value -} - -// writeError пишет ошибку клиенту. -// flag: 1 - полная ошибка, любой другой - только часть -func writeError(w http.ResponseWriter, code int, err error, flag int) { - if flag == 1 { - json.NewEncoder(w).Encode(map[string]string{"error": status(code)}) - json.NewEncoder(w).Encode(map[string]string{"message": err.Error()}) - } - json.NewEncoder(w).Encode(map[string]string{"error": status(code)}) -} diff --git a/internal/delivery/http/handler_test.go b/internal/delivery/http/handler_test.go deleted file mode 100644 index 0bebd61..0000000 --- a/internal/delivery/http/handler_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package handlers - -type \ No newline at end of file diff --git a/internal/delivery/http/handlers/accounts/account.go b/internal/delivery/http/handlers/accounts/account.go new file mode 100644 index 0000000..2d59a89 --- /dev/null +++ b/internal/delivery/http/handlers/accounts/account.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + httpapp "processing/internal/delivery/http/app" + "processing/internal/delivery/http/helpers/httputil" + "processing/internal/delivery/http/requestctx" + "processing/internal/domain" + "strconv" + + "github.com/google/uuid" +) + +type AccountDTO struct { + Name string `json:"name"` + Password string `json:"password"` + Email string `json:"email"` +} + +type Handler struct { + as domain.AccountsUsecase + log *slog.Logger +} + +func New(app *httpapp.App) *Handler { + return &Handler{ + as: app.AccountsUsecase, + log: app.Log, + } +} + +func NewHandler(ts domain.TransactionUsecase, as domain.AccountsUsecase, auth domain.AuthUseCase, log *slog.Logger) *Handler { + return &Handler{ + as: as, + log: log, + } +} + +// выводит информацию об аккаунте по айди +// GET /accounts/:id +func (h *Handler) GetAccount(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + + accountIDStr := r.PathValue("id") + accountID, err := uuid.Parse(accountIDStr) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + identity, ok := requestctx.IdentityFrom(ctx) + if !ok { + httputil.WriteError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + if identity.UserID != accountID.String() { + httputil.WriteError(w, http.StatusForbidden, domain.ErrAccessDenied, 0) + return + } + + account, err := h.as.GetAccount(ctx, accountID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 1) + return + } + + if err := httputil.WriteJSON(w, http.StatusOK, account); err != nil { + h.log.Error("[GetAccount] json encode", "err", err) + } +} + +type AccountTransactions struct { + Transactions []domain.Transaction `json:"transactions"` + Total int `json:"total"` +} + +// Get /accounts/:id/transactions?limit=..&offset=... +func (h *Handler) AccountTransactions(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + + accountIDStr := r.PathValue("id") + accountID, err := uuid.Parse(accountIDStr) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + identity, ok := requestctx.IdentityFrom(ctx) + if !ok { + httputil.WriteError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + if identity.UserID != accountID.String() { + httputil.WriteError(w, http.StatusForbidden, domain.ErrAccessDenied, 0) + return + } + + limit := r.URL.Query().Get("limit") + offset := r.URL.Query().Get("offset") + l, err := strconv.Atoi(limit) + if err != nil { + l = 10 + } + o, err := strconv.Atoi(offset) + if err != nil { + o = 0 + } + + total, transactions, err := h.as.TransactionHistory(ctx, accountID, l, o) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 0) + return + } + + dto := AccountTransactions{ + Transactions: transactions, + Total: total, + } + + if err := httputil.WriteJSON(w, http.StatusOK, dto); err != nil { + h.log.Error("[AccountTransactions] json encode", "err", err) + } +} diff --git a/internal/delivery/http/handlers/accounts/account_test.go b/internal/delivery/http/handlers/accounts/account_test.go new file mode 100644 index 0000000..1454ed1 --- /dev/null +++ b/internal/delivery/http/handlers/accounts/account_test.go @@ -0,0 +1,338 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "processing/internal/decimal" + "processing/internal/delivery/http/mocks" + "processing/internal/delivery/http/requestctx" + "processing/internal/domain" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetAccountHandler(t *testing.T) { + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + + tests := []struct { + name string + accountID string + setupMock func(*mocks.AccountsUsecase) + expectedStatusCode int + expectError bool + }{ + { + name: "успешное получение аккаунта", + accountID: testUserID.String(), + setupMock: func(m *mocks.AccountsUsecase) { + balance, _ := decimal.NewFromString("1000.50") + expectedAccount := &domain.Account{ + ID: testUserID, + Name: "Test User", + Email: "test@example.com", + Balance: balance, + PasswordHash: "hashed_password", + Role: "user", + } + m.On("GetAccount", + mock.Anything, + testUserID, + ).Return(expectedAccount, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "невалидный UUID аккаунта", + accountID: "invalid-uuid", + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "доступ запрещен к чужому аккаунту", + accountID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001").String(), + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusForbidden, + expectError: true, + }, + { + name: "аккаунт не найден", + accountID: testUserID.String(), + setupMock: func(m *mocks.AccountsUsecase) { + m.On("GetAccount", + mock.Anything, + testUserID, + ).Return(nil, errors.New("аккаунт не найден")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + { + name: "ошибка БД при получении аккаунта", + accountID: testUserID.String(), + setupMock: func(m *mocks.AccountsUsecase) { + m.On("GetAccount", + mock.Anything, + testUserID, + ).Return(nil, errors.New("database connection error")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockTransactionUsecase := mocks.NewTransactionUsecase(t) + mockAuthUsecase := mocks.NewAuthUseCase(t) + handler := NewHandler(mockTransactionUsecase, mockAccountUsecase, mockAuthUsecase, nil) + + tt.setupMock(mockAccountUsecase) + + req := httptest.NewRequest(http.MethodGet, "/accounts/"+tt.accountID, nil) + req.SetPathValue("id", tt.accountID) + ctx := requestctx.WithIdentity(context.Background(), requestctx.Identity{UserID: testUserID.String()}) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.GetAccount(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var response domain.Account + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "ответ должен быть валидным JSON") + assert.Equal(t, testUserID, response.ID) + assert.NotEmpty(t, response.Name) + assert.NotEmpty(t, response.Email) + } + }) + t.Log("\n\n\n") + } +} + +func TestAccountTransactionsHandler(t *testing.T) { + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + validTransactionID1 := uuid.MustParse("223e4567-e89b-12d3-a456-426614174001") + validTransactionID2 := uuid.MustParse("323e4567-e89b-12d3-a456-426614174002") + + tests := []struct { + name string + accountID string + limit string + offset string + setupMock func(*mocks.AccountsUsecase) + expectedStatusCode int + expectError bool + expectedTotal int + expectedCount int + }{ + { + name: "успешное получение истории транзакций", + accountID: testUserID.String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + amount1, _ := decimal.NewFromString("500.00") + amount2, _ := decimal.NewFromString("250.50") + expectedTransactions := []domain.Transaction{ + { + ID: validTransactionID1, + Amount: amount1, + Sender_id: testUserID, + Receiver_id: uuid.MustParse("423e4567-e89b-12d3-a456-426614174003"), + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + { + ID: validTransactionID2, + Amount: amount2, + Sender_id: uuid.MustParse("523e4567-e89b-12d3-a456-426614174004"), + Receiver_id: testUserID, + Status: domain.StatusCompleted, + Created_at: time.Now().Add(-24 * time.Hour), + }, + } + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(25, expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 2, + }, + { + name: "получение с пагинацией offset", + accountID: testUserID.String(), + limit: "5", + offset: "10", + setupMock: func(m *mocks.AccountsUsecase) { + amount, _ := decimal.NewFromString("100.00") + expectedTransactions := []domain.Transaction{ + { + ID: validTransactionID1, + Amount: amount, + Sender_id: testUserID, + Receiver_id: uuid.MustParse("623e4567-e89b-12d3-a456-426614174005"), + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + } + m.On("TransactionHistory", + mock.Anything, + testUserID, + 5, + 10, + ).Return(25, expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 1, + }, + { + name: "пустая история транзакций", + accountID: testUserID.String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(0, []domain.Transaction{}, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 0, + expectedCount: 0, + }, + { + name: "доступ запрещен к чужой истории", + accountID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001").String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusForbidden, + expectError: true, + }, + { + name: "невалидный UUID аккаунта", + accountID: "invalid-uuid", + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "невалидный limit параметр", + accountID: testUserID.String(), + limit: "invalid", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(25, []domain.Transaction{}, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 0, + }, + { + name: "невалидный offset параметр", + accountID: testUserID.String(), + limit: "10", + offset: "invalid", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(25, []domain.Transaction{}, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 0, + }, + { + name: "ошибка от usecase", + accountID: testUserID.String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(0, nil, errors.New("database error")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockTransactionUsecase := mocks.NewTransactionUsecase(t) + mockAuthUsecase := mocks.NewAuthUseCase(t) + handler := NewHandler(mockTransactionUsecase, mockAccountUsecase, mockAuthUsecase, nil) + + tt.setupMock(mockAccountUsecase) + + url := "/accounts/" + tt.accountID + "/transactions?limit=" + tt.limit + "&offset=" + tt.offset + req := httptest.NewRequest(http.MethodGet, url, nil) + req.SetPathValue("id", tt.accountID) + ctx := requestctx.WithIdentity(context.Background(), requestctx.Identity{UserID: testUserID.String()}) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.AccountTransactions(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + + if !tt.expectError { + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + var response AccountTransactions + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err, "ответ должен быть валидным JSON") + assert.Equal(t, tt.expectedTotal, response.Total, "неожиданное количество страниц") + assert.Equal(t, tt.expectedCount, len(response.Transactions), "неожиданное количество транзакций") + } else { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } + }) + t.Log("\n\n\n") + } +} diff --git a/internal/delivery/http/handlers/auth/auth_handler.go b/internal/delivery/http/handlers/auth/auth_handler.go new file mode 100644 index 0000000..dbfe399 --- /dev/null +++ b/internal/delivery/http/handlers/auth/auth_handler.go @@ -0,0 +1,194 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + httpapp "processing/internal/delivery/http/app" + "processing/internal/delivery/http/helpers/httputil" + "processing/internal/delivery/http/requestctx" + "processing/internal/domain" + + "github.com/google/uuid" +) + +type AuthDTO struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"username"` +} + +type Handler struct { + auth domain.AuthUseCase + log *slog.Logger +} + +type handler = Handler + +func New(app *httpapp.App) *Handler { + return &Handler{ + auth: app.AuthUseCase, + log: app.Log, + } +} + +func NewHandler(auth domain.AuthUseCase, log *slog.Logger) *Handler { + return &Handler{ + auth: auth, + log: log, + } +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + ip := httputil.ClientIP(r) + + var dto AuthDTO + if err := httputil.DecodeJSON(w, r, &dto); err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + account, err := h.auth.Register(ctx, dto.Email, dto.Password, dto.Name, ip) + if err != nil { + httputil.WriteAuthError(w, err) + return + } + + token, err := h.auth.Login(ctx, dto.Email, dto.Password, ip) + if err != nil { + httputil.WriteAuthError(w, err) + return + } + + httputil.SetAuthCookie(w, "/api", "access_token", token.AccessToken, 900) + httputil.SetAuthCookie(w, "/auth/refresh", "refresh_token", token.RefreshToken, 604800) + if err := httputil.WriteJSON(w, http.StatusCreated, map[string]interface{}{ + "account": account, + "tokens": token, + }); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 1) + } +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + ip := httputil.ClientIP(r) + + var dto AuthDTO + if err := httputil.DecodeJSON(w, r, &dto); err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + token, err := h.auth.Login(ctx, dto.Email, dto.Password, ip) + if err != nil { + httputil.WriteAuthError(w, err) + return + } + + httputil.SetAuthCookie(w, "/api", "access_token", token.AccessToken, 900) + httputil.SetAuthCookie(w, "/auth/refresh", "refresh_token", token.RefreshToken, 604800) + if err := httputil.WriteJSON(w, http.StatusOK, token); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 1) + } +} + +func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + cookie, err := r.Cookie("refresh_token") + var refreshToken string + if err == nil { + refreshToken = cookie.Value + } else { + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := httputil.DecodeJSON(w, r, &req); err != nil { + httputil.WriteError(w, http.StatusBadRequest, errors.New("refresh token отсутствует"), 1) + return + } + refreshToken = req.RefreshToken + } + ip := httputil.ClientIP(r) + if refreshToken == "" { + httputil.WriteError(w, http.StatusBadRequest, errors.New("refresh token отсутствует"), 1) + return + } + + token, err := h.auth.Refresh(ctx, refreshToken, ip) + if err != nil { + httputil.WriteAuthError(w, err) + return + } + + httputil.SetAuthCookie(w, "/api", "access_token", token.AccessToken, 900) + httputil.SetAuthCookie(w, "/auth/refresh", "refresh_token", token.RefreshToken, 604800) + if err := httputil.WriteJSON(w, http.StatusOK, token); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 0) + } +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + var refreshToken string + cookie, err := r.Cookie("refresh_token") + if err == nil { + refreshToken = cookie.Value + } else { + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := httputil.DecodeJSON(w, r, &req); err != nil { + httputil.WriteError(w, http.StatusBadRequest, errors.New("refresh token отсутствует"), 0) + return + } + refreshToken = req.RefreshToken + } + ip := httputil.ClientIP(r) + if refreshToken == "" { + httputil.WriteError(w, http.StatusBadRequest, errors.New("refresh token отсутствует"), 0) + return + } + + if err := h.auth.Logout(ctx, refreshToken, ip); err != nil { + httputil.WriteAuthError(w, err) + return + } + httputil.SetAuthCookie(w, "/api", "access_token", "", -1) + httputil.SetAuthCookie(w, "/auth/refresh", "refresh_token", "", -1) + if err := httputil.WriteJSON(w, http.StatusOK, map[string]string{"message": "success"}); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 0) + } +} + +func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + + identity, ok := requestctx.IdentityFrom(ctx) + if !ok { + httputil.WriteError(w, http.StatusBadRequest, errors.New("поле user_id должно быть string"), 1) + return + } + + userID, err := uuid.Parse(identity.UserID) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + if err := h.auth.LogoutAll(ctx, userID); err != nil { + httputil.WriteAuthError(w, err) + return + } + httputil.SetAuthCookie(w, "/api", "access_token", "", -1) + httputil.SetAuthCookie(w, "/auth/refresh", "refresh_token", "", -1) + if err := httputil.WriteJSON(w, http.StatusOK, map[string]string{"message": "success"}); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 0) + } +} diff --git a/internal/delivery/http/handlers/auth/auth_test.go b/internal/delivery/http/handlers/auth/auth_test.go new file mode 100644 index 0000000..7fc2164 --- /dev/null +++ b/internal/delivery/http/handlers/auth/auth_test.go @@ -0,0 +1,1102 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "processing/internal/decimal" + "processing/internal/delivery/http/mocks" + "processing/internal/delivery/http/requestctx" + "processing/internal/domain" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestRegisterHandler(t *testing.T) { + testUserID := uuid.New() + testBalance, _ := decimal.NewFromString("0") + testAccount := &domain.Account{ + ID: testUserID, + Name: "TestUser", + Email: "test@example.com", + Balance: testBalance, + Role: "user", + } + testTokenPair := &domain.TokenPair{ + AccessToken: "test_access_token", + RefreshToken: "test_refresh_token", + ExpiresIn: 900, + } + + tests := []struct { + name string + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешная регистрация", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "TestUser", mock.Anything). + Return(testAccount, nil) + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusCreated, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "account") + assert.Contains(t, response, "tokens") + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "test_access_token", accessCookie.Value) + assert.Equal(t, "/api", accessCookie.Path) + assert.Equal(t, 900, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "test_refresh_token", refreshCookie.Value) + assert.Equal(t, "/auth/refresh", refreshCookie.Path) + assert.Equal(t, 604800, refreshCookie.MaxAge) + }, + }, + { + name: "невалидный JSON", + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустое имя", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "", mock.Anything). + Return(nil, domain.ErrInvalidName) + }, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "имя короче 3 символов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "ab", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "ab", mock.Anything). + Return(nil, domain.ErrInvalidName) + }, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "пустой email", + requestBody: AuthDTO{ + Email: "", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "", "password123", "TestUser", mock.Anything). + Return(nil, domain.ErrInvalidEmail) + }, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "невалидный формат email", + requestBody: AuthDTO{ + Email: "invalid-email", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "invalid-email", "password123", "TestUser", mock.Anything). + Return(nil, domain.ErrInvalidEmail) + }, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "пустой пароль", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "", "TestUser", mock.Anything). + Return(nil, domain.ErrInvalidPassword) + }, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "пароль короче 8 символов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "pass123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "pass123", "TestUser", mock.Anything). + Return(nil, domain.ErrInvalidPassword) + }, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "ошибка при регистрации", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "TestUser", mock.Anything). + Return(nil, errors.New("database error")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "ошибка при автоматическом логине после регистрации", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "TestUser", mock.Anything). + Return(testAccount, nil) + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(nil, errors.New("login failed")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "email с пробелами обрезается", + requestBody: AuthDTO{ + Email: " test@example.com ", + Password: "password123", + Name: " TestUser ", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, " test@example.com ", "password123", " TestUser ", mock.Anything). + Return(testAccount, nil) + authMock.On("Login", mock.Anything, " test@example.com ", "password123", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusCreated, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "account") + assert.Contains(t, response, "tokens") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + + req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + + handler.Register(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestLoginHandler(t *testing.T) { + testTokenPair := &domain.TokenPair{ + AccessToken: "test_access_token", + RefreshToken: "test_refresh_token", + ExpiresIn: 900, + } + + tests := []struct { + name string + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный логин", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "test_access_token", response.AccessToken) + assert.Equal(t, "test_refresh_token", response.RefreshToken) + assert.Equal(t, int64(900), response.ExpiresIn) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "test_access_token", accessCookie.Value) + assert.Equal(t, "/api", accessCookie.Path) + assert.Equal(t, 900, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "test_refresh_token", refreshCookie.Value) + assert.Equal(t, "/auth/refresh", refreshCookie.Path) + assert.Equal(t, 604800, refreshCookie.MaxAge) + }, + }, + { + name: "невалидный JSON", + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустой email", + requestBody: AuthDTO{ + Email: "", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "", "password123", mock.Anything). + Return(nil, domain.ErrInvalidCredentials) + }, + expectedStatusCode: http.StatusUnauthorized, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "невалидный формат email", + requestBody: AuthDTO{ + Email: "invalid-email", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "invalid-email", "password123", mock.Anything). + Return(nil, domain.ErrInvalidCredentials) + }, + expectedStatusCode: http.StatusUnauthorized, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "пустой пароль", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", "", mock.Anything). + Return(nil, domain.ErrInvalidCredentials) + }, + expectedStatusCode: http.StatusUnauthorized, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "пароль короче 8 символов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "pass123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", "pass123", mock.Anything). + Return(nil, domain.ErrInvalidCredentials) + }, + expectedStatusCode: http.StatusUnauthorized, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "ошибка при логине - неверные креды", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(nil, errors.New("invalid credentials")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.NotContains(t, response, "message") + }, + }, + { + name: "email с пробелами обрезается", + requestBody: AuthDTO{ + Email: " test@example.com ", + Password: " password123 ", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, " test@example.com ", " password123 ", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "test_access_token", response.AccessToken) + assert.Equal(t, "test_refresh_token", response.RefreshToken) + }, + }, + { + name: "email только из пробелов", + requestBody: AuthDTO{ + Email: " ", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, " ", "password123", mock.Anything). + Return(nil, domain.ErrInvalidCredentials) + }, + expectedStatusCode: http.StatusUnauthorized, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "error") + }, + }, + { + name: "пароль только из пробелов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: " ", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", " ", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + + handler.Login(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestRefreshHandler(t *testing.T) { + testTokenPair := &domain.TokenPair{ + AccessToken: "new_access_token", + RefreshToken: "new_refresh_token", + ExpiresIn: 900, + } + + tests := []struct { + name string + setupRequest func(*http.Request) + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный refresh через cookie", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "test_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "test_refresh_token", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "new_access_token", response.AccessToken) + assert.Equal(t, "new_refresh_token", response.RefreshToken) + assert.Equal(t, int64(900), response.ExpiresIn) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "new_access_token", accessCookie.Value) + assert.Equal(t, "/api", accessCookie.Path) + assert.Equal(t, 900, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "new_refresh_token", refreshCookie.Value) + assert.Equal(t, "/auth/refresh", refreshCookie.Path) + assert.Equal(t, 604800, refreshCookie.MaxAge) + }, + }, + { + name: "успешный refresh через JSON body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "test_refresh_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "test_refresh_token", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "new_access_token", response.AccessToken) + assert.Equal(t, "new_refresh_token", response.RefreshToken) + }, + }, + { + name: "отсутствует refresh token - нет cookie и body", + setupRequest: func(req *http.Request) { + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "refresh token отсутствует") + }, + }, + { + name: "пустой refresh token в body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "refresh token отсутствует") + }, + }, + { + name: "невалидный JSON", + setupRequest: func(req *http.Request) { + }, + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "refresh token отсутствует") + }, + }, + { + name: "ошибка при refresh", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "invalid_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "invalid_refresh_token", mock.Anything). + Return(nil, errors.New("invalid refresh token")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "приоритет cookie над body", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "cookie_token", + }) + }, + requestBody: map[string]string{ + "refresh_token": "body_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "cookie_token", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "new_access_token", response.AccessToken) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if tt.requestBody != nil { + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + + if tt.setupRequest != nil { + tt.setupRequest(req) + } + + rec := httptest.NewRecorder() + + handler.Refresh(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestLogoutHandler(t *testing.T) { + tests := []struct { + name string + setupRequest func(*http.Request) + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный логаут через cookie", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "test_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "test_refresh_token", mock.Anything). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "", accessCookie.Value) + assert.Equal(t, -1, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "", refreshCookie.Value) + assert.Equal(t, -1, refreshCookie.MaxAge) + }, + }, + { + name: "успешный логаут через JSON body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "test_refresh_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "test_refresh_token", mock.Anything). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + }, + }, + { + name: "отсутствует refresh token - нет cookie и body", + setupRequest: func(req *http.Request) { + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустой refresh token в body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "невалидный JSON", + setupRequest: func(req *http.Request) { + }, + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "ошибка при логауте", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "test_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "test_refresh_token", mock.Anything). + Return(errors.New("database error")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "приоритет cookie над body", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "cookie_token", + }) + }, + requestBody: map[string]string{ + "refresh_token": "body_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "cookie_token", mock.Anything). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if tt.requestBody != nil { + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/auth/logout", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + + if tt.setupRequest != nil { + tt.setupRequest(req) + } + + rec := httptest.NewRecorder() + + handler.Logout(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestLogoutAllHandler(t *testing.T) { + testUserID := uuid.New() + + tests := []struct { + name string + setupContext func(context.Context) context.Context + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный logout всех сессий", + setupContext: func(ctx context.Context) context.Context { + return requestctx.WithIdentity(ctx, requestctx.Identity{UserID: testUserID.String()}) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("LogoutAll", mock.Anything, testUserID). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "", accessCookie.Value) + assert.Equal(t, -1, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "", refreshCookie.Value) + assert.Equal(t, -1, refreshCookie.MaxAge) + }, + }, + { + name: "отсутствует user_id в контексте", + setupContext: func(ctx context.Context) context.Context { + return ctx + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "поле user_id должно быть string") + }, + }, + { + name: "невалидный формат UUID", + setupContext: func(ctx context.Context) context.Context { + return requestctx.WithIdentity(ctx, requestctx.Identity{UserID: "invalid-uuid-format"}) + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустая строка вместо UUID", + setupContext: func(ctx context.Context) context.Context { + return requestctx.WithIdentity(ctx, requestctx.Identity{}) + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "ошибка при LogoutAll в usecase", + setupContext: func(ctx context.Context) context.Context { + return requestctx.WithIdentity(ctx, requestctx.Identity{UserID: testUserID.String()}) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("LogoutAll", mock.Anything, testUserID). + Return(errors.New("database connection error")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "валидный UUID с другим пользователем", + setupContext: func(ctx context.Context) context.Context { + anotherUserID := uuid.New() + return requestctx.WithIdentity(ctx, requestctx.Identity{UserID: anotherUserID.String()}) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("LogoutAll", mock.Anything, mock.AnythingOfType("uuid.UUID")). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + req := httptest.NewRequest(http.MethodPost, "/auth/logout-all", nil) + req.RemoteAddr = "127.0.0.1:1234" + + ctx := context.Background() + if tt.setupContext != nil { + ctx = tt.setupContext(ctx) + } + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + + handler.LogoutAll(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} diff --git a/internal/delivery/http/handlers/health/health_handler.go b/internal/delivery/http/handlers/health/health_handler.go new file mode 100644 index 0000000..207bbbc --- /dev/null +++ b/internal/delivery/http/handlers/health/health_handler.go @@ -0,0 +1,30 @@ +package health + +import ( + "context" + "net/http" + "processing/internal/delivery/http/helpers/httputil" + "time" +) + +type HealthCheck func(context.Context) error + +func Health(w http.ResponseWriter, _ *http.Request) { + if err := httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +func Readiness(checks ...HealthCheck) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + for _, check := range checks { + if err := check(ctx); err != nil { + _ = httputil.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable"}) + return + } + } + _ = httputil.WriteJSON(w, http.StatusOK, map[string]string{"status": "ready"}) + } +} diff --git a/internal/delivery/http/handlers/health/health_test.go b/internal/delivery/http/handlers/health/health_test.go new file mode 100644 index 0000000..3684a38 --- /dev/null +++ b/internal/delivery/http/handlers/health/health_test.go @@ -0,0 +1,48 @@ +package health + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHealth(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + recorder := httptest.NewRecorder() + + Health(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status: got %d, want %d", recorder.Code, http.StatusOK) + } + if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json" { + t.Fatalf("unexpected content type: got %q", contentType) + } + + var response map[string]string + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response["status"] != "ok" { + t.Fatalf("unexpected health status: got %q", response["status"]) + } +} + +func TestReadiness(t *testing.T) { + t.Run("ready", func(t *testing.T) { + rec := httptest.NewRecorder() + Readiness(func(context.Context) error { return nil })(rec, httptest.NewRequest(http.MethodGet, "/health/ready", nil)) + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("dependency unavailable", func(t *testing.T) { + rec := httptest.NewRecorder() + Readiness(func(context.Context) error { return errors.New("down") })(rec, httptest.NewRequest(http.MethodGet, "/health/ready", nil)) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + }) +} diff --git a/internal/delivery/http/handlers/transactions/transaction_handler.go b/internal/delivery/http/handlers/transactions/transaction_handler.go new file mode 100644 index 0000000..0fa41b5 --- /dev/null +++ b/internal/delivery/http/handlers/transactions/transaction_handler.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + "processing/internal/decimal" + httpapp "processing/internal/delivery/http/app" + "processing/internal/delivery/http/helpers/httputil" + "processing/internal/delivery/http/helpers/request" + "processing/internal/delivery/http/requestctx" + "processing/internal/domain" + + "github.com/google/uuid" +) + +type transferDTO struct { + Receiver_id uuid.UUID `json:"receiver_id"` + Amount string `json:"amount"` +} + +type Handler struct { + ts domain.TransactionUsecase + log *slog.Logger +} + +func New(app *httpapp.App) *Handler { + return &Handler{ + ts: app.TransactionUsecase, + log: app.Log, + } +} + +func NewHandler(ts domain.TransactionUsecase, as domain.AccountsUsecase, auth domain.AuthUseCase, log *slog.Logger) *Handler { + return &Handler{ + ts: ts, + log: log, + } +} + +// Transfer хэндлер для оплаты +// POST /transactions +func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + w.Header().Set("Content-Type", "application/json") + ctx := r.Context() + + identity, ok := requestctx.IdentityFrom(ctx) + if !ok { + httputil.WriteError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + + senderID, err := uuid.Parse(identity.UserID) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + key := r.Header.Get("Idempotency-Key") + if err := domain.ValidateIdempotencyKey(key); err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 1) + return + } + + var dto transferDTO + if err := httputil.DecodeJSON(w, r, &dto); err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + amount, err := decimal.NewFromString(dto.Amount) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + transactionID, err := h.ts.Transfer(ctx, senderID, dto.Receiver_id, key, amount) + if err != nil { + if errors.Is(err, domain.ErrIdempotencyConflict) || errors.Is(err, domain.ErrIdempotencyInProgress) { + httputil.WriteError(w, http.StatusConflict, err, 1) + return + } + if errors.Is(err, domain.ErrSameAccount) || errors.Is(err, domain.ErrInvalidAmount) || errors.Is(err, domain.ErrInsufficientFunds) || errors.Is(err, domain.ErrReceiverAccountNotFound) { + httputil.WriteError(w, http.StatusBadRequest, err, 1) + return + } + httputil.WriteError(w, http.StatusInternalServerError, err, 1) + return + } + + if err := httputil.WriteJSON(w, http.StatusCreated, map[string]string{"transaction_id": transactionID}); err != nil { + h.log.Error("[transfer] json encode", "err", err) + } +} + +type transactionDTO struct { + UserID uuid.UUID `json:"user_id"` + IdempotencyKEY string `json:"Idempotency-Key"` +} + +// получение транзакции по id +// GET /transactions/:id +func (h *Handler) GetTransaction(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + id, err := uuid.Parse(r.PathValue("id")) + w.Header().Set("Content-Type", "application/json") + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 1) + return + } + + ctx := r.Context() + identity, ok := requestctx.IdentityFrom(ctx) + if !ok { + httputil.WriteError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + + userID, err := uuid.Parse(identity.UserID) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + key := r.Header.Get("Idempotency-Key") + transaction, err := h.ts.GetTransaction(ctx, id, userID, key) + if err != nil { + if errors.Is(err, domain.ErrAccessDenied) { + httputil.WriteError(w, http.StatusNotFound, err, 0) + return + } + httputil.WriteError(w, http.StatusInternalServerError, err, 1) + return + } + + if err := httputil.WriteJSON(w, http.StatusOK, transaction); err != nil { + h.log.Error("[GetTransaction] json encode", "err", err) + } +} + +// выводит транзакции по фильтрам +// Примеры запросов: +// GET /transactions?sender_id=uuid&from=2024-01-01&limit=20&offset=0 +// GET /transactions?receiver_id=uuid&min_amount=1000 +func (h *Handler) TransactionFilter(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + w.Header().Set("Content-Type", "application/json") + + ctx := r.Context() + identity, ok := requestctx.IdentityFrom(ctx) + if !ok { + httputil.WriteError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + + userID, err := uuid.Parse(identity.UserID) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 0) + return + } + + key := r.Header.Get("Idempotency-Key") + query := r.URL.Query() + filter, err := request.NewTransactionFilter(query) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, err, 1) + return + } + + transactions, err := h.ts.GetTransactionFilter(ctx, filter, userID, key) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, err, 1) + return + } + + if err := httputil.WriteJSON(w, http.StatusOK, transactions); err != nil { + h.log.Error("[TransactionFilter] json encode", "err", err) + } +} diff --git a/internal/delivery/http/handlers/transactions/transaction_test.go b/internal/delivery/http/handlers/transactions/transaction_test.go new file mode 100644 index 0000000..e19c513 --- /dev/null +++ b/internal/delivery/http/handlers/transactions/transaction_test.go @@ -0,0 +1,469 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "processing/internal/decimal" + "processing/internal/delivery/http/mocks" + "processing/internal/delivery/http/requestctx" + "processing/internal/domain" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTransactionTransferHandler(t *testing.T) { + testSenderID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + + tests := []struct { + name string + requestBody interface{} + idempotencyKey string + setupMock func(*mocks.TransactionUsecase, uuid.UUID, uuid.UUID, decimal.Decimal) + expectedStatusCode int + expectError bool + }{ + { + name: "успешный перевод", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "500.50", + }, + idempotencyKey: "test-key-123", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + m.On("Transfer", + mock.Anything, + senderID, + receiverID, + "test-key-123", + amount, + ).Return("test-transaction-id", nil).Once() + }, + expectedStatusCode: http.StatusCreated, + expectError: false, + }, + { + name: "невалидный JSON", + requestBody: `{"invalid json`, + idempotencyKey: "test-key-invalid-json", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + // не вызываем Transfer, т.к. ошибка парсинга раньше + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "невалидный amount формат", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "invalid-amount", + }, + idempotencyKey: "test-key-456", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + // не вызываем Transfer, т.к. ошибка парсинга amount + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "ошибка от usecase", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "1000.00", + }, + idempotencyKey: "test-key-789", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + m.On("Transfer", + mock.Anything, + senderID, + receiverID, + "test-key-789", + amount, + ).Return("", errors.New("недостаточно средств")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + { + name: "пустой idempotency key", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "100.00", + }, + idempotencyKey: "", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + // Transfer не вызывается: обязательный заголовок проверяется раньше. + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "idempotency key с недопустимыми символами", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "100.00", + }, + idempotencyKey: "invalid key", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + // Transfer не вызывается: ключ проверяется раньше. + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "слишком длинный idempotency key", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "100.00", + }, + idempotencyKey: strings.Repeat("a", domain.MaxIdempotencyKeyLength+1), + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + // Transfer не вызывается: ключ проверяется раньше. + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "конфликт idempotency key", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "100.00", + }, + idempotencyKey: "conflict-key", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + m.On("Transfer", + mock.Anything, + senderID, + receiverID, + "conflict-key", + amount, + ).Return("", domain.ErrIdempotencyConflict).Once() + }, + expectedStatusCode: http.StatusConflict, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUsecase := mocks.NewTransactionUsecase(t) + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockAuthUsecase := mocks.NewAuthUseCase(t) + handler := NewHandler(mockUsecase, mockAccountUsecase, mockAuthUsecase, nil) + + var receiverID uuid.UUID + var amount decimal.Decimal + if dto, ok := tt.requestBody.(transferDTO); ok { + receiverID = dto.Receiver_id + amount, _ = decimal.NewFromString(dto.Amount) + } + + tt.setupMock(mockUsecase, testSenderID, receiverID, amount) + + var bodyBytes []byte + var err error + if strBody, ok := tt.requestBody.(string); ok { + bodyBytes = []byte(strBody) + } else { + bodyBytes, err = json.Marshal(tt.requestBody) + require.NoError(t, err) + } + + req := httptest.NewRequest(http.MethodPost, "/transactions", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + if tt.idempotencyKey != "" { + req.Header.Set("Idempotency-Key", tt.idempotencyKey) + } + ctx := requestctx.WithIdentity(context.Background(), requestctx.Identity{UserID: testSenderID.String()}) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + handler.Transfer(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var resp map[string]string + err = json.Unmarshal(rr.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.NotEmpty(t, resp["transaction_id"]) + } + }) + t.Log("\n\n\n") + } +} + +func TestGetTransactionHandler(t *testing.T) { + validTransactionID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174001") + + tests := []struct { + name string + transactionID string + setupMock func(*mocks.TransactionUsecase) + expectedStatusCode int + expectError bool + }{ + { + name: "успешное получение транзакции", + transactionID: validTransactionID.String(), + setupMock: func(m *mocks.TransactionUsecase) { + amount, _ := decimal.NewFromString("500.50") + expectedTransaction := domain.Transaction{ + ID: validTransactionID, + Amount: amount, + Sender_id: testUserID, + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174002"), + Status: domain.StatusCompleted, + Created_at: time.Now(), + } + m.On("GetTransaction", + mock.Anything, + validTransactionID, + testUserID, + "", + ).Return(expectedTransaction, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "невалидный UUID транзакции", + transactionID: "invalid-uuid", + setupMock: func(m *mocks.TransactionUsecase) { + // не вызываем GetTransaction, т.к. ошибка парсинга UUID раньше + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "транзакция не найдена", + transactionID: validTransactionID.String(), + setupMock: func(m *mocks.TransactionUsecase) { + m.On("GetTransaction", + mock.Anything, + validTransactionID, + testUserID, + "", + ).Return(domain.Transaction{}, errors.New("транзакция не найдена")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + { + name: "доступ запрещен к чужой транзакции", + transactionID: validTransactionID.String(), + setupMock: func(m *mocks.TransactionUsecase) { + m.On("GetTransaction", + mock.Anything, + validTransactionID, + testUserID, + "", + ).Return(domain.Transaction{}, domain.ErrAccessDenied).Once() + }, + expectedStatusCode: http.StatusNotFound, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUsecase := mocks.NewTransactionUsecase(t) + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockAuthUsecae := mocks.NewAuthUseCase(t) + handler := NewHandler(mockUsecase, mockAccountUsecase, mockAuthUsecae, nil) + + tt.setupMock(mockUsecase) + + req := httptest.NewRequest(http.MethodGet, "/transactions/"+tt.transactionID, nil) + req.SetPathValue("id", tt.transactionID) + ctx := requestctx.WithIdentity(context.Background(), requestctx.Identity{UserID: testUserID.String()}) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.GetTransaction(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var response domain.Transaction + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "ответ должен быть валидным JSON") + } + }) + } +} + +func TestTransactionFilterHandler(t *testing.T) { + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174001") + validSenderID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174002") + validReceiverID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174003") + amount, _ := decimal.NewFromString("500.50") + amount2, _ := decimal.NewFromString("250") + tests := []struct { + name string + queryParams string + idempotencyKey string + setupMock func(*mocks.TransactionUsecase) + expectedStatusCode int + expectError bool + }{ + { + name: "успешная фильтрация с sender_id", + queryParams: "sender_id=" + validSenderID.String() + "&limit=10&offset=0", + idempotencyKey: "test-key-123", + setupMock: func(m *mocks.TransactionUsecase) { + expectedTransactions := []domain.Transaction{ + { + ID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + Amount: amount, + Sender_id: validSenderID, + Receiver_id: validReceiverID, + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + } + m.On("GetTransactionFilter", + mock.Anything, + mock.MatchedBy(func(filter *domain.TransactionFilter) bool { + return filter.SenderID == validSenderID && + filter.Limit == 10 && + filter.Offset == 0 + }), + testUserID, + "test-key-123", + ).Return(expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "успешная фильтрация с receiver_id и датами", + queryParams: "receiver_id=" + validReceiverID.String() + "&from=2024-01-01&to=2024-12-31&limit=20&offset=5", + idempotencyKey: "test-key-456", + setupMock: func(m *mocks.TransactionUsecase) { + expectedTransactions := []domain.Transaction{} + m.On("GetTransactionFilter", + mock.Anything, + mock.MatchedBy(func(filter *domain.TransactionFilter) bool { + return filter.ReceiverID == validReceiverID && + filter.Limit == 20 && + filter.Offset == 5 + }), + testUserID, + "test-key-456", + ).Return(expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "фильтрация с min_amount и max_amount", + queryParams: "min_amount=100.00&max_amount=1000.00&limit=15&offset=0", + idempotencyKey: "test-key-789", + setupMock: func(m *mocks.TransactionUsecase) { + expectedTransactions := []domain.Transaction{ + { + ID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + Amount: amount2, + Sender_id: validSenderID, + Receiver_id: validReceiverID, + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + } + m.On("GetTransactionFilter", + mock.Anything, + mock.MatchedBy(func(filter *domain.TransactionFilter) bool { + return filter.MinAmount == "100.00" && + filter.MaxAmount == "1000.00" && + filter.Limit == 15 && + filter.Offset == 0 + }), + testUserID, + "test-key-789", + ).Return(expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "невалидный limit параметр", + queryParams: "limit=invalid&offset=0", + setupMock: func(m *mocks.TransactionUsecase) { + // не вызываем GetTransactionFilter, т.к. ошибка парсинга limit + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "невалидный UUID в sender_id", + queryParams: "sender_id=invalid-uuid&limit=10&offset=0", + setupMock: func(m *mocks.TransactionUsecase) { + // не вызываем GetTransactionFilter, т.к. ошибка парсинга UUID + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "ошибка от usecase", + queryParams: "sender_id=" + validSenderID.String() + "&limit=10&offset=0", + idempotencyKey: "test-key-777", + setupMock: func(m *mocks.TransactionUsecase) { + m.On("GetTransactionFilter", + mock.Anything, + mock.Anything, + testUserID, + "test-key-777", + ).Return([]domain.Transaction{}, errors.New("database error")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUsecase := mocks.NewTransactionUsecase(t) + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockAuthUsecae := mocks.NewAuthUseCase(t) + handler := NewHandler(mockUsecase, mockAccountUsecase, mockAuthUsecae, nil) + + tt.setupMock(mockUsecase) + + req := httptest.NewRequest(http.MethodGet, "/transactions?"+tt.queryParams, nil) + if tt.idempotencyKey != "" { + req.Header.Set("Idempotency-Key", tt.idempotencyKey) + } + ctx := requestctx.WithIdentity(context.Background(), requestctx.Identity{UserID: testUserID.String()}) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.TransactionFilter(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var response []domain.Transaction + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "ответ должен быть валидным JSON") + } + }) + } +} diff --git a/internal/delivery/http/helpers/httputil/httputil.go b/internal/delivery/http/helpers/httputil/httputil.go new file mode 100644 index 0000000..bf8804d --- /dev/null +++ b/internal/delivery/http/helpers/httputil/httputil.go @@ -0,0 +1,133 @@ +package httputil + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "processing/internal/domain" + "strings" +) + +const maxJSONBodyBytes = 64 << 10 + +func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error { + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodyBytes) + decoder := json.NewDecoder(r.Body) + + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return err + } + + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("Тело запрос должно содержать ровно один json объект!") + } + return err + } + return nil +} + +func status(id int) string { + m := map[int]string{ + 200: "OK", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 409: "Conflict", + 422: "Unprocessable Entity", + 429: "Too Many Requests", + 500: "Internal Server Error", + } + value, _ := m[id] + return value +} + +// writeError пишет ошибку клиенту. +// flag: 1 - полная ошибка, 0 - только часть +func WriteError( + w http.ResponseWriter, + code int, + err error, + flag int, +) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + + if flag == 1 { + json.NewEncoder(w).Encode(map[string]string{ + "error": status(code), + "message": err.Error(), + }) + return + } + json.NewEncoder(w).Encode(map[string]string{"error": status(code)}) +} + +func WriteAuthError(w http.ResponseWriter, err error) { + switch { + //rate limit + case errors.Is(err, domain.ErrRateLimited): + WriteError(w, http.StatusTooManyRequests, err, 0) + //аккаунт уже есть + case errors.Is(err, domain.ErrAccountAlreadyExist): + WriteError(w, http.StatusConflict, err, 0) + //невалидные данные + case errors.Is(err, domain.ErrInvalidEmail), + errors.Is(err, domain.ErrInvalidPassword), + errors.Is(err, domain.ErrInvalidName): + WriteError(w, http.StatusUnprocessableEntity, err, 0) + //невалидные креды + case errors.Is(err, domain.ErrInvalidCredentials), + errors.Is(err, domain.ErrInvalidRefreshToken), + errors.Is(err, domain.ErrRefreshTokenExpired), + errors.Is(err, domain.ErrRefreshTokenRevoked), + errors.Is(err, domain.ErrRefreshTokenReuse): + WriteError(w, http.StatusUnauthorized, err, 0) + default: + WriteError(w, http.StatusInternalServerError, err, 0) + } +} + +func ClientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err == nil { + return host + } + return strings.Trim(strings.TrimSpace(r.RemoteAddr), "[]") +} + +func WriteJSON(w http.ResponseWriter, code int, v any) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(v); err != nil { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + return err + } + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(code) + _, err := buf.WriteTo(w) + return err +} + +func SetAuthCookie( + w http.ResponseWriter, + path, + name, + token string, + maxage int, +) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: token, + Path: path, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: maxage, + }) +} diff --git a/internal/delivery/http/helpers/httputil/httputil_test.go b/internal/delivery/http/helpers/httputil/httputil_test.go new file mode 100644 index 0000000..c97f208 --- /dev/null +++ b/internal/delivery/http/helpers/httputil/httputil_test.go @@ -0,0 +1,31 @@ +package httputil + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeJSON_Validation(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + body string + }{ + {name: "unknown field", body: `{"known":"ok","unknown":true}`}, + {name: "multiple objects", body: `{"known":"ok"} {"known":"second"}`}, + {name: "too large", body: `{"known":"` + strings.Repeat("a", maxJSONBodyBytes) + `"}`}, + } { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/", strings.NewReader(tt.body)) + rec := httptest.NewRecorder() + var dst struct { + Known string `json:"known"` + } + require.Error(t, DecodeJSON(rec, req, &dst)) + }) + } +} diff --git a/internal/delivery/http/helper.go b/internal/delivery/http/helpers/request/request.go similarity index 83% rename from internal/delivery/http/helper.go rename to internal/delivery/http/helpers/request/request.go index 059fd95..338ba85 100644 --- a/internal/delivery/http/helper.go +++ b/internal/delivery/http/helpers/request/request.go @@ -1,4 +1,4 @@ -package handlers +package request import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" ) -func newTransactionFilter(query url.Values) (*domain.TransactionFilter, error) { +func NewTransactionFilter(query url.Values) (*domain.TransactionFilter, error) { senderID, err := parseUUID(query, "sender_id") if err != nil { return nil, err @@ -68,9 +68,14 @@ func parseUUID(query url.Values, key string) (uuid.UUID, error) { } func parseTime(query url.Values, key string) (time.Time, error) { - t, err := time.Parse("2006-01-02", query.Get(key)) + val := query.Get(key) + if val == "" { + return time.Time{}, nil + } + + t, err := time.Parse("2006-01-02", val) if err != nil { - return time.Time{}, err + return time.Time{}, fmt.Errorf("невалидная дата %s: %w", key, err) } return t, nil diff --git a/internal/delivery/http/jwt/jwt.go b/internal/delivery/http/jwt/jwt.go new file mode 100644 index 0000000..eb9e63d --- /dev/null +++ b/internal/delivery/http/jwt/jwt.go @@ -0,0 +1,130 @@ +package jwtLayer + +import ( + "errors" + "fmt" + "processing/internal/domain" + "processing/internal/infrastructure/config" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +var ( + ErrTokenExpired = domain.ErrTokenExpired + ErrTokenInvalid = domain.ErrTokenInvalid +) + +type Manager struct { + accessSecret []byte + refreshSecret []byte + accessTTL time.Duration + refreshTTL time.Duration + issuer string +} + +func NewManager(cfg config.JWTConfig) *Manager { + return &Manager{ + accessSecret: []byte(cfg.AccessSecret), + refreshSecret: []byte(cfg.RefreshSecret), + accessTTL: cfg.AccessTTL, + refreshTTL: cfg.RefreshTTL, + issuer: cfg.Issuer, + } +} + +func (m *Manager) GenerateTokenPair(userID string, role string) (*domain.TokenPair, error) { + now := time.Now() + accessExpiresAt := now.Add(m.accessTTL) + refreshExpiresAt := now.Add(m.refreshTTL) + + accessClaims := domain.AccessClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + ExpiresAt: jwt.NewNumericDate(accessExpiresAt), + IssuedAt: jwt.NewNumericDate(now), + Issuer: m.issuer, + }, + } + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessSigned, err := accessToken.SignedString(m.accessSecret) + if err != nil { + return nil, fmt.Errorf("подпись access token: %w", err) + } + + jti, err := uuid.NewRandom() + if err != nil { + return nil, fmt.Errorf("генерации JTI: %w", err) + } + + refreshClaims := domain.RefreshClaims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti.String(), + Subject: userID, + ExpiresAt: jwt.NewNumericDate(refreshExpiresAt), + IssuedAt: jwt.NewNumericDate(now), + Issuer: m.issuer, + }, + } + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshSigned, err := refreshToken.SignedString(m.refreshSecret) + if err != nil { + return nil, fmt.Errorf("подпись refresh token: %w", err) + } + + return &domain.TokenPair{ + AccessToken: accessSigned, + RefreshToken: refreshSigned, + ExpiresIn: int64(m.accessTTL.Seconds()), + JTI: jti.String(), + ExpiresAt: refreshExpiresAt, + }, nil +} + +func (m *Manager) ValidateAccessToken(tokenString string) (*domain.AccessClaims, error) { + claims := &domain.AccessClaims{} + if err := m.parse(tokenString, claims, m.accessSecret); err != nil { + return nil, err + } + if claims.UserID == "" || claims.Subject != claims.UserID || claims.Role == "" { + return nil, ErrTokenInvalid + } + + return claims, nil +} + +func (m *Manager) ValidateRefreshToken(tokenString string) (*domain.RefreshClaims, error) { + claims := &domain.RefreshClaims{} + if err := m.parse(tokenString, claims, m.refreshSecret); err != nil { + return nil, err + } + if claims.ID == "" || claims.UserID == "" || claims.Subject != claims.UserID { + return nil, ErrTokenInvalid + } + return claims, nil +} + +func (m *Manager) parse(tokenString string, claims jwt.Claims, secret []byte) error { + token, err := jwt.ParseWithClaims( + tokenString, claims, + func(t *jwt.Token) (interface{}, error) { + return secret, nil + }, + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithIssuer(m.issuer), + jwt.WithExpirationRequired(), + ) + switch { + case errors.Is(err, jwt.ErrTokenExpired): + return ErrTokenExpired + case err != nil: + return fmt.Errorf("%w: %v", ErrTokenInvalid, err) + case !token.Valid: + return ErrTokenInvalid + } + return nil +} diff --git a/internal/delivery/http/jwt/jwt_test.go b/internal/delivery/http/jwt/jwt_test.go new file mode 100644 index 0000000..2cff0e8 --- /dev/null +++ b/internal/delivery/http/jwt/jwt_test.go @@ -0,0 +1,50 @@ +package jwtLayer + +import ( + "errors" + "processing/internal/infrastructure/config" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManagerGenerateAndValidate(t *testing.T) { + manager := NewManager(config.JWTConfig{ + AccessSecret: "access-secret-for-tests", + RefreshSecret: "refresh-secret-for-tests", + AccessTTL: time.Minute, + RefreshTTL: time.Hour, + Issuer: "processing-test", + }) + + pair, err := manager.GenerateTokenPair("user-id", "user") + require.NoError(t, err) + + accessClaims, err := manager.ValidateAccessToken(pair.AccessToken) + require.NoError(t, err) + assert.Equal(t, "user-id", accessClaims.UserID) + assert.Equal(t, "user", accessClaims.Role) + + refreshClaims, err := manager.ValidateRefreshToken(pair.RefreshToken) + require.NoError(t, err) + assert.Equal(t, "user-id", refreshClaims.UserID) + assert.Equal(t, pair.JTI, refreshClaims.ID) +} + +func TestManagerRejectsExpiredToken(t *testing.T) { + manager := NewManager(config.JWTConfig{ + AccessSecret: "access-secret-for-tests", + RefreshSecret: "refresh-secret-for-tests", + AccessTTL: -time.Minute, + RefreshTTL: -time.Minute, + Issuer: "processing-test", + }) + + pair, err := manager.GenerateTokenPair("user-id", "user") + require.NoError(t, err) + _, err = manager.ValidateRefreshToken(pair.RefreshToken) + + assert.True(t, errors.Is(err, ErrTokenExpired)) +} diff --git a/internal/delivery/http/middleware/middleware.go b/internal/delivery/http/middleware/middleware.go new file mode 100644 index 0000000..636d777 --- /dev/null +++ b/internal/delivery/http/middleware/middleware.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "encoding/json" + "errors" + "net/http" + jwtLayer "processing/internal/delivery/http/jwt" + "processing/internal/delivery/http/requestctx" + "strings" +) + +type Auth struct { + jwt *jwtLayer.Manager +} + +func NewAuth(jwt *jwtLayer.Manager) Auth { + return Auth{jwt: jwt} +} + +func (a Auth) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, err := bearerToken(r) + if err != nil { + unauthorized(w, "требуется авторизация") + return + } + + claims, err := a.jwt.ValidateAccessToken(token) + if err != nil { + if errors.Is(err, jwtLayer.ErrTokenExpired) { + unauthorized(w, "токен истёк") + return + } + unauthorized(w, "невалидный токен") + return + } + + ctx := requestctx.WithIdentity(r.Context(), requestctx.Identity{ + UserID: claims.UserID, + Role: claims.Role, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func bearerToken(r *http.Request) (string, error) { + header := r.Header.Get("Authorization") + scheme, token, found := strings.Cut(header, " ") + if !found { + return "", errors.New("заголовок Authorization отсутствует или кэш пуст") + } + + if !strings.EqualFold(scheme, "Bearer") { + return "", errors.New("ожидается схема Bearer") + } + + if token == "" { + return "", errors.New("токен пуст") + } + return token, nil +} + +func unauthorized(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/internal/delivery/http/middleware/middleware_test.go b/internal/delivery/http/middleware/middleware_test.go new file mode 100644 index 0000000..3fbbdab --- /dev/null +++ b/internal/delivery/http/middleware/middleware_test.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + jwtLayer "processing/internal/delivery/http/jwt" + "processing/internal/delivery/http/requestctx" + "processing/internal/infrastructure/config" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthMiddlewarePassesIdentityToHandler(t *testing.T) { + const ( + userID = "123e4567-e89b-12d3-a456-426614174000" + role = "user" + ) + + manager := jwtLayer.NewManager(config.JWTConfig{ + AccessSecret: "test-access-secret", + RefreshSecret: "test-refresh-secret", + AccessTTL: time.Minute, + RefreshTTL: time.Hour, + Issuer: "middleware-test", + }) + tokens, err := manager.GenerateTokenPair(userID, role) + require.NoError(t, err) + + handlerCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + identity, ok := requestctx.IdentityFrom(r.Context()) + require.True(t, ok) + assert.Equal(t, userID, identity.UserID) + assert.Equal(t, role, identity.Role) + w.WriteHeader(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + rec := httptest.NewRecorder() + + NewAuth(manager).Middleware(next).ServeHTTP(rec, req) + + assert.True(t, handlerCalled) + assert.Equal(t, http.StatusNoContent, rec.Code) +} diff --git a/internal/delivery/http/mocks/AccountsUsecase.go b/internal/delivery/http/mocks/AccountsUsecase.go new file mode 100644 index 0000000..56ea0cc --- /dev/null +++ b/internal/delivery/http/mocks/AccountsUsecase.go @@ -0,0 +1,116 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + domain "processing/internal/domain" + + mock "github.com/stretchr/testify/mock" + + uuid "github.com/google/uuid" +) + +// AccountsUsecase is an autogenerated mock type for the AccountsUsecase type +type AccountsUsecase struct { + mock.Mock +} + +// Create provides a mock function with given fields: ctx, acc, ip +func (_m *AccountsUsecase) Create(ctx context.Context, acc *domain.Account, ip string) error { + ret := _m.Called(ctx, acc, ip) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *domain.Account, string) error); ok { + r0 = rf(ctx, acc, ip) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetAccount provides a mock function with given fields: ctx, id +func (_m *AccountsUsecase) GetAccount(ctx context.Context, id uuid.UUID) (*domain.Account, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetAccount") + } + + var r0 *domain.Account + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (*domain.Account, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) *domain.Account); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.Account) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TransactionHistory provides a mock function with given fields: ctx, accountID, limit, offset +func (_m *AccountsUsecase) TransactionHistory(ctx context.Context, accountID uuid.UUID, limit int, offset int) (int, []domain.Transaction, error) { + ret := _m.Called(ctx, accountID, limit, offset) + + if len(ret) == 0 { + panic("no return value specified for TransactionHistory") + } + + var r0 int + var r1 []domain.Transaction + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int, int) (int, []domain.Transaction, error)); ok { + return rf(ctx, accountID, limit, offset) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int, int) int); ok { + r0 = rf(ctx, accountID, limit, offset) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int, int) []domain.Transaction); ok { + r1 = rf(ctx, accountID, limit, offset) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]domain.Transaction) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, uuid.UUID, int, int) error); ok { + r2 = rf(ctx, accountID, limit, offset) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewAccountsUsecase creates a new instance of AccountsUsecase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAccountsUsecase(t interface { + mock.TestingT + Cleanup(func()) +}) *AccountsUsecase { + mock := &AccountsUsecase{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/delivery/http/mocks/AuthUseCase.go b/internal/delivery/http/mocks/AuthUseCase.go new file mode 100644 index 0000000..3074f09 --- /dev/null +++ b/internal/delivery/http/mocks/AuthUseCase.go @@ -0,0 +1,157 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + domain "processing/internal/domain" + + mock "github.com/stretchr/testify/mock" + + uuid "github.com/google/uuid" +) + +// AuthUseCase is an autogenerated mock type for the AuthUseCase type +type AuthUseCase struct { + mock.Mock +} + +// Login provides a mock function with given fields: ctx, email, password, ip +func (_m *AuthUseCase) Login(ctx context.Context, email string, password string, ip string) (*domain.TokenPair, error) { + ret := _m.Called(ctx, email, password, ip) + + if len(ret) == 0 { + panic("no return value specified for Login") + } + + var r0 *domain.TokenPair + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*domain.TokenPair, error)); ok { + return rf(ctx, email, password, ip) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *domain.TokenPair); ok { + r0 = rf(ctx, email, password, ip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.TokenPair) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = rf(ctx, email, password, ip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Logout provides a mock function with given fields: ctx, refreshToken, ip +func (_m *AuthUseCase) Logout(ctx context.Context, refreshToken string, ip string) error { + ret := _m.Called(ctx, refreshToken, ip) + + if len(ret) == 0 { + panic("no return value specified for Logout") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, refreshToken, ip) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// LogoutAll provides a mock function with given fields: ctx, userID +func (_m *AuthUseCase) LogoutAll(ctx context.Context, userID uuid.UUID) error { + ret := _m.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for LogoutAll") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) error); ok { + r0 = rf(ctx, userID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Refresh provides a mock function with given fields: ctx, refreshToken, ip +func (_m *AuthUseCase) Refresh(ctx context.Context, refreshToken string, ip string) (*domain.TokenPair, error) { + ret := _m.Called(ctx, refreshToken, ip) + + if len(ret) == 0 { + panic("no return value specified for Refresh") + } + + var r0 *domain.TokenPair + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*domain.TokenPair, error)); ok { + return rf(ctx, refreshToken, ip) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *domain.TokenPair); ok { + r0 = rf(ctx, refreshToken, ip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.TokenPair) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, refreshToken, ip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Register provides a mock function with given fields: ctx, email, password, name, ip +func (_m *AuthUseCase) Register(ctx context.Context, email string, password string, name string, ip string) (*domain.Account, error) { + ret := _m.Called(ctx, email, password, name, ip) + + if len(ret) == 0 { + panic("no return value specified for Register") + } + + var r0 *domain.Account + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*domain.Account, error)); ok { + return rf(ctx, email, password, name, ip) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) *domain.Account); ok { + r0 = rf(ctx, email, password, name, ip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.Account) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok { + r1 = rf(ctx, email, password, name, ip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewAuthUseCase creates a new instance of AuthUseCase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuthUseCase(t interface { + mock.TestingT + Cleanup(func()) +}) *AuthUseCase { + mock := &AuthUseCase{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/delivery/http/mocks/TransactionUsecase.go b/internal/delivery/http/mocks/TransactionUsecase.go new file mode 100644 index 0000000..fb46819 --- /dev/null +++ b/internal/delivery/http/mocks/TransactionUsecase.go @@ -0,0 +1,118 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + decimal "processing/internal/decimal" + domain "processing/internal/domain" + + mock "github.com/stretchr/testify/mock" + + uuid "github.com/google/uuid" +) + +// TransactionUsecase is an autogenerated mock type for the TransactionUsecase type +type TransactionUsecase struct { + mock.Mock +} + +// GetTransaction provides a mock function with given fields: ctx, transactionID, userID, key +func (_m *TransactionUsecase) GetTransaction(ctx context.Context, transactionID uuid.UUID, userID uuid.UUID, key string) (domain.Transaction, error) { + ret := _m.Called(ctx, transactionID, userID, key) + + if len(ret) == 0 { + panic("no return value specified for GetTransaction") + } + + var r0 domain.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string) (domain.Transaction, error)); ok { + return rf(ctx, transactionID, userID, key) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string) domain.Transaction); ok { + r0 = rf(ctx, transactionID, userID, key) + } else { + r0 = ret.Get(0).(domain.Transaction) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID, string) error); ok { + r1 = rf(ctx, transactionID, userID, key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTransactionFilter provides a mock function with given fields: ctx, t, userID, key +func (_m *TransactionUsecase) GetTransactionFilter(ctx context.Context, t *domain.TransactionFilter, userID uuid.UUID, key string) ([]domain.Transaction, error) { + ret := _m.Called(ctx, t, userID, key) + + if len(ret) == 0 { + panic("no return value specified for GetTransactionFilter") + } + + var r0 []domain.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *domain.TransactionFilter, uuid.UUID, string) ([]domain.Transaction, error)); ok { + return rf(ctx, t, userID, key) + } + if rf, ok := ret.Get(0).(func(context.Context, *domain.TransactionFilter, uuid.UUID, string) []domain.Transaction); ok { + r0 = rf(ctx, t, userID, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]domain.Transaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *domain.TransactionFilter, uuid.UUID, string) error); ok { + r1 = rf(ctx, t, userID, key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Transfer provides a mock function with given fields: ctx, sender_id, receiver_id, key, amount +func (_m *TransactionUsecase) Transfer(ctx context.Context, sender_id uuid.UUID, receiver_id uuid.UUID, key string, amount decimal.Decimal) (string, error) { + ret := _m.Called(ctx, sender_id, receiver_id, key, amount) + + if len(ret) == 0 { + panic("no return value specified for Transfer") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string, decimal.Decimal) (string, error)); ok { + return rf(ctx, sender_id, receiver_id, key, amount) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string, decimal.Decimal) string); ok { + r0 = rf(ctx, sender_id, receiver_id, key, amount) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID, string, decimal.Decimal) error); ok { + r1 = rf(ctx, sender_id, receiver_id, key, amount) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewTransactionUsecase creates a new instance of TransactionUsecase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTransactionUsecase(t interface { + mock.TestingT + Cleanup(func()) +}) *TransactionUsecase { + mock := &TransactionUsecase{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/delivery/http/requestctx/context.go b/internal/delivery/http/requestctx/context.go new file mode 100644 index 0000000..2648c09 --- /dev/null +++ b/internal/delivery/http/requestctx/context.go @@ -0,0 +1,22 @@ +package requestctx + +import "context" + +// Identity содержит данные аутентифицированного пользователя из HTTP-запроса. +type Identity struct { + UserID string + Role string +} + +type identityKey struct{} + +// WithIdentity возвращает копию ctx с данными аутентифицированного пользователя. +func WithIdentity(ctx context.Context, identity Identity) context.Context { + return context.WithValue(ctx, identityKey{}, identity) +} + +// IdentityFrom возвращает данные аутентифицированного пользователя из ctx. +func IdentityFrom(ctx context.Context) (Identity, bool) { + identity, ok := ctx.Value(identityKey{}).(Identity) + return identity, ok +} diff --git a/internal/delivery/http/router/router.go b/internal/delivery/http/router/router.go new file mode 100644 index 0000000..c3bc870 --- /dev/null +++ b/internal/delivery/http/router/router.go @@ -0,0 +1,37 @@ +package router + +import ( + "net/http" + + httpapp "processing/internal/delivery/http/app" + accountshandlers "processing/internal/delivery/http/handlers/accounts" + authhandlers "processing/internal/delivery/http/handlers/auth" + httphealth "processing/internal/delivery/http/handlers/health" + transactionhandlers "processing/internal/delivery/http/handlers/transactions" + "processing/internal/delivery/http/middleware" +) + +func New(app *httpapp.App, auth middleware.Auth, checks ...httphealth.HealthCheck) *http.ServeMux { + mux := http.NewServeMux() + + mux.HandleFunc("GET /health", httphealth.Health) + mux.HandleFunc("GET /health/live", httphealth.Health) + mux.Handle("GET /health/ready", httphealth.Readiness(checks...)) + + authHandler := authhandlers.New(app) + accountsHandler := accountshandlers.New(app) + transactionsHandler := transactionhandlers.New(app) + + mux.HandleFunc("POST /auth/register", authHandler.Register) + mux.HandleFunc("POST /auth/login", authHandler.Login) + mux.HandleFunc("POST /auth/refresh", authHandler.Refresh) + mux.Handle("POST /auth/logout", auth.Middleware(http.HandlerFunc(authHandler.Logout))) + mux.Handle("POST /auth/logout-all", auth.Middleware(http.HandlerFunc(authHandler.LogoutAll))) + mux.Handle("GET /accounts/{id}", auth.Middleware(http.HandlerFunc(accountsHandler.GetAccount))) + mux.Handle("GET /accounts/{id}/transactions", auth.Middleware(http.HandlerFunc(accountsHandler.AccountTransactions))) + mux.Handle("POST /transactions", auth.Middleware(http.HandlerFunc(transactionsHandler.Transfer))) + mux.Handle("GET /transactions/{id}", auth.Middleware(http.HandlerFunc(transactionsHandler.GetTransaction))) + mux.Handle("GET /transactions", auth.Middleware(http.HandlerFunc(transactionsHandler.TransactionFilter))) + + return mux +} diff --git a/internal/delivery/http/transaction_handler.go b/internal/delivery/http/transaction_handler.go deleted file mode 100644 index 23fe5a6..0000000 --- a/internal/delivery/http/transaction_handler.go +++ /dev/null @@ -1,119 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "processing/internal/decimal" - "processing/internal/usecase" - - "github.com/google/uuid" -) - -type handler struct { - service *usecase.TransferService -} - -func NewHandler(transferService *usecase.TransferService) *handler { - return &handler{service: transferService} -} - -type transferDTO struct { - Sender_id uuid.UUID `json:"sender_id"` - Receiver_id uuid.UUID `json:"receiver_id"` - Amount string `json:"amount"` -} - -// Transfer хэндлер для отправки транзакции платежа -// POST /transactions -func (h *handler) Transfer(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - w.Header().Set("Content-Type", "application/json") - ctx := r.Context() - - key := r.Header.Get("Idempotency-Key") - var dto transferDTO - if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { - writeError(w, 400, err, 0) - return - } - - amount, err := decimal.NewFromString(dto.Amount) - if err != nil { - writeError(w, 500, err, 0) - return - } - - if err := h.service.Transfer(ctx, dto.Sender_id, dto.Receiver_id, key, amount); err != nil { - writeError(w, 500, err, 1) - return - } -} - -type transactionDTO struct { - UserID uuid.UUID `json:"user_id"` - IdempotencyKEY string `json:"Idempotency-Key"` -} - -// получение транзакции по id -// GET /transactions/:id -func (h *handler) GetTransaction(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - w.Header().Set("Content-Type", "application/json") - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeError(w, 400, err, 0) - return - } - - var dto transactionDTO - if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { - writeError(w, 500, err, 0) - return - } - ctx := r.Context() - - transaction, err := h.service.GetTransaction(ctx, id, dto.UserID, dto.IdempotencyKEY) - if err != nil { - writeError(w, 500, err, 1) - return - } - - if err := json.NewEncoder(w).Encode(transaction); err != nil { - writeError(w, 500, err, 0) - return - } -} - -// выводит транзакции по фильтрам -// Примеры запросов: -// GET /transactions?sender_id=uuid&from=2024-01-01&limit=20&offset=0 -// GET /transactions?receiver_id=uuid&min_amount=1000 -func (h *handler) TransactionFilter(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - w.Header().Set("Content-Type", "application/json") - - var dto transactionDTO - if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { - writeError(w, 400, err, 0) - return - } - - ctx := r.Context() - query := r.URL.Query() - filter, err := newTransactionFilter(query) - if err != nil { - writeError(w, 400, err, 1) - return - } - - transactions, err := h.service.GetTransactionFilter(ctx, filter, dto.UserID, dto.IdempotencyKEY) - if err != nil { - writeError(w, 500, err, 1) - return - } - - if err := json.NewEncoder(w).Encode(transactions); err != nil { - writeError(w, 500, err, 0) - return - } -} diff --git a/internal/domain/account.go b/internal/domain/account.go index 4188b5a..b234760 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -1 +1,36 @@ -package domain +package domain + +import ( + "context" + "fmt" + "processing/internal/decimal" + + "github.com/google/uuid" +) + +var ( + RoleUser = "user" +) + +type Account struct { + ID uuid.UUID `json:"id"` + Name string `json:"username"` + Email string `json:"email"` + Balance decimal.Decimal `json:"balance"` + PasswordHash string `json:"-"` + Role string `json:"role"` +} + +func NewAccount(name string, balance decimal.Decimal) (*Account, error) { + id, err := uuid.NewUUID() + if err != nil { + return nil, fmt.Errorf("создание uuid: %w", err) + } + return &Account{ID: id, Name: name, Balance: balance}, nil +} + +type AccountsUsecase interface { + Create(ctx context.Context, acc *Account, ip string) error + GetAccount(ctx context.Context, id uuid.UUID) (*Account, error) + TransactionHistory(ctx context.Context, accountID uuid.UUID, limit, offset int) (int, []Transaction, error) +} diff --git a/internal/domain/cache.go b/internal/domain/cache.go index 921bd3e..f0b6be8 100644 --- a/internal/domain/cache.go +++ b/internal/domain/cache.go @@ -1,15 +1,13 @@ -package domain - -import ( - "context" - "time" - - "github.com/google/uuid" -) - -// Cache - рейтлимитит запросы пользователя -// и кэширует запросы пользователей для последующей дедупликации -type Cache interface { - CheckRateLimit(ctx context.Context, userID uuid.UUID) error - IdempotencyCheck(ctx context.Context, key string, limit int64, TTL time.Duration) error -} +package domain + +import ( + "context" + "time" +) + +// Cache - рейтлимитит запросы пользователя +// и кэширует запросы пользователей для последующей дедупликации +type Cache interface { + CheckRateLimit(ctx context.Context, id string) error + IdempotencyCheck(ctx context.Context, key string, TTL time.Duration) error +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 14ef62d..826ced4 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -1,9 +1,39 @@ -package domain - -import "errors" - -var ( - ErrInsufficientFunds = errors.New("недостаточно средств") - ErrInvalidAmount = errors.New("сумма должна быть положительной") - ErrSameAccount = errors.New("отправитель и получатель должны быть разными") +package domain + +import "errors" + +var ( + ErrAccessDenied = errors.New("доступ запрещен") + ErrInsufficientFunds = errors.New("недостаточно средств") + ErrInvalidAmount = errors.New("сумма должна быть положительной") + ErrSameAccount = errors.New("отправитель и получатель должны быть разными") + ErrReceiverAccountNotFound = errors.New("receiver аккаунт не найден") + ErrAccountNotFound = errors.New("аккаунт не найден") +) + +var ( + ErrSaveRefreshToken = errors.New("ошибка сохранения рефреш токена") + ErrRefreshTokenNotFound = errors.New("refresh токен не найден") + ErrUserNotFound = errors.New("связаннй с токеном юзер не найден") + ErrRefreshTokenRevoked = errors.New("refresh токен отозван") + ErrRefreshTokenExpired = errors.New("refresh токен истек") + ErrInvalidCredentials = errors.New("неверные учетные данные") + ErrInvalidRefreshToken = errors.New("невалидный refresh токен") + ErrTokenExpired = errors.New("токен истёк") + ErrTokenInvalid = errors.New("токен невалиден") +) + +var ( + ErrAccountAlreadyExist = errors.New("аккаунт с таким email уже существует") + ErrAccountBlocked = errors.New("аккаунт заблокирован") + ErrRateLimited = errors.New("превышен лимит запросов") + ErrDuplicateRequest = errors.New("повторный запрос") + ErrIdempotencyKeyRequired = errors.New("заголовок Idempotency-Key обязателен") + ErrInvalidIdempotencyKey = errors.New("невалидный Idempotency-Key") + ErrIdempotencyConflict = errors.New("Idempotency-Key уже использован с другими параметрами") + ErrIdempotencyInProgress = errors.New("перевод с таким Idempotency-Key ещё выполняется") + ErrRefreshTokenReuse = errors.New("повторное использование refresh токена") + ErrInvalidEmail = errors.New("невалидный email") + ErrInvalidPassword = errors.New("невалидный пароль") + ErrInvalidName = errors.New("невалидное имя") ) diff --git a/internal/domain/jwt.go b/internal/domain/jwt.go new file mode 100644 index 0000000..3abad2d --- /dev/null +++ b/internal/domain/jwt.go @@ -0,0 +1,43 @@ +package domain + +import ( + "context" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type AuthUseCase interface { + Register(ctx context.Context, email, password, name string, ip string) (*Account, error) + Login(ctx context.Context, email, password string, ip string) (*TokenPair, error) + Refresh(ctx context.Context, refreshToken string, ip string) (*TokenPair, error) + Logout(ctx context.Context, refreshToken string, ip string) error + LogoutAll(ctx context.Context, userID uuid.UUID) error +} + +type RefreshSession struct { + UserID uuid.UUID + FamilyID uuid.UUID + Revoked bool + ExpiresAt time.Time +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + JTI string `json:"-"` + ExpiresAt time.Time `json:"-"` +} + +type AccessClaims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type RefreshClaims struct { + UserID string `json:"user_id"` + jwt.RegisteredClaims +} diff --git a/internal/domain/repositories.go b/internal/domain/repositories.go index f7fe91e..2d755e3 100644 --- a/internal/domain/repositories.go +++ b/internal/domain/repositories.go @@ -2,9 +2,7 @@ package domain import ( "context" - "fmt" "processing/internal/decimal" - "time" "github.com/google/uuid" @@ -18,79 +16,44 @@ const ( StatusFailed TransactionStatus = "failed" ) -// Интерфейсы репозиториев (контракты для infrastructure слоя) type TransactionStorage interface { Transaction(ctx context.Context, tx *Transaction) error UpdateStatus(ctx context.Context, tx *Transaction, status TransactionStatus) error + TryCreateIdempotency(ctx context.Context, record *TransferIdempotency) (bool, error) + GetIdempotency(ctx context.Context, senderID uuid.UUID, key string) (TransferIdempotency, error) + CompleteIdempotency(ctx context.Context, senderID uuid.UUID, key string, transactionID uuid.UUID) error GetByID(ctx context.Context, transactionID uuid.UUID) (Transaction, error) GetTransactions(ctx context.Context, filter TransactionFilter) ([]Transaction, error) + TotalTransactions(ctx context.Context, userID uuid.UUID) (int, error) } type AccountsStorage interface { Create(ctx context.Context, ac *Account) error + LockForTransfer(ctx context.Context, firstID, secondID uuid.UUID) error GetById(ctx context.Context, id uuid.UUID) (*Account, error) + GetByEmail(ctx context.Context, email string) (*Account, error) Sub(ctx context.Context, sender_id uuid.UUID, amount decimal.Decimal) error Add(ctx context.Context, receiver_id uuid.UUID, amount decimal.Decimal) error } +type TokenStorage interface { + SaveRefreshToken(ctx context.Context, jti string, userID, familyID uuid.UUID, expiresAt time.Time) error + GetRefreshToken(ctx context.Context, jti string) (*RefreshSession, error) + RevokeRefreshToken(ctx context.Context, jti string) error + RevokeTokenFamily(ctx context.Context, familyID uuid.UUID) error + RevokeAllUserTokens(ctx context.Context, userID uuid.UUID) error +} + // UnitOfWork управляет аккаунтами, транзакциями и транзакциями самой бд type UnitOfWork interface { Accounts() AccountsStorage Transactions() TransactionStorage + Tokens() TokenStorage Commit() error Rollback() error } -// TxUOW нужен для создания транзакции (фабрика) type TxUOW interface { + // TxUOW нужен для создания транзакции (фабрика) NewTX(ctx context.Context) (UnitOfWork, error) } - -// Доменные сущности -type Transaction struct { - ID uuid.UUID `json:"-"` - Amount decimal.Decimal `json:"amount"` - Sender_id uuid.UUID `json:"sender_id"` - Receiver_id uuid.UUID `json:"receiver_id"` - Status TransactionStatus `json:"status"` - Created_at time.Time `json:"created_at"` -} - -type Account struct { - ID uuid.UUID `json:"account_id"` - Name string `json:"name"` - Balance decimal.Decimal `json:"balance"` -} - -// Фабричные методы для создания доменных сущностей -func NewTransaction(amount decimal.Decimal, sender_id uuid.UUID, receiver_id uuid.UUID) (*Transaction, error) { - id, err := uuid.NewUUID() - if err != nil { - return nil, fmt.Errorf("создание uuid: %w", err) - } - return &Transaction{ - ID: id, - Amount: amount, - Sender_id: sender_id, - Receiver_id: receiver_id, - }, nil -} - -func NewAccount(name string, balance decimal.Decimal) (*Account, error) { - id, err := uuid.NewUUID() - if err != nil { - return nil, fmt.Errorf("создание uuid: %w", err) - } - return &Account{ID: id, Name: name, Balance: balance}, nil -} - -type TransactionFilter struct { - SenderID uuid.UUID - ReceiverID uuid.UUID - MinAmount string - MaxAmount string - From time.Time - To time.Time - Limit int - Offset int -} diff --git a/internal/domain/transactions.go b/internal/domain/transactions.go index 45c579f..fa7a12c 100644 --- a/internal/domain/transactions.go +++ b/internal/domain/transactions.go @@ -1,28 +1,91 @@ -package domain - +package domain + import ( + "context" + "fmt" "processing/internal/decimal" - + "time" + "github.com/google/uuid" ) -// validateTransferRequest - валидирует реквест, проверяет достаточно ли денег на балансе сендера -// не является ли получатель отправителем, положительная ли сумма -func ValidateTransferRequest( - sender uuid.UUID, - receiver uuid.UUID, - sender_balance decimal.Decimal, - amount decimal.Decimal) error { - if sender == receiver { - return ErrSameAccount - } +const MaxIdempotencyKeyLength = 128 + +type IdempotencyStatus string + +const ( + IdempotencyStatusProcessing IdempotencyStatus = "processing" + IdempotencyStatusCompleted IdempotencyStatus = "completed" +) - if !amount.IsPositive() { - return ErrInvalidAmount +type TransferIdempotency struct { + SenderID uuid.UUID + Key string + RequestFingerprint string + Status IdempotencyStatus + TransactionID *uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time +} + +func ValidateIdempotencyKey(key string) error { + if key == "" { + return ErrIdempotencyKeyRequired + } + if len(key) > MaxIdempotencyKeyLength { + return ErrInvalidIdempotencyKey } - if sender_balance.Compare(amount) == -1 { - return ErrInsufficientFunds + for i := 0; i < len(key); i++ { + char := key[i] + allowed := char >= 'a' && char <= 'z' || + char >= 'A' && char <= 'Z' || + char >= '0' && char <= '9' || + char == '.' || char == '_' || char == ':' || char == '-' + if !allowed { + return ErrInvalidIdempotencyKey + } } + return nil } + +type TransactionUsecase interface { + Transfer(ctx context.Context, sender_id, receiver_id uuid.UUID, key string, amount decimal.Decimal) (string, error) + GetTransaction(ctx context.Context, transactionID, userID uuid.UUID, key string) (Transaction, error) + GetTransactionFilter(ctx context.Context, t *TransactionFilter, userID uuid.UUID, key string) ([]Transaction, error) +} + +type Transaction struct { + ID uuid.UUID `json:"-"` + Amount decimal.Decimal `json:"amount"` + Sender_id uuid.UUID `json:"sender_id"` + Receiver_id uuid.UUID `json:"receiver_id"` + Status TransactionStatus `json:"status"` + Created_at time.Time `json:"created_at"` +} + +func NewTransaction(amount decimal.Decimal, sender_id uuid.UUID, receiver_id uuid.UUID) (*Transaction, error) { + id, err := uuid.NewUUID() + if err != nil { + return nil, fmt.Errorf("создание uuid: %w", err) + } + return &Transaction{ + ID: id, + Amount: amount, + Sender_id: sender_id, + Receiver_id: receiver_id, + }, nil +} + +type TransactionFilter struct { + AccountID uuid.UUID + SenderID uuid.UUID + ReceiverID uuid.UUID + MinAmount string + MaxAmount string + From time.Time + To time.Time + Limit int + Offset int +} diff --git a/internal/infrastructure/cache/redis.go b/internal/infrastructure/cache/redis.go index f63f4bd..8f1f2b7 100644 --- a/internal/infrastructure/cache/redis.go +++ b/internal/infrastructure/cache/redis.go @@ -2,86 +2,131 @@ package cache import ( "context" - "errors" "fmt" - "log/slog" + "processing/internal/domain" "time" - "github.com/google/uuid" "github.com/redis/go-redis/v9" ) -var ( - ErrRateLimitExceed = errors.New("превышен лимит запросов") - ErrDupRequest = errors.New("запрос дубликат") +// Lua скрипт для sliding window rate limiting +// KEYS[1] - ключ для sorted set +// ARGV[1] - текущее время (timestamp) +// ARGV[2] - окно времени в секундах +// ARGV[3] - лимит запросов +// ARGV[4] - уникальный идентификатор запроса +var rateLimitScript = redis.NewScript( + ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local limit = tonumber(ARGV[3]) + local request_id = ARGV[4] + local min_time = now - window + redis.call('ZREMRANGEBYSCORE', key, '-inf', min_time) + + local current = redis.call('ZCARD', key) + + if current >= limit then + return 0 + end + redis.call('ZADD', key, now, request_id) + redis.call('EXPIRE', key, window + 10) + return 1 +`, ) type Redis struct { - client *redis.Client - log *slog.Logger + client *redis.Client + rateLimitMin int64 + rateLimitHour int64 + rateLimitDay int64 } -func NewRedis(addr string, log *slog.Logger) *Redis { +type NewRedisOptions struct { + Addr string + Username string + Password string + DB int + RateLimitMin int64 + RateLimitHour int64 + RateLimitDay int64 +} + +func NewRedis(opts NewRedisOptions) *Redis { c := redis.NewClient(&redis.Options{ - Addr: addr, + Addr: opts.Addr, + Username: opts.Username, + Password: opts.Password, + DB: opts.DB, }) - return &Redis{client: c, log: log} + return &Redis{ + client: c, + rateLimitMin: opts.RateLimitMin, + rateLimitHour: opts.RateLimitHour, + rateLimitDay: opts.RateLimitDay, + } +} + +func (redis *Redis) Ping(ctx context.Context) error { + return redis.client.Ping(ctx).Err() +} + +func (redis *Redis) Close() error { + return redis.client.Close() } -// IdempotencyCheck - функция счётчик, проверяет не был ли уже такой запрос от пользователя -func (redis *Redis) IdempotencyCheck(ctx context.Context, key string, limit int64, TTL time.Duration) error { - count, err := redis.client.Incr(ctx, key).Result() +// IdempotencyCheck добавляет идемпотентности операции, проверяет не был ли уже такой запрос от ключа +// Атомарно устанавливает флаг на TTL. Повторный вызов с тем же ключом вернет ErrDupRequest +func (redis *Redis) IdempotencyCheck(ctx context.Context, key string, TTL time.Duration) error { + set, err := redis.client.SetNX(ctx, key, 1, TTL).Result() if err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) return err } - if count == 1 { - redis.client.Expire(ctx, key, TTL) - } - - if count > limit { - redis.log.InfoContext(ctx, "получен запрос дубликат", "err", err) - return ErrDupRequest + if !set { + return domain.ErrDuplicateRequest } - return nil } // CheckRateLimit ограничивает запросы от пользователя -func (redis *Redis) CheckRateLimit(ctx context.Context, userID uuid.UUID) error { - if err := redis.checkWindow(ctx, userID, 5, time.Minute, "min"); err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) +// принимает контекст и какой то id(user_id, ip, etc..) +func (redis *Redis) CheckRateLimit(ctx context.Context, id string) error { + if err := redis.checkWindow(ctx, id, redis.rateLimitMin, time.Minute, "min"); err != nil { return err } - if err := redis.checkWindow(ctx, userID, 60, time.Hour, "hour"); err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) + if err := redis.checkWindow(ctx, id, redis.rateLimitHour, time.Hour, "hour"); err != nil { return err } - if err := redis.checkWindow(ctx, userID, 200, 24*time.Hour, "day"); err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) + if err := redis.checkWindow(ctx, id, redis.rateLimitDay, 24*time.Hour, "day"); err != nil { return err } return nil } -func (redis *Redis) checkWindow(ctx context.Context, userID uuid.UUID, limit int64, window time.Duration, suffix string) error { - key := fmt.Sprintf("ratelimit:%s:%s", userID, suffix) +func (redis *Redis) checkWindow(ctx context.Context, id string, limit int64, window time.Duration, suffix string) error { + key := fmt.Sprintf("ratelimit:%s:%s", id, suffix) + now := time.Now().Unix() + windowSeconds := int64(window.Seconds()) + requestID := fmt.Sprintf("%d-%d", now, time.Now().UnixNano()) - count, err := redis.client.Incr(ctx, key).Result() + result, err := rateLimitScript.Run(ctx, redis.client, []string{key}, now, windowSeconds, limit, requestID).Result() if err != nil { - return err + return fmt.Errorf("ошибка выполнения lua скрипта: %w", err) } - if count == 1 { - redis.client.Expire(ctx, key, window) + allowed, ok := result.(int64) + if !ok { + return fmt.Errorf("неожиданный тип результата из lua скрипта") } - if count > limit { - return ErrRateLimitExceed + if allowed == 0 { + return domain.ErrRateLimited } + return nil } diff --git a/internal/infrastructure/cache/redis_test.go b/internal/infrastructure/cache/redis_test.go index 6e67ca3..e57fd9f 100644 --- a/internal/infrastructure/cache/redis_test.go +++ b/internal/infrastructure/cache/redis_test.go @@ -1,136 +1,129 @@ -package cache - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/google/uuid" -) - -func TestIdempotencyCheck(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - - client := NewRedis(mr.Addr(), logger) - key := "somekey" - if err := client.IdempotencyCheck(context.Background(), key, 1, 24*time.Hour); err != nil { - t.Log(err) - return - } - t.Log("запрос уникальный") - if err := client.IdempotencyCheck(context.Background(), key, 1, 24*time.Hour); err != nil { - t.Log(err) - } -} - -func TestRedisMinutes(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - - client := NewRedis(mr.Addr(), logger) - userID, _ := uuid.NewUUID() - for range 5 { - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - } - - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(time.Minute) - t.Log("промотали время вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - return - } - t.Log("успех") -} - -func TestRedisHours(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - client := NewRedis(mr.Addr(), logger) - userID, _ := uuid.NewUUID() - - for range 60 { - mr.FastForward(time.Minute) - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - } - t.Log("Отослали 60 запросов") - - t.Log("отслыаем еще один запрос") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(time.Hour) - t.Log("промотали время на 1 час вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - return - } - t.Log("успех") -} - -func TestRedisDay(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - client := NewRedis(mr.Addr(), logger) - userID, _ := uuid.NewUUID() - - for range 200 { - mr.FastForward(time.Minute) - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - } - t.Log("Отослали 200 запросов") - - t.Log("отслыаем еще один запрос") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(time.Hour) - t.Log("промотали время на 1 час вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(24 * time.Hour) - t.Log("промотали время на 24 часа вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - return - } - t.Log("успех") -} +package cache + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/google/uuid" +) + +func TestIdempotencyCheck(t *testing.T) { + mr := miniredis.RunT(t) + + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + key := "somekey" + if err := client.IdempotencyCheck(context.Background(), key, 24*time.Hour); err != nil { + t.Log(err) + return + } + t.Log("запрос уникальный") + if err := client.IdempotencyCheck(context.Background(), key, 24*time.Hour); err != nil { + t.Log(err) + } +} + +func TestRedisMinutes(t *testing.T) { + mr := miniredis.RunT(t) + + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + userID, _ := uuid.NewUUID() + for range 5 { + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + } + + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(time.Minute) + t.Log("промотали время вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + return + } + t.Log("успех") +} + +func TestRedisHours(t *testing.T) { + mr := miniredis.RunT(t) + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + userID, _ := uuid.NewUUID() + + for range 60 { + mr.FastForward(time.Minute) + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + } + t.Log("Отослали 60 запросов") + + t.Log("отслыаем еще один запрос") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(time.Hour) + t.Log("промотали время на 1 час вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + return + } + t.Log("успех") +} + +func TestRedisDay(t *testing.T) { + mr := miniredis.RunT(t) + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + userID, _ := uuid.NewUUID() + + for range 200 { + mr.FastForward(time.Minute) + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + } + t.Log("Отослали 200 запросов") + + t.Log("отслыаем еще один запрос") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(time.Hour) + t.Log("промотали время на 1 час вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(24 * time.Hour) + t.Log("промотали время на 24 часа вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + return + } + t.Log("успех") +} diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go new file mode 100644 index 0000000..990ad16 --- /dev/null +++ b/internal/infrastructure/config/config.go @@ -0,0 +1,306 @@ +package config + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/joho/godotenv" +) + +const ( + EnvironmentDevelopment = "development" + EnvironmentStaging = "staging" + EnvironmentProduction = "production" +) + +const ( + SSLModeDisable = "disable" + SSLModeAllow = "allow" + SSLModePrefer = "prefer" + SSLModeRequire = "require" + SSLModeVerifyCA = "verify-ca" + SSLModeVerifyFull = "verify-full" +) + +type Config struct { + Environment string + LogLevel string + HTTP HTTPConfig + Postgres PostgresConfig + Redis RedisConfig + JWT JWTConfig + Ratelimit RateLimitConfig +} + +type HTTPConfig struct { + Port string + ReadHeaderTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + ShutdownTimeout time.Duration +} + +type JWTConfig struct { + AccessSecret string + RefreshSecret string + AccessTTL time.Duration + RefreshTTL time.Duration + Issuer string +} + +type PostgresConfig struct { + HOST string + PORT string + DBNAME string + USER string + PASSWORD string + SSLMODE string + MaxConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration +} + +type RedisConfig struct { + HOST string + PORT string + USER string + PASSWORD string + DB int + RateLimitMin int64 + RateLimitHour int64 + RateLimitDay int64 +} + +type RateLimitConfig struct { + PerMinute int64 + PerHour int64 + PerDay int64 +} + +func Load() (*Config, error) { + if err := loadDotEnv(); err != nil { + return nil, err + } + + cfg := &Config{ + Environment: getEnv("ENVIRONMENT", EnvironmentDevelopment), + LogLevel: getEnv("LOG_LEVEL", "info"), + } + + cfg.HTTP = HTTPConfig{ + Port: getEnv("HTTP_PORT", "8080"), + ReadHeaderTimeout: getEnvAsDuration("HTTP_READ_HEADER_TIMEOUT", 5*time.Second), + ReadTimeout: getEnvAsDuration("HTTP_READ_TIMEOUT", 10*time.Second), + WriteTimeout: getEnvAsDuration("HTTP_WRITE_TIMEOUT", 15*time.Second), + IdleTimeout: getEnvAsDuration("HTTP_IDLE_TIMEOUT", 60*time.Second), + ShutdownTimeout: getEnvAsDuration("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second), + } + + cfg.Postgres = loadPostgresConfig() + + cfg.Redis = RedisConfig{ + HOST: getEnv("REDIS_HOST", "localhost"), + PORT: getEnv("REDIS_PORT", "6379"), + USER: getEnv("REDIS_USER", ""), + PASSWORD: getEnv("REDIS_PASSWORD", ""), + DB: int(getEnvAsInt("REDIS_DB", 0)), + } + + cfg.JWT = JWTConfig{ + AccessSecret: getEnv("ACCESS_TOKEN_SECRET", ""), + RefreshSecret: getEnv("REFRESH_TOKEN_SECRET", ""), + AccessTTL: getEnvAsDuration("ACCESS_TOKEN_TTL", 15*time.Minute), + RefreshTTL: getEnvAsDuration("REFRESH_TOKEN_TTL", 720*time.Hour), + Issuer: getEnv("ISSUER", "my-app"), + } + + cfg.Ratelimit = RateLimitConfig{ + PerMinute: getEnvAsInt("RATE_LIMIT_PER_MINUTE", 60), + PerHour: getEnvAsInt("RATE_LIMIT_PER_HOUR", 1000), + PerDay: getEnvAsInt("RATE_LIMIT_PER_DAY", 10000), + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func LoadPostgres() (PostgresConfig, error) { + if err := loadDotEnv(); err != nil { + return PostgresConfig{}, err + } + + cfg := loadPostgresConfig() + if err := cfg.Validate(); err != nil { + return PostgresConfig{}, err + } + return cfg, nil +} + +func loadPostgresConfig() PostgresConfig { + return PostgresConfig{ + HOST: getEnv("POSTGRES_HOST", "localhost"), + PORT: getEnv("POSTGRES_PORT", "5432"), + DBNAME: getEnv("POSTGRES_DB", ""), + USER: getEnv("POSTGRES_USER", ""), + PASSWORD: getEnv("POSTGRES_PASSWORD", ""), + SSLMODE: getEnv("POSTGRES_SSLMODE", SSLModeRequire), + MaxConns: int(getEnvAsInt("POSTGRES_MAX_CONNS", 10)), + MaxIdleConns: int(getEnvAsInt("POSTGRES_MAX_IDLE_CONNS", 5)), + ConnMaxLifetime: getEnvAsDuration("POSTGRES_CONN_MAX_LIFETIME", 30*time.Minute), + ConnMaxIdleTime: getEnvAsDuration("POSTGRES_CONN_MAX_IDLE_TIME", 5*time.Minute), + } +} + +func loadDotEnv() error { + if err := godotenv.Load(); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func (c *Config) Validate() error { + var errs []error + if !oneOf(c.Environment, EnvironmentDevelopment, EnvironmentStaging, EnvironmentProduction) { + errs = append(errs, fmt.Errorf("ENVIRONMENT: недопустимое значение %q", c.Environment)) + } + + if !oneOf(c.LogLevel, "debug", "info", "warn", "error") { + errs = append(errs, fmt.Errorf("LOG_LEVEL: недопустимое значение %q", c.LogLevel)) + } + + errs = append(errs, c.Postgres.Validate()) + + errs = append(errs, required("REDIS_HOST", c.Redis.HOST)) + errs = append(errs, required("REDIS_PORT", c.Redis.PORT)) + if c.Redis.DB < 0 { + errs = append(errs, errors.New("REDIS_DB: не может быть отрицательным")) + } + + if c.JWT.AccessSecret == "" || c.JWT.RefreshSecret == "" { + errs = append(errs, errors.New("ACCESS_TOKEN_SECRET И REFRESH_TOKEN_SECRET не могут быть пустыми")) + } + if c.JWT.AccessSecret == c.JWT.RefreshSecret && c.JWT.AccessSecret != "" { + errs = append(errs, errors.New("ACCESS_TOKEN_SECRET и REFRESH_TOKEN_SECRET должны различаться")) + } + if c.JWT.AccessTTL >= c.JWT.RefreshTTL { + errs = append(errs, errors.New("ACCESS_TOKEN_TTL должен быть меньше REFRESH_TOKEN_TTL")) + } + if c.HTTP.ReadHeaderTimeout <= 0 || c.HTTP.ReadTimeout <= 0 || c.HTTP.WriteTimeout <= 0 || c.HTTP.IdleTimeout <= 0 || c.HTTP.ShutdownTimeout <= 0 { + errs = append(errs, errors.New("HTTP_*_TIMEOUT: all timeouts must be positive")) + } + + if c.Ratelimit.PerMinute <= 0 || c.Ratelimit.PerHour <= 0 || c.Ratelimit.PerDay <= 0 { + errs = append(errs, errors.New("RATE_LIMIT_*: все лимиты должны быть положительными")) + } + if c.Ratelimit.PerMinute > c.Ratelimit.PerHour || c.Ratelimit.PerHour > c.Ratelimit.PerDay { + errs = append(errs, errors.New("RATE_LIMIT_*: лимиты должны возрастать(minute <= hour <= day)")) + } + + if c.Environment == EnvironmentProduction { + if c.Postgres.SSLMODE == SSLModeDisable || c.Postgres.SSLMODE == SSLModeAllow || c.Postgres.SSLMODE == SSLModePrefer { + errs = append(errs, fmt.Errorf("POSTGRES_SSLMODE=%q защищен в production", c.Postgres.SSLMODE)) + } + if c.Redis.PASSWORD == "" { + errs = append(errs, errors.New("REDIS_PASSWORD: обязателен в production")) + } + } + + return errors.Join(errs...) +} + +func (c PostgresConfig) Validate() error { + var errs []error + errs = append(errs, required("POSTGRES_HOST", c.HOST)) + errs = append(errs, required("POSTGRES_PORT", c.PORT)) + errs = append(errs, required("POSTGRES_DB", c.DBNAME)) + errs = append(errs, required("POSTGRES_USER", c.USER)) + errs = append(errs, required("POSTGRES_PASSWORD", c.PASSWORD)) + + if !oneOf(c.SSLMODE, SSLModeDisable, SSLModeAllow, SSLModePrefer, SSLModeRequire, SSLModeVerifyCA, SSLModeVerifyFull) { + errs = append(errs, fmt.Errorf("POSTGRES_SSLMODE: недопустимое значение %q", c.SSLMODE)) + } + if c.MaxConns <= 0 { + errs = append(errs, errors.New("POSTGRES_MAX_CONNS: должен быть больше нуля")) + } + if c.MaxIdleConns < 0 || c.MaxIdleConns > c.MaxConns { + errs = append(errs, errors.New("POSTGRES_MAX_IDLE_CONNS must be between zero and POSTGRES_MAX_CONNS")) + } + if c.ConnMaxLifetime <= 0 || c.ConnMaxIdleTime <= 0 { + errs = append(errs, errors.New("POSTGRES_CONN_MAX_* durations must be positive")) + } + return errors.Join(errs...) +} + +func oneOf(value string, allowed ...string) bool { + for _, a := range allowed { + if value == a { + return true + } + } + return false +} + +func required(key, value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s: обязательная переменная не задана", key) + } + return nil +} + +func (c *PostgresConfig) PostgresDSN() string { + q := url.Values{} + q.Set("sslmode", c.SSLMODE) + u := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(c.USER, c.PASSWORD), + Host: net.JoinHostPort(c.HOST, c.PORT), + Path: "/" + c.DBNAME, + RawQuery: q.Encode(), + } + return u.String() +} + +func (c *RedisConfig) Addr() string { + return net.JoinHostPort(c.HOST, c.PORT) +} + +func getEnv(key, defaultValue string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return defaultValue +} + +func getEnvAsInt(key string, defaultValue int64) int64 { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return defaultValue + } + i, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return -1 + } + return i +} + +func getEnvAsDuration(key string, defaultValue time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return defaultValue + } + d, err := time.ParseDuration(raw) + if err != nil { + return -1 + } + return d +} diff --git a/internal/infrastructure/config/config_test.go b/internal/infrastructure/config/config_test.go new file mode 100644 index 0000000..5cb5a5a --- /dev/null +++ b/internal/infrastructure/config/config_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadPostgresDoesNotRequireServerConfig(t *testing.T) { + t.Setenv("POSTGRES_HOST", "postgres") + t.Setenv("POSTGRES_PORT", "5432") + t.Setenv("POSTGRES_DB", "processing") + t.Setenv("POSTGRES_USER", "admin") + t.Setenv("POSTGRES_PASSWORD", "secret") + t.Setenv("POSTGRES_SSLMODE", SSLModeDisable) + t.Setenv("POSTGRES_MAX_CONNS", "10") + t.Setenv("ACCESS_TOKEN_SECRET", "") + t.Setenv("REFRESH_TOKEN_SECRET", "") + + cfg, err := LoadPostgres() + + require.NoError(t, err) + assert.Equal(t, "postgres", cfg.HOST) + assert.Equal(t, SSLModeDisable, cfg.SSLMODE) +} + +func TestLoadPostgresRejectsInvalidSSLMode(t *testing.T) { + t.Setenv("POSTGRES_HOST", "postgres") + t.Setenv("POSTGRES_PORT", "5432") + t.Setenv("POSTGRES_DB", "processing") + t.Setenv("POSTGRES_USER", "admin") + t.Setenv("POSTGRES_PASSWORD", "secret") + t.Setenv("POSTGRES_SSLMODE", "invalid") + t.Setenv("POSTGRES_MAX_CONNS", "10") + + _, err := LoadPostgres() + + require.Error(t, err) + assert.ErrorContains(t, err, "POSTGRES_SSLMODE") +} diff --git a/internal/infrastructure/logger/logger.go b/internal/infrastructure/logger/logger.go new file mode 100644 index 0000000..6fd56eb --- /dev/null +++ b/internal/infrastructure/logger/logger.go @@ -0,0 +1,36 @@ +package logger + +import ( + "log/slog" + "os" +) + +func NewLogger(loglevel string, env string) (*slog.Logger, error) { + var level slog.Level + if err := level.UnmarshalText([]byte(loglevel)); err != nil { + level = slog.LevelInfo + } + + var logger *slog.Logger + switch env { + case "production": + logger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + AddSource: true, + })) + default: + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + AddSource: true, + })) + } + + return logger, nil +} + +func WithService(logger *slog.Logger, service string) *slog.Logger { + if logger == nil { + return slog.Default().With("service", service) + } + return logger.With("service", service) +} diff --git a/internal/infrastructure/storage/helper.go b/internal/infrastructure/storage/helper.go index b7284c9..e4c878c 100644 --- a/internal/infrastructure/storage/helper.go +++ b/internal/infrastructure/storage/helper.go @@ -1,79 +1,81 @@ -package storage - -import ( - "context" - "fmt" - "log/slog" - "processing/internal/decimal" - "processing/internal/domain" - - "github.com/google/uuid" -) - -func sqlrequest(ctx context.Context, filter domain.TransactionFilter, log *slog.Logger) (string, []interface{}) { - query := `SELECT id, amount, sender_id, receiver_id, status, created_at FROM transactions WHERE 1=1` - args := []interface{}{} - argCounter := 1 - - // Добавляем условия в зависимости от фильтров - if filter.SenderID != uuid.Nil { - query += fmt.Sprintf(" AND sender_id = $%d", argCounter) - args = append(args, filter.SenderID) - argCounter++ - } - - if filter.ReceiverID != uuid.Nil { - query += fmt.Sprintf(" AND receiver_id = $%d", argCounter) - args = append(args, filter.ReceiverID) - argCounter++ - } - - if filter.MinAmount != "" { - minAmount, err := decimal.NewFromString(filter.MinAmount) - if err != nil { - log.ErrorContext(ctx, "ошибка конвертации минимальной суммы", "error", err, "min_amount", filter.MinAmount) - return "", nil - } - query += fmt.Sprintf(" AND amount >= $%d", argCounter) - args = append(args, minAmount) - argCounter++ - } - - if filter.MaxAmount != "" { - maxAmount, err := decimal.NewFromString(filter.MaxAmount) - if err != nil { - log.ErrorContext(ctx, "ошибка конвертации максимальной суммы", "error", err, "max_amount", filter.MaxAmount) - return "", nil - } - query += fmt.Sprintf(" AND amount <= $%d", argCounter) - args = append(args, maxAmount) - argCounter++ - } - - if !filter.From.IsZero() { - query += fmt.Sprintf(" AND created_at >= $%d", argCounter) - args = append(args, filter.From) - argCounter++ - } - - if !filter.To.IsZero() { - query += fmt.Sprintf(" AND created_at <= $%d", argCounter) - args = append(args, filter.To) - argCounter++ - } - - query += " ORDER BY created_at DESC" - - if filter.Limit > 0 { - query += fmt.Sprintf(" LIMIT $%d", argCounter) - args = append(args, filter.Limit) - argCounter++ - } - - if filter.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argCounter) - args = append(args, filter.Offset) - } - - return query, args -} +package storage + +import ( + "context" + "fmt" + "processing/internal/decimal" + "processing/internal/domain" + + "github.com/google/uuid" +) + +func sqlrequest(ctx context.Context, filter domain.TransactionFilter) (string, []interface{}) { + query := `SELECT id, amount, sender_id, receiver_id, status, created_at FROM transactions WHERE 1=1` + args := []interface{}{} + argCounter := 1 + + if filter.AccountID != uuid.Nil { + query += fmt.Sprintf(" AND(receiver_id = $%d OR sender_id = $%d)", argCounter, argCounter) + args = append(args, filter.AccountID) + argCounter++ + } + + if filter.SenderID != uuid.Nil { + query += fmt.Sprintf(" AND sender_id = $%d", argCounter) + args = append(args, filter.SenderID) + argCounter++ + } + + if filter.ReceiverID != uuid.Nil { + query += fmt.Sprintf(" AND receiver_id = $%d", argCounter) + args = append(args, filter.ReceiverID) + argCounter++ + } + + if filter.MinAmount != "" { + minAmount, err := decimal.NewFromString(filter.MinAmount) + if err != nil { + return "", nil + } + query += fmt.Sprintf(" AND amount >= $%d", argCounter) + args = append(args, minAmount) + argCounter++ + } + + if filter.MaxAmount != "" { + maxAmount, err := decimal.NewFromString(filter.MaxAmount) + if err != nil { + return "", nil + } + query += fmt.Sprintf(" AND amount <= $%d", argCounter) + args = append(args, maxAmount) + argCounter++ + } + + if !filter.From.IsZero() { + query += fmt.Sprintf(" AND created_at >= $%d", argCounter) + args = append(args, filter.From) + argCounter++ + } + + if !filter.To.IsZero() { + query += fmt.Sprintf(" AND created_at <= $%d", argCounter) + args = append(args, filter.To) + argCounter++ + } + + query += " ORDER BY created_at DESC" + + if filter.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argCounter) + args = append(args, filter.Limit) + argCounter++ + } + + if filter.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argCounter) + args = append(args, filter.Offset) + } + + return query, args +} diff --git a/internal/infrastructure/storage/storage.go b/internal/infrastructure/storage/storage.go index 543a8bd..159ef02 100644 --- a/internal/infrastructure/storage/storage.go +++ b/internal/infrastructure/storage/storage.go @@ -5,170 +5,288 @@ import ( "database/sql" "errors" "fmt" - "log/slog" "processing/internal/decimal" "processing/internal/domain" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" ) type accountRepo struct { - tx *sql.Tx - log *slog.Logger + tx *sql.Tx } + type txRepo struct { - tx *sql.Tx - log *slog.Logger + tx *sql.Tx +} + +type txToken struct { + tx *sql.Tx } // транзакция которую мы будем раздавать type sqlTx struct { tx *sql.Tx accounts *accountRepo + token *txToken txs *txRepo - log *slog.Logger } func (u *sqlTx) Accounts() domain.AccountsStorage { return u.accounts } func (u *sqlTx) Transactions() domain.TransactionStorage { return u.txs } +func (u *sqlTx) Tokens() domain.TokenStorage { return u.token } func (u *sqlTx) Commit() error { - err := u.tx.Commit() - if err != nil { - u.log.Error("ошибка при коммите транзакции", "error", err) - return err - } - u.log.Debug("транзакция успешно закоммичена") - return nil + return u.tx.Commit() } func (u *sqlTx) Rollback() error { - err := u.tx.Rollback() - if err != nil { - u.log.Error("ошибка при откате транзакции", "error", err) - return err - } - u.log.Debug("транзакция успешно откачена") - return nil + return u.tx.Rollback() } type uowFactory struct { - db *sql.DB - log *slog.Logger + db *sql.DB } -func NewUoWFactory(db *sql.DB, log *slog.Logger) domain.TxUOW { - return &uowFactory{db: db, log: log} +func NewUoWFactory(db *sql.DB) domain.TxUOW { + return &uowFactory{db: db} } // NewTX создает новую транзакцию базы данных func (u *uowFactory) NewTX(ctx context.Context) (domain.UnitOfWork, error) { - tx, err := u.db.BeginTx(ctx, nil) + tx, err := u.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { - u.log.ErrorContext(ctx, "не удалось начать транзакцию", "error", err) return nil, fmt.Errorf("tx begin: %w", err) } - u.log.DebugContext(ctx, "транзакция успешно создана") return &sqlTx{ tx: tx, - accounts: &accountRepo{tx: tx, log: u.log}, - txs: &txRepo{tx: tx, log: u.log}, - log: u.log, + accounts: &accountRepo{tx: tx}, + txs: &txRepo{tx: tx}, + token: &txToken{tx: tx}, }, nil } // Create - создаёт аккаунт и возвращает ID func (s *accountRepo) Create(ctx context.Context, ac *domain.Account) error { - query := `INSERT INTO accounts(id, name, balance) VALUES($1, $2, $3)` - s.log.DebugContext(ctx, "создание аккаунта", "account_id", ac.ID, "name", ac.Name, "balance", ac.Balance) - if _, err := s.tx.ExecContext(ctx, query, ac.ID, ac.Name, ac.Balance); err != nil { - s.log.ErrorContext(ctx, "ошибка создания аккаунта", "error", err, "account_id", ac.ID) + query := `INSERT INTO accounts(id, name, email, balance, password_hash, role) VALUES($1, $2, $3, $4, $5, $6)` + + if _, err := s.tx.ExecContext(ctx, query, ac.ID, ac.Name, ac.Email, ac.Balance, ac.PasswordHash, ac.Role); err != nil { + var pgerr *pgconn.PgError + if errors.As(err, &pgerr) && pgerr.Code == "23505" { + return domain.ErrAccountAlreadyExist + } return fmt.Errorf("создание аккакунта: %w", err) } - s.log.InfoContext(ctx, "аккаунт успешно создан", "account_id", ac.ID) return nil } // GetById - возвращает аккаунт по id func (s *accountRepo) GetById(ctx context.Context, id uuid.UUID) (*domain.Account, error) { - s.log.DebugContext(ctx, "получение аккаунта по id", "account_id", id) ac := &domain.Account{} - query := `SELECT id, name, balance FROM accounts WHERE id = $1` - err := s.tx.QueryRowContext(ctx, query, id).Scan(&ac.ID, &ac.Name, &ac.Balance) + query := `SELECT id, name, email, balance, password_hash, role FROM accounts WHERE id = $1` + err := s.tx.QueryRowContext(ctx, query, id).Scan(&ac.ID, &ac.Name, &ac.Email, &ac.Balance, &ac.PasswordHash, &ac.Role) if err != nil { if errors.Is(err, sql.ErrNoRows) { - s.log.WarnContext(ctx, "аккаунт не найден", "account_id", id) - return nil, fmt.Errorf("аккаунт не найден или не создан: %w", err) + return nil, domain.ErrAccountNotFound } - s.log.ErrorContext(ctx, "ошибка получения аккаунта", "error", err, "account_id", id) return nil, fmt.Errorf("получение данных аккаунта по id: %w", err) } - s.log.DebugContext(ctx, "аккаунт успешно получен", "account_id", id, "balance", ac.Balance) + return ac, nil +} + +// GetByEmail - возвращает аккаунт по email +func (s *accountRepo) GetByEmail(ctx context.Context, email string) (*domain.Account, error) { + ac := &domain.Account{} + query := `SELECT id, name, email, balance, password_hash, role FROM accounts WHERE LOWER(email) = LOWER($1)` + err := s.tx.QueryRowContext(ctx, query, email).Scan(&ac.ID, &ac.Name, &ac.Email, &ac.Balance, &ac.PasswordHash, &ac.Role) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrAccountNotFound + } + return nil, fmt.Errorf("получение данных аккаунта по email: %w", err) + } return ac, nil } // Sub - вычетает сумму с баланса аккаунта func (s *accountRepo) Sub(ctx context.Context, sender_id uuid.UUID, amount decimal.Decimal) error { - s.log.DebugContext(ctx, "вычет суммы с баланса", "account_id", sender_id, "amount", amount) - query := `UPDATE accounts SET balance = balance - $1 WHERE id = $2` - if _, err := s.tx.ExecContext(ctx, query, amount, sender_id); err != nil { - s.log.ErrorContext(ctx, "ошибка вычета суммы с баланса", "error", err, "account_id", sender_id, "amount", amount) + query := ` + UPDATE accounts + SET balance = balance - $1 + WHERE id = $2 AND balance >= $1 + ` + res, err := s.tx.ExecContext(ctx, query, amount, sender_id) + if err != nil { return fmt.Errorf("вычет суммы с баланса: %w", err) } - s.log.InfoContext(ctx, "сумма успешно вычтена с баланса", "account_id", sender_id, "amount", amount) + + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return domain.ErrInsufficientFunds + } + return nil } // Add - добавляет сумму на баланс аккаунта func (s *accountRepo) Add(ctx context.Context, receiver_id uuid.UUID, amount decimal.Decimal) error { - s.log.DebugContext(ctx, "добавление суммы на баланс", "account_id", receiver_id, "amount", amount) query := `UPDATE accounts SET balance = balance + $1 WHERE id = $2` - if _, err := s.tx.ExecContext(ctx, query, amount, receiver_id); err != nil { - s.log.ErrorContext(ctx, "ошибка добавления суммы на баланс", "error", err, "account_id", receiver_id, "amount", amount) + res, err := s.tx.ExecContext(ctx, query, amount, receiver_id) + if err != nil { return fmt.Errorf("добавление суммы на баланс: %w", err) } - s.log.InfoContext(ctx, "сумма успешно добавлена на баланс", "account_id", receiver_id, "amount", amount) + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrReceiverAccountNotFound + } + return nil } // Transaction создает транзакцию в бд func (s *txRepo) Transaction(ctx context.Context, tx *domain.Transaction) error { - s.log.DebugContext(ctx, "создание транзакции", "transaction_id", tx.ID, "amount", tx.Amount, "sender_id", tx.Sender_id, "receiver_id", tx.Receiver_id) query := ` INSERT INTO transactions(id, amount, sender_id, receiver_id) VALUES($1, $2, $3, $4) RETURNING status, created_at ` if err := s.tx.QueryRowContext(ctx, query, tx.ID, tx.Amount, tx.Sender_id, tx.Receiver_id). Scan(&tx.Status, &tx.Created_at); err != nil { - s.log.ErrorContext(ctx, "ошибка создания транзакции", "error", err, "transaction_id", tx.ID) return fmt.Errorf("создание транзакции: %w", err) } - s.log.InfoContext(ctx, "транзакция успешно создана", "transaction_id", tx.ID, "status", tx.Status) return nil } // UpdateStatus обновляет статус транзакции в бд func (s *txRepo) UpdateStatus(ctx context.Context, tx *domain.Transaction, status domain.TransactionStatus) error { - s.log.DebugContext(ctx, "обновление статуса транзакции", "transaction_id", tx.ID, "new_status", status) query := ` UPDATE transactions SET status = $1 WHERE id = $2 RETURNING status, created_at ` if err := s.tx.QueryRowContext(ctx, query, status, tx.ID).Scan(&tx.Status, &tx.Created_at); err != nil { - s.log.ErrorContext(ctx, "ошибка обновления статуса транзакции", "error", err, "transaction_id", tx.ID, "status", status) return fmt.Errorf("обновление статуса транзакции: %w", err) } - s.log.InfoContext(ctx, "статус транзакции успешно обновлен", "transaction_id", tx.ID, "status", tx.Status) + return nil +} + +func (s *accountRepo) LockForTransfer(ctx context.Context, firstID, secondID uuid.UUID) error { + rows, err := s.tx.QueryContext(ctx, ` + SELECT id + FROM accounts + WHERE id IN ($1, $2) + ORDER BY id + FOR UPDATE + `, firstID, secondID) + if err != nil { + return fmt.Errorf("lock transfer accounts: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return fmt.Errorf("scan locked account: %w", err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate locked accounts: %w", err) + } + return nil +} + +func (s *txRepo) TryCreateIdempotency(ctx context.Context, record *domain.TransferIdempotency) (bool, error) { + query := ` + INSERT INTO transfer_idempotency(sender_id, idempotency_key, request_fingerprint, status) + VALUES($1, $2, $3, 'processing') + ON CONFLICT (sender_id, idempotency_key) DO NOTHING + RETURNING status, created_at, updated_at + ` + + err := s.tx.QueryRowContext( + ctx, + query, + record.SenderID, + record.Key, + record.RequestFingerprint, + ).Scan(&record.Status, &record.CreatedAt, &record.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("создание записи идемпотентности: %w", err) + } + + return true, nil +} + +func (s *txRepo) GetIdempotency(ctx context.Context, senderID uuid.UUID, key string) (domain.TransferIdempotency, error) { + record := domain.TransferIdempotency{} + transactionID := uuid.NullUUID{} + query := ` + SELECT sender_id, idempotency_key, request_fingerprint, status, transaction_id, created_at, updated_at + FROM transfer_idempotency + WHERE sender_id = $1 AND idempotency_key = $2 + ` + + err := s.tx.QueryRowContext(ctx, query, senderID, key).Scan( + &record.SenderID, + &record.Key, + &record.RequestFingerprint, + &record.Status, + &transactionID, + &record.CreatedAt, + &record.UpdatedAt, + ) + if err != nil { + return domain.TransferIdempotency{}, fmt.Errorf("получение записи идемпотентности: %w", err) + } + + if transactionID.Valid { + id := transactionID.UUID + record.TransactionID = &id + } + + return record, nil +} + +func (s *txRepo) CompleteIdempotency(ctx context.Context, senderID uuid.UUID, key string, transactionID uuid.UUID) error { + query := ` + UPDATE transfer_idempotency + SET status = 'completed', transaction_id = $3, updated_at = now() + WHERE sender_id = $1 AND idempotency_key = $2 AND status = 'processing' + ` + + result, err := s.tx.ExecContext(ctx, query, senderID, key, transactionID) + if err != nil { + return fmt.Errorf("завершение записи идемпотентности: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("проверка завершения записи идемпотентности: %w", err) + } + if rows != 1 { + return fmt.Errorf("запись идемпотентности не найдена или уже завершена") + } + return nil } func (s *txRepo) GetByID(ctx context.Context, transactionID uuid.UUID) (domain.Transaction, error) { - s.log.DebugContext(ctx, "получение транзакции по id", "transaction_id", transactionID) transaction := domain.Transaction{} - query := `SELECT amount, sender_id, receiver_id, status, created_at FROM transactions WHERE id = $1` + query := `SELECT id, amount, sender_id, receiver_id, status, created_at FROM transactions WHERE id = $1` err := s.tx. QueryRowContext(ctx, query, transactionID). Scan( + &transaction.ID, &transaction.Amount, &transaction.Sender_id, &transaction.Receiver_id, @@ -176,31 +294,20 @@ func (s *txRepo) GetByID(ctx context.Context, transactionID uuid.UUID) (domain.T &transaction.Created_at, ) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.log.WarnContext(ctx, "транзакция не найдена", "transaction_id", transactionID) - } else { - s.log.ErrorContext(ctx, "ошибка получения транзакции", "error", err, "transaction_id", transactionID) - } return domain.Transaction{}, fmt.Errorf("получение транзакции по айди: %w", err) } - s.log.DebugContext(ctx, "транзакция успешно получена", "transaction_id", transactionID, "status", transaction.Status) return transaction, nil } // GetTransactions получает транзакции по фильтрам func (s *txRepo) GetTransactions(ctx context.Context, filter domain.TransactionFilter) ([]domain.Transaction, error) { - s.log.DebugContext(ctx, "получение транзакций по фильтрам", "filter", filter) - - query, args := sqlrequest(ctx, filter, s.log) + query, args := sqlrequest(ctx, filter) if query == "" { - s.log.Error("не получилось построить запрос") return nil, errors.New("не получилось построить запрос") } - s.log.DebugContext(ctx, "выполнение SQL запроса", "query", query, "args", args) rows, err := s.tx.QueryContext(ctx, query, args...) if err != nil { - s.log.ErrorContext(ctx, "ошибка выполнения запроса", "error", err) return nil, fmt.Errorf("получение транзакций по фильтрам: %w", err) } defer rows.Close() @@ -217,17 +324,111 @@ func (s *txRepo) GetTransactions(ctx context.Context, filter domain.TransactionF &t.Created_at, ) if err != nil { - s.log.ErrorContext(ctx, "ошибка сканирования строки", "error", err) return nil, fmt.Errorf("сканирование транзакции: %w", err) } transactions = append(transactions, t) } if err = rows.Err(); err != nil { - s.log.ErrorContext(ctx, "ошибка при обработке строк", "error", err) return nil, fmt.Errorf("обработка строк результата: %w", err) } - s.log.InfoContext(ctx, "транзакции успешно получены", "count", len(transactions)) return transactions, nil } + +func (s *txRepo) TotalTransactions(ctx context.Context, userID uuid.UUID) (int, error) { + query := `SELECT COUNT(*) FROM transactions WHERE receiver_id=$1 OR sender_id=$1` + var count int + + if err := s.tx.QueryRowContext(ctx, query, userID).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} + +func (s *txToken) SaveRefreshToken(ctx context.Context, jti string, userID, familyID uuid.UUID, expiresAt time.Time) error { + query := `INSERT INTO refresh_token(jti, user_id, family_id, expires_at, revoked) VALUES($1, $2, $3, $4, false)` + + res, err := s.tx.ExecContext(ctx, query, jti, userID, familyID, expiresAt) + if err != nil { + return domain.ErrSaveRefreshToken + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrSaveRefreshToken + } + + return nil +} + +func (s *txToken) GetRefreshToken(ctx context.Context, jti string) (*domain.RefreshSession, error) { + query := `SELECT user_id, family_id, revoked, expires_at FROM refresh_token WHERE jti = $1` + + session := &domain.RefreshSession{} + err := s.tx.QueryRowContext(ctx, query, jti).Scan(&session.UserID, &session.FamilyID, &session.Revoked, &session.ExpiresAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrRefreshTokenNotFound + } + return nil, fmt.Errorf("получение refresh токена: %w", err) + } + + return session, nil +} + +func (s *txToken) RevokeRefreshToken(ctx context.Context, jti string) error { + query := `UPDATE refresh_token SET revoked = true WHERE jti = $1 AND revoked = false` + + res, err := s.tx.ExecContext(ctx, query, jti) + if err != nil { + return fmt.Errorf("отзыв refresh токена: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrRefreshTokenNotFound + } + + return nil +} + +func (s *txToken) RevokeAllUserTokens(ctx context.Context, userID uuid.UUID) error { + query := `UPDATE refresh_token SET revoked = true WHERE user_id = $1 AND revoked = false` + + res, err := s.tx.ExecContext(ctx, query, userID) + if err != nil { + return fmt.Errorf("отзыв всех токенов пользователя: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrUserNotFound + } + + return nil +} + +func (s *txToken) RevokeTokenFamily(ctx context.Context, familyID uuid.UUID) error { + query := `UPDATE refresh_token SET revoked = true WHERE family_id = $1 AND revoked = false` + + _, err := s.tx.ExecContext(ctx, query, familyID) + if err != nil { + return fmt.Errorf("отзыв семейства refresh токенов: %w", err) + } + + return nil +} diff --git a/internal/infrastructure/storage/storage_test.go b/internal/infrastructure/storage/storage_test.go deleted file mode 100644 index 835eead..0000000 --- a/internal/infrastructure/storage/storage_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package storage - -import ( - _ "github.com/jackc/pgx/v5/stdlib" -) diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go new file mode 100644 index 0000000..bfb5cbd --- /dev/null +++ b/internal/pkg/app/app.go @@ -0,0 +1,133 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + httpapp "processing/internal/delivery/http/app" + jwtLayer "processing/internal/delivery/http/jwt" + "processing/internal/delivery/http/middleware" + httprouter "processing/internal/delivery/http/router" + "processing/internal/infrastructure/cache" + "processing/internal/infrastructure/config" + "processing/internal/infrastructure/logger" + "processing/internal/infrastructure/storage" + "processing/internal/usecase" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func Run() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + return run(cfg) +} + +func run(cfg *config.Config) error { + appLogger, err := logger.NewLogger(cfg.LogLevel, cfg.Environment) + if err != nil { + return fmt.Errorf("create logger: %w", err) + } + + db, err := sql.Open("pgx", cfg.Postgres.PostgresDSN()) + if err != nil { + return fmt.Errorf("open postgres: %w", err) + } + defer func() { + if err := db.Close(); err != nil { + appLogger.Error("close postgres", "err", err) + } + }() + db.SetMaxOpenConns(cfg.Postgres.MaxConns) + db.SetMaxIdleConns(cfg.Postgres.MaxIdleConns) + db.SetConnMaxLifetime(cfg.Postgres.ConnMaxLifetime) + db.SetConnMaxIdleTime(cfg.Postgres.ConnMaxIdleTime) + + cacheClient := cache.NewRedis(cache.NewRedisOptions{ + Addr: cfg.Redis.Addr(), + Username: cfg.Redis.USER, + Password: cfg.Redis.PASSWORD, + DB: cfg.Redis.DB, + RateLimitMin: cfg.Ratelimit.PerMinute, + RateLimitHour: cfg.Ratelimit.PerHour, + RateLimitDay: cfg.Ratelimit.PerDay, + }) + defer func() { + if err := cacheClient.Close(); err != nil { + appLogger.Error("close redis", "err", err) + } + }() + + startupCtx, startupCancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := db.PingContext(startupCtx); err != nil { + startupCancel() + return fmt.Errorf("ping postgres: %w", err) + } + if err := cacheClient.Ping(startupCtx); err != nil { + startupCancel() + return fmt.Errorf("ping redis: %w", err) + } + startupCancel() + + tx := storage.NewUoWFactory(db) + jwtManager := jwtLayer.NewManager(cfg.JWT) + transactionService := usecase.NewTransactionsService(tx, cacheClient, appLogger) + accountsService := usecase.NewAccountService(tx, cacheClient, appLogger) + authService := usecase.NewAuthService(tx, cacheClient, appLogger, jwtManager) + app := httpapp.NewApp(transactionService, accountsService, authService, appLogger) + auth := middleware.NewAuth(jwtManager) + + router := httprouter.New(app, auth, db.PingContext, cacheClient.Ping) + + server := &http.Server{ + Addr: ":" + cfg.HTTP.Port, + Handler: router, + ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout, + ReadTimeout: cfg.HTTP.ReadTimeout, + WriteTimeout: cfg.HTTP.WriteTimeout, + IdleTimeout: cfg.HTTP.IdleTimeout, + } + + serverErr := make(chan error, 1) + go func() { + serverErr <- server.ListenAndServe() + }() + appLogger.Info("Server started", "server port", cfg.HTTP.Port) + + signalCtx, stop := signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGTERM, + ) + defer stop() + + select { + case err := <-serverErr: + if !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server: %w", err) + } + return nil + case <-signalCtx.Done(): + } + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.HTTP.ShutdownTimeout) + defer shutdownCancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("http shutdown: %w", err) + } + if err := <-serverErr; err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server stop: %w", err) + } + appLogger.Info("server stopped") + return nil +} diff --git a/internal/usecase/accounts.go b/internal/usecase/accounts.go new file mode 100644 index 0000000..18edf27 --- /dev/null +++ b/internal/usecase/accounts.go @@ -0,0 +1,133 @@ +package usecase + +import ( + "context" + "log/slog" + "processing/internal/domain" + "processing/internal/infrastructure/logger" + + "time" + + "github.com/google/uuid" +) + +type AccountsService struct { + tx domain.TxUOW + cache domain.Cache + log *slog.Logger +} + +func NewAccountService(tx domain.TxUOW, cache domain.Cache, log *slog.Logger) *AccountsService { + log = logger.WithService(log, "Accounts") + return &AccountsService{ + tx: tx, + cache: cache, + log: log, + } +} + +// Create создает аккаунт +func (as *AccountsService) Create(ctx context.Context, acc *domain.Account, ip string) error { + if err := as.cache.CheckRateLimit(ctx, ip); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов при создании аккаунта", "ip", ip) + return err + } + + if err := as.cache.IdempotencyCheck(ctx, ip, time.Minute); err != nil { + as.log.WarnContext(ctx, "повторный запрос на создание аккаунта", "ip", ip) + return err + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return err + } + defer uow.Rollback() + + if err := uow.Accounts().Create(ctx, acc); err != nil { + as.log.ErrorContext(ctx, "ошибка создания аккаунта в БД", "err", err, "email", acc.Email) + return err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка коммита транзакции", "err", err) + return err + } + + as.log.InfoContext(ctx, "аккаунт успешно создан", "account_id", acc.ID, "email", acc.Email) + return nil +} + +// GetAccount получает аккаунт по id +func (as *AccountsService) GetAccount(ctx context.Context, id uuid.UUID) (*domain.Account, error) { + if err := as.cache.CheckRateLimit(ctx, id.String()); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов получения аккаунта", "account_id", id) + return nil, err + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return nil, err + } + defer uow.Rollback() + + acc, err := uow.Accounts().GetById(ctx, id) + if err != nil { + as.log.ErrorContext(ctx, "ошибка получения аккаунта из БД", "err", err, "account_id", id) + return nil, err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка коммита транзакции", "err", err) + return nil, err + } + + as.log.InfoContext(ctx, "аккаунт успешно получен", "account_id", id) + return acc, nil +} + +// TransactionHistory выводит все транзакции пользователя +func (as *AccountsService) TransactionHistory(ctx context.Context, accountID uuid.UUID, limit, offset int) (int, []domain.Transaction, error) { + if err := as.cache.CheckRateLimit(ctx, accountID.String()); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов истории транзакций", "account_id", accountID) + return 0, nil, err + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return 0, nil, err + } + defer uow.Rollback() + + var total int + + total, err = uow.Transactions().TotalTransactions(ctx, accountID) + if err != nil { + as.log.ErrorContext(ctx, "ошибка получения количества транзакций", "err", err, "account_id", accountID) + return 0, nil, err + } + + var transactions []domain.Transaction + filter := domain.TransactionFilter{ + AccountID: accountID, + Limit: limit, + Offset: offset, + } + + transactions, err = uow.Transactions().GetTransactions(ctx, filter) + if err != nil { + as.log.ErrorContext(ctx, "ошибка получения транзакций из БД", "err", err, "account_id", accountID, "limit", limit, "offset", offset) + return 0, nil, err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка коммита транзакции", "err", err) + return 0, nil, err + } + + as.log.InfoContext(ctx, "история транзакций получена", "account_id", accountID, "total", total, "returned", len(transactions)) + return total, transactions, nil +} diff --git a/internal/usecase/auth.go b/internal/usecase/auth.go new file mode 100644 index 0000000..efdee9f --- /dev/null +++ b/internal/usecase/auth.go @@ -0,0 +1,351 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "log/slog" + "processing/internal/decimal" + "processing/internal/domain" + "processing/internal/infrastructure/logger" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +const ( + bcryptCost = 12 + minPasswordLen = 8 + maxPasswordLen = 72 + maxEmailLen = 254 + minAccountNameLen = 3 + maxAccountNameLen = 100 + dummyHash = "$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" +) + +var emailPattern = regexp.MustCompile(`^[a-z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`) + +type tokenManager interface { + GenerateTokenPair(userID string, role string) (*domain.TokenPair, error) + ValidateRefreshToken(token string) (*domain.RefreshClaims, error) +} + +type AuthService struct { + tx domain.TxUOW + cache domain.Cache + jwt tokenManager + log *slog.Logger + now func() time.Time +} + +func NewAuthService(tx domain.TxUOW, cache domain.Cache, log *slog.Logger, jwt tokenManager) *AuthService { + return &AuthService{ + tx: tx, + cache: cache, + jwt: jwt, + log: logger.WithService(log, "Auth"), + now: time.Now, + } +} + +func (as *AuthService) Register(ctx context.Context, email, password, name, ip string) (*domain.Account, error) { + email = normalizeEmail(email) + name = strings.TrimSpace(name) + if err := validateRegistration(email, password, name); err != nil { + return nil, err + } + if err := as.checkRateLimit(ctx, "register", ip); err != nil { + return nil, err + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return nil, fmt.Errorf("хеширование пароля: %w", err) + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + return nil, fmt.Errorf("открытие транзакции регистрации: %w", err) + } + defer func() { _ = uow.Rollback() }() + + account := &domain.Account{ + ID: uuid.New(), + Email: email, + Name: name, + PasswordHash: string(passwordHash), + Balance: decimal.Zero(), + Role: domain.RoleUser, + } + + if err := uow.Accounts().Create(ctx, account); err != nil { + return nil, fmt.Errorf("создание аккаунта: %w", err) + } + + if err := uow.Commit(); err != nil { + return nil, fmt.Errorf("коммит регистрации: %w", err) + } + + as.log.InfoContext(ctx, "пользователь зарегистрирован", "user_id", account.ID) + return account, nil +} + +func (as *AuthService) Login(ctx context.Context, email, password, ip string) (*domain.TokenPair, error) { + email = normalizeEmail(email) + if err := as.checkRateLimit(ctx, "login", loginRateIdentity(ip, email)); err != nil { + return nil, err + } + + if !validEmail(email) || len(password) < minPasswordLen || len(password) > maxPasswordLen { + compareDummyPassword(password) + return nil, domain.ErrInvalidCredentials + } + + account, err := as.loadAccountForLogin(ctx, email) + if err != nil { + if errors.Is(err, domain.ErrAccountNotFound) { + compareDummyPassword(password) + return nil, domain.ErrInvalidCredentials + } + return nil, err + } + if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(password)); err != nil { + as.log.WarnContext(ctx, "неверные учетные данные", "user_id", account.ID) + return nil, domain.ErrInvalidCredentials + } + + pair, err := as.jwt.GenerateTokenPair(account.ID.String(), account.Role) + if err != nil { + return nil, fmt.Errorf("генерация токенов: %w", err) + } + familyID, err := uuid.NewRandom() + if err != nil { + return nil, fmt.Errorf("генерация family ID: %w", err) + } + if err := as.saveRefreshSession(ctx, pair, account.ID, familyID); err != nil { + return nil, err + } + + as.log.InfoContext(ctx, "успешный вход", "user_id", account.ID) + return publicTokenPair(pair), nil +} + +func (as *AuthService) Refresh(ctx context.Context, refreshToken, ip string) (*domain.TokenPair, error) { + if refreshToken == "" { + return nil, domain.ErrInvalidRefreshToken + } + if err := as.checkRateLimit(ctx, "refresh", ip); err != nil { + return nil, err + } + + claims, err := as.jwt.ValidateRefreshToken(refreshToken) + if err != nil { + if errors.Is(err, domain.ErrTokenExpired) { + return nil, domain.ErrRefreshTokenExpired + } + return nil, domain.ErrInvalidRefreshToken + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + return nil, fmt.Errorf("открытие транзакции refresh: %w", err) + } + defer func() { _ = uow.Rollback() }() + + session, err := uow.Tokens().GetRefreshToken(ctx, claims.ID) + if err != nil { + if errors.Is(err, domain.ErrRefreshTokenNotFound) { + return nil, domain.ErrInvalidRefreshToken + } + return nil, fmt.Errorf("получение refresh-сессии: %w", err) + } + if claims.UserID != session.UserID.String() { + return nil, domain.ErrInvalidRefreshToken + } + if session.Revoked { + return nil, as.revokeFamilyAfterReuse(ctx, uow, session.FamilyID) + } + if !as.now().Before(session.ExpiresAt) { + if err := uow.Tokens().RevokeRefreshToken(ctx, claims.ID); err != nil && !errors.Is(err, domain.ErrRefreshTokenNotFound) { + return nil, fmt.Errorf("отзыв истекшей refresh-сессии: %w", err) + } + if err := uow.Commit(); err != nil { + return nil, fmt.Errorf("коммит отзыва истекшей refresh-сессии: %w", err) + } + return nil, domain.ErrRefreshTokenExpired + } + + if err := uow.Tokens().RevokeRefreshToken(ctx, claims.ID); err != nil { + if errors.Is(err, domain.ErrRefreshTokenNotFound) { + return nil, as.revokeFamilyAfterReuse(ctx, uow, session.FamilyID) + } + return nil, fmt.Errorf("отзыв refresh-сессии: %w", err) + } + + account, err := uow.Accounts().GetById(ctx, session.UserID) + if err != nil { + return nil, fmt.Errorf("получение аккаунта при refresh: %w", err) + } + newPair, err := as.jwt.GenerateTokenPair(account.ID.String(), account.Role) + if err != nil { + return nil, fmt.Errorf("генерация новых токенов: %w", err) + } + if err := uow.Tokens().SaveRefreshToken(ctx, newPair.JTI, account.ID, session.FamilyID, newPair.ExpiresAt); err != nil { + return nil, fmt.Errorf("сохранение новой refresh-сессии: %w", err) + } + if err := uow.Commit(); err != nil { + return nil, fmt.Errorf("коммит rotation refresh-сессии: %w", err) + } + + as.log.InfoContext(ctx, "refresh-сессия обновлена", "user_id", account.ID, "old_jti", claims.ID, "new_jti", newPair.JTI) + return publicTokenPair(newPair), nil +} + +func (as *AuthService) Logout(ctx context.Context, refreshToken, ip string) error { + if err := as.checkRateLimit(ctx, "logout", ip); err != nil { + as.log.WarnContext(ctx, "rate limit не блокирует logout", "err", err) + } + + claims, err := as.jwt.ValidateRefreshToken(refreshToken) + if err != nil { + return nil + } + uow, err := as.tx.NewTX(ctx) + if err != nil { + return fmt.Errorf("открытие транзакции logout: %w", err) + } + defer func() { _ = uow.Rollback() }() + + session, err := uow.Tokens().GetRefreshToken(ctx, claims.ID) + if err != nil { + if errors.Is(err, domain.ErrRefreshTokenNotFound) { + return nil + } + return fmt.Errorf("получение refresh-сессии при logout: %w", err) + } + if claims.UserID != session.UserID.String() || session.Revoked { + return nil + } + if err := uow.Tokens().RevokeRefreshToken(ctx, claims.ID); err != nil { + if errors.Is(err, domain.ErrRefreshTokenNotFound) { + return nil + } + return fmt.Errorf("отзыв refresh-сессии при logout: %w", err) + } + if err := uow.Commit(); err != nil { + return fmt.Errorf("коммит logout: %w", err) + } + return nil +} + +func (as *AuthService) LogoutAll(ctx context.Context, userID uuid.UUID) error { + uow, err := as.tx.NewTX(ctx) + if err != nil { + return fmt.Errorf("открытие транзакции logout-all: %w", err) + } + defer func() { _ = uow.Rollback() }() + + if err := uow.Tokens().RevokeAllUserTokens(ctx, userID); err != nil && !errors.Is(err, domain.ErrUserNotFound) { + return fmt.Errorf("отзыв всех refresh-сесий: %w", err) + } + if err := uow.Commit(); err != nil { + return fmt.Errorf("коммит logout-all: %w", err) + } + return nil +} + +func (as *AuthService) loadAccountForLogin(ctx context.Context, email string) (*domain.Account, error) { + uow, err := as.tx.NewTX(ctx) + if err != nil { + return nil, fmt.Errorf("открытие транзакции login: %w", err) + } + defer func() { _ = uow.Rollback() }() + + account, err := uow.Accounts().GetByEmail(ctx, email) + if err != nil { + return nil, err + } + if err := uow.Commit(); err != nil { + return nil, fmt.Errorf("коммит чтения аккаунта: %w", err) + } + return account, nil +} + +func (as *AuthService) saveRefreshSession(ctx context.Context, pair *domain.TokenPair, userID, familyID uuid.UUID) error { + uow, err := as.tx.NewTX(ctx) + if err != nil { + return fmt.Errorf("открытие транзакции refresh-сессии: %w", err) + } + defer func() { _ = uow.Rollback() }() + if err := uow.Tokens().SaveRefreshToken(ctx, pair.JTI, userID, familyID, pair.ExpiresAt); err != nil { + return fmt.Errorf("сохранение refresh-сессии: %w", err) + } + if err := uow.Commit(); err != nil { + return fmt.Errorf("коммит refresh-сессии: %w", err) + } + return nil +} + +func (as *AuthService) revokeFamilyAfterReuse(ctx context.Context, uow domain.UnitOfWork, familyID uuid.UUID) error { + if err := uow.Tokens().RevokeTokenFamily(ctx, familyID); err != nil { + return fmt.Errorf("отзыв семейства сессий после reuse: %w", err) + } + if err := uow.Commit(); err != nil { + return fmt.Errorf("коммит отзыва семейства после reuse: %w", err) + } + return domain.ErrRefreshTokenReuse +} + +func (as *AuthService) checkRateLimit(ctx context.Context, operation, identity string) error { + if err := as.cache.CheckRateLimit(ctx, "auth:"+operation+":"+identity); err != nil { + return fmt.Errorf("rate limit %s: %w", operation, err) + } + return nil +} + +func validateRegistration(email, password, name string) error { + if !validEmail(email) { + return domain.ErrInvalidEmail + } + if len(password) < minPasswordLen || len(password) > maxPasswordLen { + return fmt.Errorf("длина пароля должна быть от %d до %d байт: %w", minPasswordLen, maxPasswordLen, domain.ErrInvalidPassword) + } + nameLen := utf8.RuneCountInString(name) + if nameLen < minAccountNameLen || nameLen > maxAccountNameLen { + return fmt.Errorf("длина имени должна быть от %d до %d символов: %w", minAccountNameLen, maxAccountNameLen, domain.ErrInvalidName) + } + return nil +} + +func validEmail(email string) bool { + return email != "" && len(email) <= maxEmailLen && emailPattern.MatchString(email) +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func compareDummyPassword(password string) { + if len(password) > maxPasswordLen { + password = password[:maxPasswordLen] + } + _ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password)) +} + +func loginRateIdentity(ip, email string) string { + digest := sha256.Sum256([]byte(email)) + return fmt.Sprintf("%s:%x", ip, digest[:8]) +} + +func publicTokenPair(pair *domain.TokenPair) *domain.TokenPair { + return &domain.TokenPair{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + ExpiresIn: pair.ExpiresIn, + } +} diff --git a/internal/usecase/auth_test.go b/internal/usecase/auth_test.go new file mode 100644 index 0000000..71dfdb3 --- /dev/null +++ b/internal/usecase/auth_test.go @@ -0,0 +1,411 @@ +package usecase + +import ( + "context" + "errors" + "io" + "log/slog" + "processing/internal/decimal" + "processing/internal/domain" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestAuthRegisterNormalizesAndPersistsAccount(t *testing.T) { + accounts := &fakeAccounts{} + uow := &fakeUOW{accounts: accounts, tokens: &fakeTokens{}} + cache := &fakeAuthCache{} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{uow}}, cache, &fakeTokenManager{}) + + account, err := service.Register(context.Background(), " User@Example.COM ", "password123", " Test User ", "127.0.0.1") + + require.NoError(t, err) + require.NotNil(t, account) + assert.Equal(t, "user@example.com", account.Email) + assert.Equal(t, "Test User", account.Name) + assert.Equal(t, domain.RoleUser, account.Role) + assert.True(t, uow.committed) + require.Len(t, cache.keys, 1) + assert.Equal(t, "auth:register:127.0.0.1", cache.keys[0]) + require.NotNil(t, accounts.created) + assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(accounts.created.PasswordHash), []byte("password123"))) +} + +func TestAuthRegisterRejectsPasswordOverBcryptLimit(t *testing.T) { + service := newTestAuthService(&fakeTxFactory{}, &fakeAuthCache{}, &fakeTokenManager{}) + + _, err := service.Register(context.Background(), "user@example.com", string(make([]byte, maxPasswordLen+1)), "Test User", "127.0.0.1") + + assert.ErrorIs(t, err, domain.ErrInvalidPassword) +} + +func TestAuthLoginDoesNotMaskDatabaseFailure(t *testing.T) { + dbErr := errors.New("database unavailable") + readUOW := &fakeUOW{ + accounts: &fakeAccounts{getByEmailErr: dbErr}, + tokens: &fakeTokens{}, + } + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{readUOW}}, &fakeAuthCache{}, &fakeTokenManager{}) + + _, err := service.Login(context.Background(), "user@example.com", "password123", "127.0.0.1") + + assert.ErrorIs(t, err, dbErr) + assert.NotErrorIs(t, err, domain.ErrInvalidCredentials) +} + +func TestAuthLoginUnknownAccountReturnsInvalidCredentials(t *testing.T) { + readUOW := &fakeUOW{ + accounts: &fakeAccounts{getByEmailErr: domain.ErrAccountNotFound}, + tokens: &fakeTokens{}, + } + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{readUOW}}, &fakeAuthCache{}, &fakeTokenManager{}) + + _, err := service.Login(context.Background(), "missing@example.com", "password123", "127.0.0.1") + + assert.ErrorIs(t, err, domain.ErrInvalidCredentials) +} + +func TestAuthLoginCommitsReadBeforePasswordCheckAndSavesSession(t *testing.T) { + userID := uuid.New() + hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost) + require.NoError(t, err) + readUOW := &fakeUOW{ + accounts: &fakeAccounts{byEmail: &domain.Account{ID: userID, Email: "user@example.com", PasswordHash: string(hash), Role: domain.RoleUser}}, + tokens: &fakeTokens{}, + } + writeTokens := &fakeTokens{} + writeUOW := &fakeUOW{accounts: &fakeAccounts{}, tokens: writeTokens} + expiresAt := time.Now().Add(time.Hour) + manager := &fakeTokenManager{generated: &domain.TokenPair{ + AccessToken: "access", RefreshToken: "refresh", ExpiresIn: 900, JTI: "new-jti", ExpiresAt: expiresAt, + }} + cache := &fakeAuthCache{} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{readUOW, writeUOW}}, cache, manager) + + pair, err := service.Login(context.Background(), " USER@example.com ", "password123", "127.0.0.1") + + require.NoError(t, err) + assert.Equal(t, "access", pair.AccessToken) + assert.Empty(t, pair.JTI) + assert.True(t, readUOW.committed) + assert.True(t, writeUOW.committed) + assert.Equal(t, "new-jti", writeTokens.savedJTI) + assert.Equal(t, userID, writeTokens.savedUserID) + assert.NotEqual(t, uuid.Nil, writeTokens.savedFamilyID) + require.Len(t, cache.keys, 1) + assert.Contains(t, cache.keys[0], "auth:login:127.0.0.1:") +} + +func TestAuthLoginRollsBackWhenSessionCannotBeSaved(t *testing.T) { + userID := uuid.New() + hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost) + require.NoError(t, err) + readUOW := &fakeUOW{ + accounts: &fakeAccounts{byEmail: &domain.Account{ID: userID, PasswordHash: string(hash), Role: domain.RoleUser}}, + tokens: &fakeTokens{}, + } + saveErr := errors.New("save failed") + writeUOW := &fakeUOW{accounts: &fakeAccounts{}, tokens: &fakeTokens{saveErr: saveErr}} + manager := &fakeTokenManager{generated: &domain.TokenPair{ + JTI: "new-jti", ExpiresAt: time.Now().Add(time.Hour), + }} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{readUOW, writeUOW}}, &fakeAuthCache{}, manager) + + _, err = service.Login(context.Background(), "user@example.com", "password123", "127.0.0.1") + + assert.ErrorIs(t, err, saveErr) + assert.False(t, writeUOW.committed) + assert.True(t, writeUOW.rolled) +} + +func TestDummyHashMatchesConfiguredBcryptCost(t *testing.T) { + cost, err := bcrypt.Cost([]byte(dummyHash)) + require.NoError(t, err) + assert.Equal(t, bcryptCost, cost) +} + +func TestAuthRefreshRejectsClaimsSessionMismatch(t *testing.T) { + userID := uuid.New() + uow := &fakeUOW{ + accounts: &fakeAccounts{}, + tokens: &fakeTokens{session: &domain.RefreshSession{ + UserID: userID, FamilyID: uuid.New(), ExpiresAt: time.Now().Add(time.Hour), + }}, + } + manager := &fakeTokenManager{claims: refreshClaims(uuid.New().String(), "old-jti")} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{uow}}, &fakeAuthCache{}, manager) + + _, err := service.Refresh(context.Background(), "refresh", "127.0.0.1") + + assert.ErrorIs(t, err, domain.ErrInvalidRefreshToken) + assert.False(t, uow.committed) + assert.Empty(t, uow.tokens.(*fakeTokens).revokedJTI) +} + +func TestAuthRefreshRotatesSessionAtomically(t *testing.T) { + userID := uuid.New() + familyID := uuid.New() + tokens := &fakeTokens{session: &domain.RefreshSession{UserID: userID, FamilyID: familyID, ExpiresAt: time.Now().Add(time.Hour)}} + uow := &fakeUOW{ + accounts: &fakeAccounts{byID: &domain.Account{ID: userID, Role: domain.RoleUser}}, + tokens: tokens, + } + manager := &fakeTokenManager{ + claims: refreshClaims(userID.String(), "old-jti"), + generated: &domain.TokenPair{ + AccessToken: "new-access", RefreshToken: "new-refresh", ExpiresIn: 900, + JTI: "new-jti", ExpiresAt: time.Now().Add(time.Hour), + }, + } + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{uow}}, &fakeAuthCache{}, manager) + + pair, err := service.Refresh(context.Background(), "refresh", "127.0.0.1") + + require.NoError(t, err) + assert.Equal(t, "new-access", pair.AccessToken) + assert.Equal(t, "old-jti", tokens.revokedJTI) + assert.Equal(t, "new-jti", tokens.savedJTI) + assert.Equal(t, familyID, tokens.savedFamilyID) + assert.True(t, uow.committed) +} + +func TestAuthRefreshReuseRevokesTokenFamily(t *testing.T) { + userID := uuid.New() + familyID := uuid.New() + tokens := &fakeTokens{session: &domain.RefreshSession{ + UserID: userID, FamilyID: familyID, Revoked: true, ExpiresAt: time.Now().Add(time.Hour), + }} + uow := &fakeUOW{accounts: &fakeAccounts{}, tokens: tokens} + manager := &fakeTokenManager{claims: refreshClaims(userID.String(), "old-jti")} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{uow}}, &fakeAuthCache{}, manager) + + _, err := service.Refresh(context.Background(), "refresh", "127.0.0.1") + + assert.ErrorIs(t, err, domain.ErrRefreshTokenReuse) + assert.Equal(t, familyID, tokens.revokedFamilyID) + assert.Equal(t, uuid.Nil, tokens.revokedAllUserID) + assert.True(t, uow.committed) +} + +func TestAuthRefreshConcurrentConsumeIsTreatedAsReuse(t *testing.T) { + userID := uuid.New() + familyID := uuid.New() + tokens := &fakeTokens{ + session: &domain.RefreshSession{UserID: userID, FamilyID: familyID, ExpiresAt: time.Now().Add(time.Hour)}, + revokeErr: domain.ErrRefreshTokenNotFound, + } + uow := &fakeUOW{accounts: &fakeAccounts{}, tokens: tokens} + manager := &fakeTokenManager{claims: refreshClaims(userID.String(), "old-jti")} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{uow}}, &fakeAuthCache{}, manager) + + _, err := service.Refresh(context.Background(), "refresh", "127.0.0.1") + + assert.ErrorIs(t, err, domain.ErrRefreshTokenReuse) + assert.Equal(t, familyID, tokens.revokedFamilyID) + assert.Equal(t, uuid.Nil, tokens.revokedAllUserID) + assert.True(t, uow.committed) +} + +func TestAuthRefreshUsesInjectedClockForSessionExpiry(t *testing.T) { + now := time.Date(2026, time.August, 10, 12, 0, 0, 0, time.UTC) + userID := uuid.New() + tokens := &fakeTokens{session: &domain.RefreshSession{UserID: userID, FamilyID: uuid.New(), ExpiresAt: now}} + uow := &fakeUOW{accounts: &fakeAccounts{}, tokens: tokens} + manager := &fakeTokenManager{claims: refreshClaims(userID.String(), "old-jti")} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{uow}}, &fakeAuthCache{}, manager) + service.now = func() time.Time { return now } + + _, err := service.Refresh(context.Background(), "refresh", "127.0.0.1") + + assert.ErrorIs(t, err, domain.ErrRefreshTokenExpired) + assert.Equal(t, "old-jti", tokens.revokedJTI) + assert.True(t, uow.committed) +} + +func TestAuthLogoutAllIsIdempotent(t *testing.T) { + uow := &fakeUOW{accounts: &fakeAccounts{}, tokens: &fakeTokens{revokeAllErr: domain.ErrUserNotFound}} + service := newTestAuthService(&fakeTxFactory{uows: []domain.UnitOfWork{uow}}, &fakeAuthCache{}, &fakeTokenManager{}) + + err := service.LogoutAll(context.Background(), uuid.New()) + + require.NoError(t, err) + assert.True(t, uow.committed) +} + +func TestAuthRateLimitErrorKeepsDomainCause(t *testing.T) { + service := newTestAuthService(&fakeTxFactory{}, &fakeAuthCache{err: domain.ErrRateLimited}, &fakeTokenManager{}) + + _, err := service.Login(context.Background(), "user@example.com", "password123", "127.0.0.1") + + assert.ErrorIs(t, err, domain.ErrRateLimited) +} + +func refreshClaims(userID, jti string) *domain.RefreshClaims { + claims := &domain.RefreshClaims{UserID: userID} + claims.ID = jti + return claims +} + +func newTestAuthService(tx domain.TxUOW, cache domain.Cache, manager tokenManager) *AuthService { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewAuthService(tx, cache, log, manager) +} + +type fakeAuthCache struct { + keys []string + err error +} + +func (f *fakeAuthCache) CheckRateLimit(_ context.Context, key string) error { + f.keys = append(f.keys, key) + return f.err +} + +func (f *fakeAuthCache) IdempotencyCheck(context.Context, string, time.Duration) error { return nil } + +type fakeTokenManager struct { + generated *domain.TokenPair + generateErr error + claims *domain.RefreshClaims + validateErr error +} + +func (f *fakeTokenManager) GenerateTokenPair(string, string) (*domain.TokenPair, error) { + return f.generated, f.generateErr +} + +func (f *fakeTokenManager) ValidateRefreshToken(string) (*domain.RefreshClaims, error) { + return f.claims, f.validateErr +} + +type fakeTxFactory struct { + mu sync.Mutex + uows []domain.UnitOfWork + err error +} + +func (f *fakeTxFactory) NewTX(context.Context) (domain.UnitOfWork, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return nil, f.err + } + if len(f.uows) == 0 { + return nil, errors.New("unexpected transaction") + } + uow := f.uows[0] + f.uows = f.uows[1:] + return uow, nil +} + +type fakeUOW struct { + accounts domain.AccountsStorage + tokens domain.TokenStorage + committed bool + rolled bool + commitErr error +} + +func (f *fakeUOW) Accounts() domain.AccountsStorage { return f.accounts } +func (f *fakeUOW) Transactions() domain.TransactionStorage { return fakeTransactionStorage{} } +func (f *fakeUOW) Tokens() domain.TokenStorage { return f.tokens } +func (f *fakeUOW) Commit() error { f.committed = true; return f.commitErr } +func (f *fakeUOW) Rollback() error { f.rolled = true; return nil } + +type fakeAccounts struct { + created *domain.Account + createErr error + byID *domain.Account + getByIDErr error + byEmail *domain.Account + getByEmailErr error +} + +func (f *fakeAccounts) Create(_ context.Context, account *domain.Account) error { + f.created = account + return f.createErr +} + +func (f *fakeAccounts) GetById(context.Context, uuid.UUID) (*domain.Account, error) { + return f.byID, f.getByIDErr +} + +func (f *fakeAccounts) GetByEmail(context.Context, string) (*domain.Account, error) { + return f.byEmail, f.getByEmailErr +} + +func (*fakeAccounts) Sub(context.Context, uuid.UUID, decimal.Decimal) error { return nil } +func (*fakeAccounts) Add(context.Context, uuid.UUID, decimal.Decimal) error { return nil } + +type fakeTokens struct { + session *domain.RefreshSession + getErr error + savedJTI string + savedUserID uuid.UUID + savedFamilyID uuid.UUID + saveErr error + revokedJTI string + revokeErr error + revokedFamilyID uuid.UUID + revokeFamilyErr error + revokedAllUserID uuid.UUID + revokeAllErr error +} + +func (f *fakeTokens) SaveRefreshToken(_ context.Context, jti string, userID, familyID uuid.UUID, _ time.Time) error { + f.savedJTI = jti + f.savedUserID = userID + f.savedFamilyID = familyID + return f.saveErr +} + +func (f *fakeTokens) GetRefreshToken(context.Context, string) (*domain.RefreshSession, error) { + return f.session, f.getErr +} + +func (f *fakeTokens) RevokeRefreshToken(_ context.Context, jti string) error { + f.revokedJTI = jti + return f.revokeErr +} + +func (f *fakeTokens) RevokeTokenFamily(_ context.Context, familyID uuid.UUID) error { + f.revokedFamilyID = familyID + return f.revokeFamilyErr +} + +func (f *fakeTokens) RevokeAllUserTokens(_ context.Context, userID uuid.UUID) error { + f.revokedAllUserID = userID + return f.revokeAllErr +} + +type fakeTransactionStorage struct{} + +func (fakeTransactionStorage) Transaction(context.Context, *domain.Transaction) error { return nil } +func (fakeTransactionStorage) UpdateStatus(context.Context, *domain.Transaction, domain.TransactionStatus) error { + return nil +} +func (*fakeAccounts) LockForTransfer(context.Context, uuid.UUID, uuid.UUID) error { return nil } +func (fakeTransactionStorage) TryCreateIdempotency(context.Context, *domain.TransferIdempotency) (bool, error) { + return false, nil +} +func (fakeTransactionStorage) GetIdempotency(context.Context, uuid.UUID, string) (domain.TransferIdempotency, error) { + return domain.TransferIdempotency{}, nil +} +func (fakeTransactionStorage) CompleteIdempotency(context.Context, uuid.UUID, string, uuid.UUID) error { + return nil +} +func (fakeTransactionStorage) GetByID(context.Context, uuid.UUID) (domain.Transaction, error) { + return domain.Transaction{}, nil +} +func (fakeTransactionStorage) GetTransactions(context.Context, domain.TransactionFilter) ([]domain.Transaction, error) { + return nil, nil +} +func (fakeTransactionStorage) TotalTransactions(context.Context, uuid.UUID) (int, error) { + return 0, nil +} diff --git a/internal/usecase/transactions.go b/internal/usecase/transactions.go index 56bed9a..f38f085 100644 --- a/internal/usecase/transactions.go +++ b/internal/usecase/transactions.go @@ -2,176 +2,300 @@ package usecase import ( "context" - "fmt" - "io" + "crypto/sha256" + "encoding/hex" + "errors" "log/slog" - "os" "processing/internal/decimal" "processing/internal/domain" + "processing/internal/infrastructure/logger" "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" ) -type TransferService struct { +const ( + transferOperationVersion = "transfer:v1" + transferMaxAttempts = 3 + transferRetryBaseDelay = 25 * time.Millisecond + moneyPrecision = 36 + moneyScale = 18 +) + +type transferCommand struct { + SenderID uuid.UUID + ReceiverID uuid.UUID + Key string + Amount decimal.Decimal + RequestFingerprint string +} + +type TransactionsService struct { tx domain.TxUOW cache domain.Cache log *slog.Logger } -func NewService(tx domain.TxUOW, cache domain.Cache, loggerPath string) *TransferService { - file, err := os.OpenFile(loggerPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - panic(err) - } - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - slog.SetDefault(logger) - slog.Info("создан логгер") - return &TransferService{ - tx: tx, +func NewTransactionsService(txUOW domain.TxUOW, cache domain.Cache, log *slog.Logger) *TransactionsService { + log = logger.WithService(log, "Transactions") + return &TransactionsService{ + tx: txUOW, cache: cache, - log: logger, + log: log, } } -// Transfer - главная функция процессинга. Создает транзакцию. -// Как работает: вычет с балансов аккаунтов -> создание транзакции -// принимает контекст, ключ для redis, sender_id, receiver_id, amount -func (ts *TransferService) Transfer( +// Transfer атомарно меняет балансы, создаёт транзакцию и сохраняет +// результат по Idempotency-Key в одной SQL-транзакции. +func (ts *TransactionsService) Transfer( ctx context.Context, sender_id, receiver_id uuid.UUID, key string, amount decimal.Decimal, -) error { - if err := ts.cache.CheckRateLimit(ctx, sender_id); err != nil { - ts.log.Error("CheckRateLimit", "err", err) - return err +) (string, error) { + if err := domain.ValidateIdempotencyKey(key); err != nil { + return "", err + } + if !amount.IsPositive() || !amount.FitsNumeric(moneyPrecision, moneyScale) { + return "", domain.ErrInvalidAmount + } + + command := transferCommand{ + SenderID: sender_id, + ReceiverID: receiver_id, + Key: key, + Amount: amount, } - //проверка идемпотентности запроса - if err := ts.cache.IdempotencyCheck(ctx, key, 1, 24*time.Hour); err != nil { - ts.log.Error("IdempotencyCheck", "err", err) - return err + command.RequestFingerprint = transferFingerprint(command) + + rateLimitChecked := false + for attempt := 1; attempt <= transferMaxAttempts; attempt++ { + transactionID, err := ts.transferAttempt(ctx, command, &rateLimitChecked) + if err == nil { + return transactionID, nil + } + if !isRetryablePostgresError(err) || attempt == transferMaxAttempts { + return "", err + } + + delay := time.Duration(attempt) * transferRetryBaseDelay + ts.log.WarnContext(ctx, "retrying transfer after PostgreSQL concurrency error", "attempt", attempt, "delay", delay, "err", err) + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return "", ctx.Err() + case <-timer.C: + } } - //новая транзакция + return "", errors.New("transfer retry loop exhausted") +} + +func (ts *TransactionsService) transferAttempt(ctx context.Context, command transferCommand, rateLimitChecked *bool) (string, error) { uow, err := ts.tx.NewTX(ctx) if err != nil { - ts.log.Error("NewTX", "err", err) - return err + ts.log.ErrorContext(ctx, "ошибка создания транзакции БД", "err", err) + return "", err } defer uow.Rollback() - //получаем пользователей по айди валидации - sender, err := uow.Accounts().GetById(ctx, sender_id) + // Проверка на перевод самому себе + if command.SenderID == command.ReceiverID { + return "", domain.ErrSameAccount + } + + // Блокируем оба счета в определённом порядке (ORDER BY id в SQL) + // чтобы предотвратить дедлоки при встречных переводах + if err := uow.Accounts().LockForTransfer(ctx, command.SenderID, command.ReceiverID); err != nil { + ts.log.ErrorContext(ctx, "ошибка блокировки счетов", "err", err, "sender_id", command.SenderID, "receiver_id", command.ReceiverID) + return "", err + } + + replayID, err := ts.resolveIdempotency(ctx, uow.Transactions(), command) if err != nil { - ts.log.Error("Accounts.GetById", "err", err) - return err + return "", err + } + if replayID != nil { + return replayID.String(), nil } - receiver, err := uow.Accounts().GetById(ctx, receiver_id) + + if !*rateLimitChecked { + if err := ts.cache.CheckRateLimit(ctx, command.SenderID.String()); err != nil { + ts.log.WarnContext(ctx, "transfer rate limit rejected", "sender_id", command.SenderID) + return "", err + } + *rateLimitChecked = true + } + + tx, err := ts.executeTransfer(ctx, uow, command) if err != nil { - ts.log.Error("Account.GetById", "err", err) - return err + return "", err } - //валидация - if err := domain.ValidateTransferRequest(sender.ID, receiver.ID, sender.Balance, amount); err != nil { - return err + + if err := uow.Transactions().CompleteIdempotency(ctx, command.SenderID, command.Key, tx.ID); err != nil { + ts.log.ErrorContext(ctx, "ошибка сохранения результа идемпотентности", "err", err, "transaction_id", tx.ID) + return "", err } - //сначала вычитаем сумму с баланса отправителя - if err := uow.Accounts().Sub(ctx, sender_id, amount); err != nil { - ts.log.Error("DB substituion", "err", err) - return err + + if err := uow.Commit(); err != nil { + ts.log.ErrorContext(ctx, "ошибка коммита транзакции БД", "err", err, "transaction_id", tx.ID) + return "", err } - //затем прибавляем сумму на баланс получателя - if err := uow.Accounts().Add(ctx, receiver_id, amount); err != nil { - ts.log.Error("DB Amount add", "err", err) - return err + + ts.log.InfoContext(ctx, "транзакция успешно завершена", "transaction_id", tx.ID, "sender_id", command.SenderID, "receiver_id", command.ReceiverID, "amount", command.Amount) + return tx.ID.String(), nil +} + +func isRetryablePostgresError(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false } + return pgErr.Code == "40001" || pgErr.Code == "40P01" +} - //создание транзакции - tx, err := domain.NewTransaction(amount, sender_id, receiver_id) +func (ts *TransactionsService) resolveIdempotency( + ctx context.Context, + repo domain.TransactionStorage, + command transferCommand, +) (*uuid.UUID, error) { + record := &domain.TransferIdempotency{ + SenderID: command.SenderID, + Key: command.Key, + RequestFingerprint: command.RequestFingerprint, + } + created, err := repo.TryCreateIdempotency(ctx, record) if err != nil { - ts.log.Error("creating domain.Transaction", "err", err) - return err + ts.log.ErrorContext(ctx, "ошибка резервирования Idempotency-Key", "err", err, "sender_id", command.SenderID) + return nil, err + } + if created { + return nil, nil } - if err := uow.Transactions().Transaction(ctx, tx); err != nil { - ts.log.Error("DB transaction creating", "err", err) - return err + existing, err := repo.GetIdempotency(ctx, command.SenderID, command.Key) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка получения результата идемпотентности", "err", err, "sender_id", command.SenderID) + return nil, err + } + if existing.RequestFingerprint != command.RequestFingerprint { + ts.log.WarnContext(ctx, "Idempotency-Key повторно использован с другим payload", "sender_id", command.SenderID) + return nil, domain.ErrIdempotencyConflict + } + if existing.Status != domain.IdempotencyStatusCompleted || existing.TransactionID == nil { + return nil, domain.ErrIdempotencyInProgress + } + + ts.log.InfoContext(ctx, "возвращён результат повторного перевода", "transaction_id", *existing.TransactionID, "sender_id", command.SenderID) + return existing.TransactionID, nil +} + +func (ts *TransactionsService) executeTransfer( + ctx context.Context, + uow domain.UnitOfWork, + command transferCommand, +) (*domain.Transaction, error) { + // Счета уже заблокированы в transferAttempt + if err := uow.Accounts().Sub(ctx, command.SenderID, command.Amount); err != nil { + ts.log.ErrorContext(ctx, "ошибка вычисления суммы со счета отправителя", "err", err, "sender_id", command.SenderID, "amount", command.Amount) + return nil, err + } + if err := uow.Accounts().Add(ctx, command.ReceiverID, command.Amount); err != nil { + ts.log.ErrorContext(ctx, "ошибка добавления суммы на счет получателя", "err", err, "receiver_id", command.ReceiverID, "amount", command.Amount) + return nil, err } + tx, err := domain.NewTransaction(command.Amount, command.SenderID, command.ReceiverID) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка создания объекта транзакции", "err", err, "sender_id", command.SenderID, "receiver_id", command.ReceiverID) + return nil, err + } + if err := uow.Transactions().Transaction(ctx, tx); err != nil { + ts.log.ErrorContext(ctx, "ошибка сохранения транзакции в БД", "err", err, "transaction_id", tx.ID) + return nil, err + } if err := uow.Transactions().UpdateStatus(ctx, tx, domain.StatusCompleted); err != nil { - ts.log.Error("update status", "err", err) - return err + ts.log.ErrorContext(ctx, "ошибка обновления статуса транзакции", "err", err, "transaction_id", tx.ID) + return nil, err } - return uow.Commit() + return tx, nil } -func (ts *TransferService) GetTransaction( +func transferFingerprint(command transferCommand) string { + payload := transferOperationVersion + "\x00" + command.SenderID.String() + "\x00" + command.ReceiverID.String() + "\x00" + command.Amount.String() + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) +} + +func (ts *TransactionsService) GetTransaction( ctx context.Context, transactionID, userID uuid.UUID, key string, ) (domain.Transaction, error) { - if err := ts.cache.CheckRateLimit(ctx, userID); err != nil { - ts.log.Error("CheckRateLimit", "err", err) + if err := ts.cache.CheckRateLimit(ctx, userID.String()); err != nil { + ts.log.WarnContext(ctx, "превышен лимит запросов при получении транзакции", "user_id", userID) return domain.Transaction{}, err } - if err := ts.cache.IdempotencyCheck(ctx, key, 10, time.Minute); err != nil { - ts.log.Error("IdempotencyCheck", "err", err) - return domain.Transaction{}, err - } uow, err := ts.tx.NewTX(ctx) if err != nil { - ts.log.Error("NewTX", "err", err) - return domain.Transaction{}, fmt.Errorf("ошибка начала транзакции бд: %w", err) + ts.log.ErrorContext(ctx, "ошибка создания транзакции БД", "err", err) + return domain.Transaction{}, err } defer uow.Rollback() transaction, err := uow.Transactions().GetByID(ctx, transactionID) if err != nil { - ts.log.Error("Transactions.GetByID", "err", err) - return domain.Transaction{}, fmt.Errorf("ошибка получения транзакции из бд: %w", err) + ts.log.ErrorContext(ctx, "ошибка получения транзакции из БД", "err", err, "transaction_id", transactionID) + return domain.Transaction{}, err + } + + if transaction.Sender_id != userID && transaction.Receiver_id != userID { + ts.log.WarnContext(ctx, "попытка доступа к чужой транзакции", "user_id", userID, "transaction_id", transactionID) + return domain.Transaction{}, domain.ErrAccessDenied + } + + if err := uow.Commit(); err != nil { + ts.log.ErrorContext(ctx, "ошибка коммита транзакции БД", "err", err) + return domain.Transaction{}, err } - uow.Commit() return transaction, nil } -func (ts *TransferService) GetTransactionFilter( +func (ts *TransactionsService) GetTransactionFilter( ctx context.Context, t *domain.TransactionFilter, userID uuid.UUID, key string, ) ([]domain.Transaction, error) { - if err := ts.cache.CheckRateLimit(ctx, userID); err != nil { - ts.log.Error("CheckRateLimit", "err", err) - return nil, err - } - - if err := ts.cache.IdempotencyCheck(ctx, key, 10, time.Minute); err != nil { - ts.log.Error("IdempotencyCheck", "err", err) + if err := ts.cache.CheckRateLimit(ctx, userID.String()); err != nil { + ts.log.WarnContext(ctx, "превышен лимит запросов при фильтрации транзакций", "user_id", userID) return nil, err } uow, err := ts.tx.NewTX(ctx) if err != nil { - ts.log.Error("NewTX", "err", err) - return nil, fmt.Errorf("ошибка начала транзакции бд: %w", err) + ts.log.ErrorContext(ctx, "ошибка создания транзакции БД", "err", err) + return nil, err } defer uow.Rollback() transactions, err := uow.Transactions().GetTransactions(ctx, *t) if err != nil { - ts.log.Error("Transactions.GetTransactions", "err", err) - return nil, fmt.Errorf("ошибка получения транзакций из бд: %w", err) + ts.log.ErrorContext(ctx, "ошибка получения транзакций из БД", "err", err, "account_id", t.AccountID) + return nil, err } if err := uow.Commit(); err != nil { - ts.log.Error("Commit", "err", err) - return nil, fmt.Errorf("ошибка коммита транзакции: %w", err) + ts.log.ErrorContext(ctx, "ошибка коммита транзакции БД", "err", err) + return nil, err } + ts.log.InfoContext(ctx, "транзакции по фильтру получены", "user_id", userID, "count", len(transactions)) return transactions, nil } diff --git a/internal/usecase/transactions_test.go b/internal/usecase/transactions_test.go new file mode 100644 index 0000000..d7e49c9 --- /dev/null +++ b/internal/usecase/transactions_test.go @@ -0,0 +1,323 @@ +package usecase + +import ( + "context" + "errors" + "io" + "log/slog" + "processing/internal/decimal" + "processing/internal/domain" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransferPersistsIdempotencyWithMoneyMovement(t *testing.T) { + senderID := uuid.MustParse("10000000-0000-0000-0000-000000000001") + receiverID := uuid.MustParse("10000000-0000-0000-0000-000000000002") + amount, err := decimal.NewFromString("125.50") + require.NoError(t, err) + + repo := &transferUnitTxRepo{createIdempotency: true} + accounts := transferUnitAccounts(senderID, receiverID) + uow := &transferUnitUOW{accounts: accounts, transactions: repo} + cache := &transferUnitCache{} + service := newTransferUnitService(&transferUnitFactory{uows: []*transferUnitUOW{uow}}, cache) + + transactionID, err := service.Transfer(context.Background(), senderID, receiverID, "transfer-key-1", amount) + + require.NoError(t, err) + require.NotEmpty(t, transactionID) + require.NotNil(t, repo.savedTransaction) + assert.Equal(t, repo.savedTransaction.ID.String(), transactionID) + assert.Equal(t, repo.savedTransaction.ID, repo.completedTransactionID) + assert.Equal(t, senderID, repo.reserved.SenderID) + assert.Equal(t, "transfer-key-1", repo.reserved.Key) + assert.Len(t, repo.reserved.RequestFingerprint, 64) + assert.Equal(t, 1, accounts.subCalls) + assert.Equal(t, 1, accounts.addCalls) + assert.Equal(t, 1, cache.rateLimitCalls) + assert.True(t, uow.committed) +} + +func TestTransferReplayReturnsStoredResultBeforeRedis(t *testing.T) { + senderID := uuid.MustParse("20000000-0000-0000-0000-000000000001") + receiverID := uuid.MustParse("20000000-0000-0000-0000-000000000002") + transactionID := uuid.MustParse("20000000-0000-0000-0000-000000000003") + amount, err := decimal.NewFromString("10.00") + require.NoError(t, err) + + repo := &transferUnitTxRepo{ + createIdempotency: false, + existing: domain.TransferIdempotency{ + SenderID: senderID, + Key: "replay-key", + RequestFingerprint: transferFingerprint(transferCommand{ + SenderID: senderID, + ReceiverID: receiverID, + Amount: amount, + }), + Status: domain.IdempotencyStatusCompleted, + TransactionID: &transactionID, + }, + } + cache := &transferUnitCache{rateLimitErr: errors.New("redis unavailable")} + uow := &transferUnitUOW{ + accounts: transferUnitAccounts(senderID, receiverID), + transactions: repo, + } + service := newTransferUnitService(&transferUnitFactory{uows: []*transferUnitUOW{uow}}, cache) + + result, err := service.Transfer(context.Background(), senderID, receiverID, "replay-key", amount) + + require.NoError(t, err) + assert.Equal(t, transactionID.String(), result) + assert.Equal(t, 0, cache.rateLimitCalls) + assert.Nil(t, repo.savedTransaction) + assert.False(t, uow.committed) +} + +func TestTransferRejectsSameKeyWithDifferentPayload(t *testing.T) { + senderID := uuid.MustParse("30000000-0000-0000-0000-000000000001") + receiverID := uuid.MustParse("30000000-0000-0000-0000-000000000002") + transactionID := uuid.MustParse("30000000-0000-0000-0000-000000000003") + amount, err := decimal.NewFromString("20") + require.NoError(t, err) + + repo := &transferUnitTxRepo{ + existing: domain.TransferIdempotency{ + SenderID: senderID, + Key: "conflict-key", + RequestFingerprint: "different-fingerprint", + Status: domain.IdempotencyStatusCompleted, + TransactionID: &transactionID, + }, + } + cache := &transferUnitCache{} + uow := &transferUnitUOW{ + accounts: transferUnitAccounts(senderID, receiverID), + transactions: repo, + } + service := newTransferUnitService(&transferUnitFactory{uows: []*transferUnitUOW{uow}}, cache) + + _, err = service.Transfer(context.Background(), senderID, receiverID, "conflict-key", amount) + + require.ErrorIs(t, err, domain.ErrIdempotencyConflict) + assert.Equal(t, 0, cache.rateLimitCalls) + assert.Nil(t, repo.savedTransaction) + assert.False(t, uow.committed) +} + +func TestTransferRollbackAllowsRetry(t *testing.T) { + senderID := uuid.MustParse("40000000-0000-0000-0000-000000000001") + receiverID := uuid.MustParse("40000000-0000-0000-0000-000000000002") + amount, err := decimal.NewFromString("50") + require.NoError(t, err) + + failedRepo := &transferUnitTxRepo{createIdempotency: true} + failedAccounts := transferUnitAccounts(senderID, receiverID) + failedAccounts.subErr = domain.ErrInsufficientFunds + failedUOW := &transferUnitUOW{accounts: failedAccounts, transactions: failedRepo} + + successRepo := &transferUnitTxRepo{createIdempotency: true} + successUOW := &transferUnitUOW{ + accounts: transferUnitAccounts(senderID, receiverID), + transactions: successRepo, + } + + service := newTransferUnitService( + &transferUnitFactory{uows: []*transferUnitUOW{failedUOW, successUOW}}, + &transferUnitCache{}, + ) + + _, err = service.Transfer(context.Background(), senderID, receiverID, "retry-key", amount) + require.ErrorIs(t, err, domain.ErrInsufficientFunds) + assert.True(t, failedUOW.rolledBack) + assert.Nil(t, failedRepo.savedTransaction) + assert.False(t, failedUOW.committed) +} + +func TestTransferRetriesSerializationFailure(t *testing.T) { + senderID := uuid.MustParse("50000000-0000-0000-0000-000000000001") + receiverID := uuid.MustParse("50000000-0000-0000-0000-000000000002") + amount, err := decimal.NewFromString("15.00") + require.NoError(t, err) + + failed := &transferUnitUOW{ + accounts: transferUnitAccounts(senderID, receiverID), + transactions: &transferUnitTxRepo{ + createIdempotency: true, + createIdempotencyErr: &pgconn.PgError{Code: "40001"}, + }, + } + + success := &transferUnitUOW{ + accounts: transferUnitAccounts(senderID, receiverID), + transactions: &transferUnitTxRepo{createIdempotency: true}, + } + + cache := &transferUnitCache{} + service := newTransferUnitService( + &transferUnitFactory{uows: []*transferUnitUOW{failed, success}}, + cache, + ) + + transactionID, err := service.Transfer(context.Background(), senderID, receiverID, "conflict-replay", amount) + + require.NoError(t, err) + assert.NotEmpty(t, transactionID) + assert.True(t, failed.rolledBack) + assert.True(t, success.committed) + assert.Equal(t, 1, cache.rateLimitCalls) +} + +func TestTransferRejectsAmountOutsideDatabaseNumericBeforeOpeningTransaction(t *testing.T) { + service := newTransferUnitService(&transferUnitFactory{}, &transferUnitCache{}) + amount, err := decimal.NewFromString("1000000000000000000") + require.NoError(t, err) + + _, err = service.Transfer(context.Background(), uuid.New(), uuid.New(), "too-large", amount) + + require.ErrorIs(t, err, domain.ErrInvalidAmount) +} + +func newTransferUnitService(factory domain.TxUOW, cache domain.Cache) *TransactionsService { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewTransactionsService(factory, cache, log) +} + +func transferUnitAccounts(senderID, receiverID uuid.UUID) *transferUnitAccountRepo { + return &transferUnitAccountRepo{accounts: map[uuid.UUID]*domain.Account{ + senderID: {ID: senderID}, + receiverID: {ID: receiverID}, + }} +} + +type transferUnitCache struct { + rateLimitCalls int + rateLimitErr error +} + +func (f *transferUnitCache) CheckRateLimit(context.Context, string) error { + f.rateLimitCalls++ + return f.rateLimitErr +} + +func (*transferUnitCache) IdempotencyCheck(context.Context, string, time.Duration) error { + return errors.New("transfer must not use Redis idempotency") +} + +type transferUnitFactory struct { + uows []*transferUnitUOW +} + +func (f *transferUnitFactory) NewTX(context.Context) (domain.UnitOfWork, error) { + if len(f.uows) == 0 { + return nil, errors.New("unexpected transaction") + } + uow := f.uows[0] + f.uows = f.uows[1:] + return uow, nil +} + +type transferUnitUOW struct { + accounts domain.AccountsStorage + transactions domain.TransactionStorage + committed bool + rolledBack bool + commitErr error +} + +func (f *transferUnitUOW) Accounts() domain.AccountsStorage { return f.accounts } +func (f *transferUnitUOW) Transactions() domain.TransactionStorage { + return f.transactions +} +func (*transferUnitUOW) Tokens() domain.TokenStorage { return nil } +func (f *transferUnitUOW) Commit() error { + if f.commitErr != nil { + return f.commitErr + } + f.committed = true + return nil +} +func (f *transferUnitUOW) Rollback() error { + f.rolledBack = true + return nil +} + +type transferUnitAccountRepo struct { + accounts map[uuid.UUID]*domain.Account + subCalls int + addCalls int + subErr error + addErr error +} + +func (*transferUnitAccountRepo) Create(context.Context, *domain.Account) error { return nil } +func (*transferUnitAccountRepo) LockForTransfer(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} +func (f *transferUnitAccountRepo) GetById(_ context.Context, id uuid.UUID) (*domain.Account, error) { + account, ok := f.accounts[id] + if !ok { + return nil, domain.ErrAccountNotFound + } + return account, nil +} +func (*transferUnitAccountRepo) GetByEmail(context.Context, string) (*domain.Account, error) { + return nil, domain.ErrAccountNotFound +} +func (f *transferUnitAccountRepo) Sub(context.Context, uuid.UUID, decimal.Decimal) error { + f.subCalls++ + return f.subErr +} +func (f *transferUnitAccountRepo) Add(context.Context, uuid.UUID, decimal.Decimal) error { + f.addCalls++ + return f.addErr +} + +type transferUnitTxRepo struct { + createIdempotency bool + createIdempotencyErr error + reserved domain.TransferIdempotency + existing domain.TransferIdempotency + getIdempotencyErr error + savedTransaction *domain.Transaction + transactionErr error + updateStatusErr error + completeErr error + completedTransactionID uuid.UUID +} + +func (f *transferUnitTxRepo) Transaction(_ context.Context, transaction *domain.Transaction) error { + f.savedTransaction = transaction + return f.transactionErr +} +func (f *transferUnitTxRepo) UpdateStatus(_ context.Context, transaction *domain.Transaction, status domain.TransactionStatus) error { + transaction.Status = status + return f.updateStatusErr +} +func (f *transferUnitTxRepo) TryCreateIdempotency(_ context.Context, record *domain.TransferIdempotency) (bool, error) { + f.reserved = *record + return f.createIdempotency, f.createIdempotencyErr +} +func (f *transferUnitTxRepo) GetIdempotency(context.Context, uuid.UUID, string) (domain.TransferIdempotency, error) { + return f.existing, f.getIdempotencyErr +} +func (f *transferUnitTxRepo) CompleteIdempotency(_ context.Context, _ uuid.UUID, _ string, transactionID uuid.UUID) error { + f.completedTransactionID = transactionID + return f.completeErr +} +func (*transferUnitTxRepo) GetByID(context.Context, uuid.UUID) (domain.Transaction, error) { + return domain.Transaction{}, nil +} +func (*transferUnitTxRepo) GetTransactions(context.Context, domain.TransactionFilter) ([]domain.Transaction, error) { + return nil, nil +} +func (*transferUnitTxRepo) TotalTransactions(context.Context, uuid.UUID) (int, error) { + return 0, nil +} diff --git a/makefile b/makefile index b2fe163..083a6d5 100644 --- a/makefile +++ b/makefile @@ -1,16 +1,53 @@ -DB_URL = postgres://admin:secret@localhost:5432/postgres_bd +POSTGRES_PORT ?= 5432 +DB_URL ?= postgres://admin:secret@localhost:$(POSTGRES_PORT)/postgres_bd?sslmode=disable + +.PHONY: run stop test lint e2e ci-local compose-up compose-down docker-up docker-down migrate-up migrate-down + +run: + go run ./cmd/server + +stop: + docker compose stop app + +test: + go test ./internal/... + +api-test: + make api-health-test + make api-auth-test + make api-accounts-test + +api-health-test: + curl http://localhost:8080/health || (echo "Health check failed" && exit 1) + curl http://localhost:8080/health/live || (echo "Liveness check failed" && exit 1) + curl http://localhost:8080/health/ready || (echo "Readiness check failed" && exit 1) + +api-auth-test: + go run ./scripts/api-auth-test/main.go + +api-accounts-test: + go run ./scripts/api-accounts-test/main.go + +lint: + go vet ./... + +ci-local: lint test e2e + +e2e: + go test -count=1 ./e2e/... docker-up: - docker compose up -d + docker compose up --build -d --remove-orphans docker-down: - docker compose down + docker compose down --remove-orphans migrate-up: - goose -dir migrations postgres $(DB_URL) up + goose -dir migrations postgres "$(DB_URL)" up migrate-down: - goose -dir migrations postgres $(DB_URL) down + goose -dir migrations postgres "$(DB_URL)" down -run: - go run cmd/main.go \ No newline at end of file +clean: + docker compose down --remove-orphans + docker volume rm processing_postgres_datatestpass \ No newline at end of file diff --git a/migrations/00001_processing.sql b/migrations/00001_processing.sql new file mode 100644 index 0000000..42eb988 --- /dev/null +++ b/migrations/00001_processing.sql @@ -0,0 +1,32 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + balance NUMERIC(36, 18) NOT NULL DEFAULT 0, + CONSTRAINT balance_is_positive CHECK (balance >= 0) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id UUID PRIMARY KEY, + amount NUMERIC(36, 18) NOT NULL, + sender_id UUID NOT NULL REFERENCES accounts(id), + receiver_id UUID NOT NULL REFERENCES accounts(id), + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'completed', 'failed')), + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS refresh_token ( + jti TEXT PRIMARY KEY NOT NULL, + user_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS refresh_token; +DROP TABLE IF EXISTS transactions; +DROP TABLE IF EXISTS accounts; diff --git a/migrations/00001_transaction.sql b/migrations/00001_transaction.sql deleted file mode 100644 index c1f4826..0000000 --- a/migrations/00001_transaction.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - balance NUMERIC(36, 18) NOT NULL -); - -CREATE TABLE IF NOT EXISTS transactions ( - id UUID PRIMARY KEY, - amount NUMERIC(36, 18) NOT NULL, - sender_id UUID NOT NULL REFERENCES accounts(id), - receiver_id UUID NOT NULL REFERENCES accounts(id), - status VARCHAR(20) NOT NULL DEFAULT 'pending', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - - --- +goose Down -DROP TABLE IF EXISTS transactions; -DROP TABLE IF EXISTS accounts; diff --git a/migrations/00002_auth_hardening.sql b/migrations/00002_auth_hardening.sql new file mode 100644 index 0000000..2e60275 --- /dev/null +++ b/migrations/00002_auth_hardening.sql @@ -0,0 +1,15 @@ +-- +goose Up + +CREATE UNIQUE INDEX IF NOT EXISTS accounts_email_lower_uidx + ON accounts (LOWER(email)); + +CREATE INDEX IF NOT EXISTS refresh_token_user_id_idx + ON refresh_token (user_id); + +CREATE INDEX IF NOT EXISTS refresh_token_expires_at_idx + ON refresh_token (expires_at); + +-- +goose Down +DROP INDEX IF EXISTS refresh_token_expires_at_idx; +DROP INDEX IF EXISTS refresh_token_user_id_idx; +DROP INDEX IF EXISTS accounts_email_lower_uidx; diff --git a/migrations/00003_refresh_token_family.sql b/migrations/00003_refresh_token_family.sql new file mode 100644 index 0000000..50432b7 --- /dev/null +++ b/migrations/00003_refresh_token_family.sql @@ -0,0 +1,21 @@ +-- +goose Up + +ALTER TABLE refresh_token + ADD COLUMN IF NOT EXISTS family_id UUID; + +UPDATE refresh_token +SET family_id = gen_random_uuid() +WHERE family_id IS NULL; + +ALTER TABLE refresh_token + ALTER COLUMN family_id SET NOT NULL; + +CREATE INDEX IF NOT EXISTS refresh_token_family_id_idx + ON refresh_token (family_id); + +-- +goose Down + +DROP INDEX IF EXISTS refresh_token_family_id_idx; + +ALTER TABLE refresh_token + DROP COLUMN IF EXISTS family_id; diff --git a/migrations/00004_transfer_idempotency.sql b/migrations/00004_transfer_idempotency.sql new file mode 100644 index 0000000..f50cccc --- /dev/null +++ b/migrations/00004_transfer_idempotency.sql @@ -0,0 +1,21 @@ +-- +goose Up +CREATE TABLE transfer_idempotency ( + sender_id UUID NOT NULL REFERENCES accounts(id), + idempotency_key VARCHAR(128) NOT NULL, + request_fingerprint CHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'processing', + transaction_id UUID UNIQUE REFERENCES transactions(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (sender_id, idempotency_key), + CONSTRAINT transfer_idempotency_status_check + CHECK (status IN ('processing', 'completed')), + CONSTRAINT transfer_idempotency_result_check + CHECK ( + (status = 'processing' AND transaction_id IS NULL) + OR (status = 'completed' AND transaction_id IS NOT NULL) + ) +); + +-- +goose Down +DROP TABLE IF EXISTS transfer_idempotency; diff --git a/migrations/00005_transfer_constraints.sql b/migrations/00005_transfer_constraints.sql new file mode 100644 index 0000000..e6c42c3 --- /dev/null +++ b/migrations/00005_transfer_constraints.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE transactions + ADD CONSTRAINT transactions_amount_positive CHECK (amount > 0), + ADD CONSTRAINT transactions_distinct_accounts CHECK (sender_id <> receiver_id); + +-- +goose Down +ALTER TABLE transactions + DROP CONSTRAINT IF EXISTS transactions_distinct_accounts, + DROP CONSTRAINT IF EXISTS transactions_amount_positive; diff --git a/photo_2026-06-06_10-22-39.jpg b/photo_2026-06-06_10-22-39.jpg deleted file mode 100644 index 8c32825..0000000 Binary files a/photo_2026-06-06_10-22-39.jpg and /dev/null differ diff --git a/scripts/api-accounts-test/main.go b/scripts/api-accounts-test/main.go new file mode 100644 index 0000000..1a53b37 --- /dev/null +++ b/scripts/api-accounts-test/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +func main() { + email := randomEmail() + username := randomUsername() + password := "testpass" + jsonData := `{ + "email": "` + email + `", + "username": "` + username + `", + "password": "` + password + `" + }` + //register + resp, err := http.Post("http://localhost:8080/auth/register", "application/json", bytes.NewBuffer([]byte(jsonData))) + if err != nil { + log.Println(err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 201 { + log.Println(resp.Body) + return + } + id := accountID(resp) + + //accounts/id + req, err := http.NewRequest("GET", "http://localhost:8080/accounts/"+id, nil) + if err != nil { + log.Println(err) + return + } + + resp, err = http.DefaultClient.Do(req) + if err != nil { + log.Println(err) + return + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + log.Println(resp.Body) + return + } + log.Println(resp) + //accounts/id/transactions + req, err = http.NewRequest("GET", "http://localhost:8080/accounts/"+id+"/transactions", nil) + if err != nil { + log.Println(err) + return + } + + resp, err = http.DefaultClient.Do(req) + if err != nil { + log.Println(err) + return + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + log.Println(resp.Body) + return + } + log.Println(resp) +} + +func accountID(r *http.Response) string { + var data struct { + Account struct { + ID string `json:"id"` + } `json:"account"` + } + + _ = json.NewDecoder(r.Body).Decode(&data) + return data.Account.ID +} + +func randomEmail() string { + return fmt.Sprintf("user_%d@example.com", time.Now().Unix()) +} + +func randomUsername() string { + return fmt.Sprintf("user_%d", time.Now().Unix()) +} diff --git a/scripts/api-auth-test/main.go b/scripts/api-auth-test/main.go new file mode 100644 index 0000000..0720ae6 --- /dev/null +++ b/scripts/api-auth-test/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +func main() { + email := randomEmail() + username := randomUsername() + password := "testpass" + + jsonData := `{ + "email": "` + email + `", + "username": "` + username + `", + "password": "` + password + `" + }` + + //register + resp, err := http.Post("http://localhost:8080/auth/register", "application/json", bytes.NewBuffer([]byte(jsonData))) + if err != nil { + log.Println(err) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + log.Println("Register response:", string(body)) + + if resp.StatusCode != 201 { + log.Println(resp.Body) + return + } + + access_token := accessToken(resp) + refresh_token := refreshToken(resp) + log.Println("Register success: ", access_token) + + //login + resp, err = http.Post("http://localhost:8080/auth/login", "application/json", bytes.NewBuffer([]byte(jsonData))) + if err != nil { + log.Println(err) + return + } + log.Println("Login success!") + + //refresh endpoint + refreshData := `{"refresh_token": "` + refresh_token + `"}` + resp, err = http.Post("http://localhost:8080/auth/refresh", "application/json", bytes.NewBuffer([]byte(refreshData))) + if err != nil { + log.Println(err) + return + } + + //logout + req, err := http.NewRequest("POST", "http://localhost:8080/auth/logout", nil) + if err != nil { + log.Println(err) + return + } + req.Header.Set("Authorization", "Bearer "+refresh_token) + req.Header.Set("Cookie", "refresh_token="+refresh_token) + resp, err = http.DefaultClient.Do(req) + if err != nil { + log.Println(err) + return + } + if resp.StatusCode != 200 { + log.Println("Logout failed with status:", resp.StatusCode) + return + } + log.Println("Logout success!") + + //register + email = randomEmail() + username = randomUsername() + password = "testpass" + + jsonData = `{ + "email": "` + email + `", + "username": "` + username + `", + "password": "` + password + `" + }` + + resp, err = http.Post("http://localhost:8080/auth/register", "application/json", bytes.NewBuffer([]byte(jsonData))) + if err != nil { + log.Println(err) + return + } + defer resp.Body.Close() + body, _ = io.ReadAll(resp.Body) + log.Println("Register response:", string(body)) + + if resp.StatusCode != 201 { + log.Println(resp.Body) + return + } + + refresh_token = refreshToken(resp) + + //logout-all + req, err = http.NewRequest("POST", "http://localhost:8080/auth/logout-all", nil) + if err != nil { + log.Println(err) + return + } + req.Header.Set("Authorization", "Bearer "+refresh_token) + resp, err = http.DefaultClient.Do(req) + if err != nil { + log.Println(err) + return + } + if resp.StatusCode != 200 { + log.Println("Logout-all failed with status:", resp.StatusCode) + return + } + log.Println("Logout-all success!") +} + +func randomEmail() string { + return fmt.Sprintf("user_%d@example.com", time.Now().Unix()) +} + +func randomUsername() string { + return fmt.Sprintf("user_%d", time.Now().Unix()) +} + +func accessToken(r *http.Response) string { + var data struct { + Tokens struct { + AccessToken string `json:"access_token"` + } `json:"tokens"` + } + + _ = json.NewDecoder(r.Body).Decode(&data) + return data.Tokens.AccessToken +} + +func refreshToken(r *http.Response) string { + var data struct { + Tokens struct { + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + + _ = json.NewDecoder(r.Body).Decode(&data) + return data.Tokens.RefreshToken +}