commit c3ba2dcae406ce93c64f8f40ac8ebecfadec35ec Author: Naeel Date: Fri Apr 10 16:47:27 2026 +0300 chore: initial import from sless/shared-sqs (v0.1.14) - Standalone SQS-service repository - Multi-tenant message queue service, AWS SQS compatible - Based on GoAws, with mutable tenants, auth, WebUI, Redis persistence - Ready for independent development and deployment - See doc/ and README.md for architecture and usage diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..0747353 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,91 @@ +# Правила работы агента в этом проекте + +## ГЛАВНОЕ ПРАВИЛО + +**НЕ "СОВЕРШЕНСТВОВАТЬ" РАБОЧИЙ КОД БЕЗ ЯВНОГО УКАЗАНИЯ.** + +--- + +## ЗАПРЕТ НА ВЫДУМКИ + +**КАТЕГОРИЧЕСКИ ЗАПРЕЩАЕТСЯ придумывать, догадываться или предполагать:** +- значения параметров, которые не видны в коде или документации +- допустимые значения enum/ролей/типов — если не взяты из реального источника +- поведение API, провайдеров, библиотек — если не подтверждено кодом или документацией +- любые факты о системе, которые агент "знает" из общих соображений + +**Если информации нет — спросить у пользователя. Не угадывать.** + +Если код работает — не трогать. Никаких: +- рефакторингов "попутно" +- улучшений стиля +- добавления комментариев / docstring +- переименований переменных +- "пока уж заодно поправлю" + +Делай только то, о чём явно попросили. Ничего лишнего. + +--- + +## Комментарии в коде + +Комментарии — обязательны: +- В начале каждого файла при создании или правке — дата и время изменения +- На каждой функции/методе — краткое назначение +- На нетривиальной логике — **почему** сделано именно так (не "что делает", а "зачем") + +Цель: любой агент в новом чате должен понять логику без дополнительных вопросов. + +--- + +## Темп работы + +Не спешить. Перед каждым шагом — убедиться что предыдущий понят и согласован. + +--- + +## Документация + +Всё важное фиксировать в `doc/`: +- `doc/architecture/` — архитектура, стек, схемы +- `doc/api/` — дизайн API +- `doc/decisions/` — принятые решения с обоснованием +- `doc/infrastructure/` — инфраструктура, кластер, сервисы +- `doc/errors/` — ошибки и как решили +- `doc/progress.md` — трекер задач + +Обновлять после каждого значимого изменения. + +--- + +## Именование + +Имена должны быть **уникальными и осмысленными по всему проекту**: +- имена файлов +- имена функций/методов +- имена переменных/констант +- имена ресурсов (Terraform, Kubernetes и т.д.) + +Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение. + +Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста. + +--- + +## Лог мышления (обязательно) + +Каждый агент в каждом чате **обязан** вести лог своих рассуждений: +- Папка: `doc/thinking/` +- Файл: `ГГГГ-ММ-ДД.md` (по дате сессии) +- В начале файла указать имя агента и модель +- Если файл на текущую дату уже существует — дописывать в конец, добавив разделитель `---` и имя агента +- Записывать **полный** ход мыслей: что анализирую, какие гипотезы, что нашёл, что отбросил, к чему пришёл, почему +- Записывать **до** начала действий (план) и **после** (результат) + +Цель: пользователь должен видеть весь процесс рассуждений в читаемом виде. + +--- + +## Git + +Коммитить и пушить после каждого завершённого этапа. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95a32b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Binaries +shared-sqs +*.exe +*.dll +*.dylib +/bin/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Environment +.env +.env.local +*.log +*.pem +*.key + +# Build +dist/ +build/ + +# Secrets +secrets/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6b834b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Dockerfile — shared-sqs multi-stage build +# Updated: 2026-04-09 + +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o shared-sqs ./app/cmd/ + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs +COPY --from=builder /build/app/conf/goaws.yaml /conf/goaws.yaml +EXPOSE 4100 +HEALTHCHECK --interval=10s --timeout=5s --retries=3 \ + CMD wget -q -O - http://localhost:4100/health || exit 1 +ENTRYPOINT ["shared-sqs", "--config", "/conf/goaws.yaml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ec023d --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# Makefile — shared-sqs +# Created: 2026-04-09 +# Registry: Docker Hub (naeel/shared-sqs) — pearlharbor не используется (нестабилен) + +IMAGE_REPO=naeel/shared-sqs +VERSION=v0.1.0 +BINARY=shared-sqs + +.PHONY: build docker-build docker-push test run clean + +build: + CGO_ENABLED=0 go build -o $(BINARY) app/cmd/goaws.go + +docker-build: + docker build -t $(IMAGE_REPO):$(VERSION) . + +docker-push: + docker push $(IMAGE_REPO):$(VERSION) + +test: + go test ./... + +run: + ./$(BINARY) --admin-token=dev-token-123 --port=4100 --debug + +clean: + rm -f $(BINARY) diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..fdcb021 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1693 @@ +# shared-sqs — План реализации + +**Дата:** 2026-04-09 +**Исполнитель:** Claude Sonnet (или другой агент) +**Подготовил:** Claude Opus 4 (анализ GoAWS, архитектура, ловушки) + +--- + +## 1. ЧТО ЭТО + +Multi-tenant SQS-совместимый сервис на базе форка [GoAWS](https://github.com/Admiral-Piett/goaws) (Go, MIT, 835 stars). + +**Отличие от sqs-operator:** sqs-operator деплоит каждому тенанту ОТДЕЛЬНЫЙ pod с ElasticMQ (~300MB RAM каждый). shared-sqs — ОДИН pod обслуживает ВСЕХ тенантов (~50MB RAM base). + +**Что shared-sqs делает:** +- SQS-совместимый API (CreateQueue, SendMessage, ReceiveMessage, DeleteMessage и т.д.) +- Аутентификация по AccessKeyId (из AWS Authorization header) +- Изоляция очередей между тенантами (тенант видит ТОЛЬКО свои очереди) +- Admin API для управления тенантами (CRUD) +- Работает с AWS CLI и AWS SDK без модификаций + +--- + +## 2. АРХИТЕКТУРА GoAWS (то, что форкаем) + +### 2.1 Структура исходников +``` +app/ +├── cmd/goaws.go # Entry point (~40 LOC): флаги, загрузка конфига, HTTP сервер +├── conf/ # Загрузка YAML конфига +├── gosqs/ # SQS handlers (ЯДРО — ~20 файлов) +│ ├── create_queue.go # CreateQueueV1() +│ ├── send_message.go # SendMessageV1() +│ ├── receive_message.go # ReceiveMessageV1() +│ ├── delete_message.go # DeleteMessageV1() +│ ├── delete_message_batch.go +│ ├── delete_queue.go +│ ├── get_queue_attributes.go +│ ├── get_queue_url.go +│ ├── list_queues.go +│ ├── purge_queue.go +│ ├── send_message_batch.go +│ ├── set_queue_attributes.go +│ ├── change_message_visibility.go +│ ├── queue_attributes.go # Helpers для атрибутов +│ └── gosqs.go # PeriodicTasks (visibility timeout, DLQ, dedup) +├── gosns/ # SNS handlers — НЕ НУЖНЫ, УДАЛИТЬ +├── models/ +│ ├── globals.go # SyncQueues, SyncTopics — глобальные map + RWMutex +│ ├── models.go # Queue, SqsMessage, Topic structs +│ ├── configuration.go # Environment, EnvQueue, config structs +│ ├── constants.go +│ ├── conversions.go # Парсинг тел запросов +│ ├── errors.go # AWS-совместимые ошибки +│ ├── helpers.go +│ ├── requests.go # Request structs (CreateQueueRequest, SendMessageRequest и т.д.) +│ └── responses.go # Response structs (XML + JSON) +├── router/ +│ └── router.go # gorilla/mux, actionHandler, routingTableV1 +├── interfaces/ # AbstractResponseBody interface +├── utils/ # Hash, MD5, REQUEST_TRANSFORMER +├── mocks/ # Тестовые моки +├── fixtures/ # Тестовые данные +├── servertest/ +└── test/ +``` + +### 2.2 Критические архитектурные точки + +**Глобальный state** (`models/globals.go`): +```go +var SyncQueues = struct { + sync.RWMutex + Queues map[string]*Queue +}{Queues: make(map[string]*Queue)} +``` +Все очереди храняться В ОДНОМ map. Ключ = имя очереди (string). + +**Роутинг** (`router/router.go`): +```go +r.HandleFunc("/", actionHandler) +r.HandleFunc("/{account}", actionHandler) +r.HandleFunc("/queue/{queueName}", actionHandler) +r.HandleFunc("/{account}/{queueName}", actionHandler) +``` +Все запросы идут в `actionHandler`, который извлекает `Action` из: +- Query param `Action=CreateQueue` (AWS Query Protocol) +- Header `X-Amz-Target: AmazonSQS.CreateQueue` (AWS JSON Protocol) + +**Dispatch table** (`router/router.go`): +```go +var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": sqs.CreateQueueV1, + "SendMessage": sqs.SendMessageV1, + "ReceiveMessage": sqs.ReceiveMessageV1, + // ... все SQS + SNS actions +} +``` + +**URL конструкция** (в create_queue.go): +```go +queueUrl := "http://" + host + ":" + port + "/" + accountID + "/" + queueName +queueArn := "arn:aws:sqs:" + region + ":" + accountID + ":" + queueName +``` +`accountID` берётся из `models.CurrentEnvironment.AccountID` — ГЛОБАЛЬНАЯ переменная (одна на всех). + +**Зависимости** (go.mod): +- `gorilla/mux v1.8.0` — роутер +- `gorilla/schema v1.4.1` — form decoder +- `google/uuid v1.6.0` — UUID генерация +- `sirupsen/logrus` — логирование +- `ghodss/yaml` — YAML парсинг +- `aws/aws-sdk-go v1.47.3` — только для тестов + +--- + +## 3. ПЛАН ИЗМЕНЕНИЙ + +### 3.0 Общие правила работы + +**КРИТИЧНО — все команды ТОЛЬКО через SSH:** +``` +ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 КОМАНДА +``` + +**Go файлы редактировать ТОЛЬКО через Python patch скрипты на VM**, НЕ через sshfs/VS Code. + +**Markdown/yaml/conf файлы** можно создавать через `cat > file << EOF` по SSH. + +**Перед go build** — убедиться что бинарник в `.gitignore`. + +### Этап 1: Клон GoAWS + чистка (1 час) + +**Действия:** + +1. Клонировать GoAWS в `shared-sqs/`: +```bash +cd ~/terra/sless/shared-sqs +git clone https://github.com/Admiral-Piett/goaws.git _upstream +# Копируем ТОЛЬКО нужное: +cp -r _upstream/app ./app +cp _upstream/go.mod ./go.mod +cp _upstream/go.sum ./go.sum +cp _upstream/Dockerfile ./Dockerfile +rm -rf _upstream +``` + +2. Поменять module name в go.mod: +``` +module shared-sqs +go 1.22 +``` +(Повысить версию Go с 1.18 до 1.22+) + +3. Обновить все import paths: + - Заменить `github.com/Admiral-Piett/goaws/app/` → `shared-sqs/app/` + - Это во ВСЕХ .go файлах + +4. УДАЛИТЬ всё связанное с SNS: + - `app/gosns/` — целиком + - Из `router/router.go` — убрать все SNS записи из `routingTableV1` + - Из `models/globals.go` — убрать `SyncTopics` + - Из `models/models.go` — убрать `Topic`, `Subscription`, `SNSMessage`, `FilterPolicy` + - Из `models/configuration.go` — убрать `EnvTopic`, `EnvSubsciption` + - Из `models/requests.go` и `responses.go` — убрать SNS-related structs + +5. УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты): + - `app/mocks/` + - `app/fixtures/` + - `app/servertest/` + - `app/test/` + - `app/smoke_tests/` (если скопировалась) + +6. Проверить что компилируется: +```bash +cd ~/terra/sless/shared-sqs +go mod tidy +go build -o shared-sqs app/cmd/goaws.go +``` + +7. Проверить что стартует: +```bash +./shared-sqs -debug +# В другом окне: curl http://localhost:4100/health +# Ожидание: 200 OK +``` + +**Тест прохождения этапа:** `go build` успешен, `/health` возвращает 200. + +--- + +### Этап 2: Tenant Model + хранилище (30 мин) + +**Создать файл `app/tenant/tenant.go`:** + +```go +package tenant + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +// Tenant — модель тенанта shared-sqs +type Tenant struct { + ID string // уникальный идентификатор (генерируется) + Name string // имя тенанта (для отображения) + AccessKey string // аналог AWS AccessKeyId + SecretKey string // аналог AWS SecretAccessKey + MaxQueues int // лимит очередей (0 = безлимит) + CreatedAt time.Time + Active bool +} + +// TenantStore — in-memory хранилище тенантов +type TenantStore struct { + sync.RWMutex + // byID — поиск по ID + byID map[string]*Tenant + // byAccessKey — поиск по AccessKey (для auth) + byAccessKey map[string]*Tenant +} + +// NewTenantStore — создаёт пустое хранилище +func NewTenantStore() *TenantStore { ... } + +// Create — создаёт нового тенанта, генерирует ключи +func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { ... } + +// GetByAccessKey — поиск тенанта по AccessKeyId (для auth middleware) +func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) { ... } + +// GetByID — поиск по ID +func (s *TenantStore) GetByID(id string) (*Tenant, bool) { ... } + +// Delete — удаление тенанта +func (s *TenantStore) Delete(id string) bool { ... } + +// List — список всех тенантов +func (s *TenantStore) List() []*Tenant { ... } +``` + +**Генерация ключей (БЕЗОПАСНАЯ):** +```go +func generateAccessKey() string { + // Формат: SSAK-{random hex 12} (SS = Shared SQS) + b := make([]byte, 12) + rand.Read(b) + return "SSAK-" + hex.EncodeToString(b) +} + +func generateSecretKey() string { + // 32 байта random → 64 hex символа + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} +``` + +**ЛОВУШКА #1:** Используй `crypto/rand`, НЕ `math/rand` для ключей. `math/rand` предсказуем. + +**ЛОВУШКА #2:** Два индекса (byID и byAccessKey) — при Delete надо удалить из ОБОИХ. + +**Тест прохождения:** TenantStore создаёт/ищет/удаляет тенантов. Ключи уникальны. + +--- + +### Этап 3: Auth Middleware (30 мин) + +**Создать файл `app/auth/middleware.go`:** + +```go +package auth + +import ( + "context" + "net/http" + "strings" +) + +type contextKey string +const TenantContextKey contextKey = "tenant" + +// AuthMiddleware — извлекает AccessKeyId из AWS Authorization header +// и находит тенанта в store +func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // /health и /admin/** — пропускаем (разная auth) + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/admin/") { + next.ServeHTTP(w, r) + return + } + + accessKeyId := extractAccessKeyId(r) + if accessKeyId == "" { + writeAuthError(w, "MissingAuthenticationToken") + return + } + + t, ok := store.GetByAccessKey(accessKeyId) + if !ok || !t.Active { + writeAuthError(w, "InvalidClientTokenId") + return + } + + ctx := context.WithValue(r.Context(), TenantContextKey, t) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} +``` + +**Извлечение AccessKeyId из Authorization header:** +```go +// extractAccessKeyId — извлекает AWS AccessKeyId из запроса +// Формат header: "AWS4-HMAC-SHA256 Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request, ..." +func extractAccessKeyId(r *http.Request) string { + // Вариант 1: Authorization header (AWS Signature V4) + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { + // Ищем "Credential=" и берём всё до первого "/" + idx := strings.Index(auth, "Credential=") + if idx >= 0 { + rest := auth[idx+len("Credential="):] + slashIdx := strings.Index(rest, "/") + if slashIdx > 0 { + return rest[:slashIdx] + } + } + } + + // Вариант 2: Query parameter (presigned URLs) + if cred := r.URL.Query().Get("X-Amz-Credential"); cred != "" { + parts := strings.SplitN(cred, "/", 2) + if len(parts) > 0 { + return parts[0] + } + } + + return "" +} +``` + +**ЛОВУШКА #3:** AWS CLI ВСЕГДА отправляет Authorization header с Signature V4. Наш middleware должен УМЕТЬ парсить его, даже если мы НЕ ПРОВЕРЯЕМ подпись. + +**ЛОВУШКА #4:** Когда AWS SDK делает `ReceiveMessage` с long polling (WaitTimeSeconds > 0), соединение держится до 20 секунд. Auth middleware не должен ставить таймаут короче. + +**ЛОВУШКА #5:** AWS SDK может отправлять запросы с `X-Amz-Security-Token` (STS). Игнорировать этот header. + +**Тест прохождения:** Запрос с `Authorization: AWS4-HMAC-SHA256 Credential=SSAK-abc123.../...` → тенант найден в контексте. Запрос без header → 403. + +--- + +### Этап 4: Queue Isolation — САМЫЙ КРИТИЧНЫЙ (2 часа) + +Это ЯДРО всех изменений. Все SQS handlers должны работать с tenant-scoped очередями. + +**4.1 Изменение ключей в SyncQueues** + +Сейчас: `SyncQueues.Queues["my-queue"]` +Станет: `SyncQueues.Queues["SSAK-abc123:my-queue"]` + +Формат внутреннего ключа: `{tenantAccessKey}:{queueName}` + +Почему AccessKey а не TenantID: AccessKey уже есть в auth context, не надо лишний lookup. AccessKey уникален. + +**4.2 Helper функции (создать `app/gosqs/tenant_helpers.go`):** + +```go +// tenantQueueKey — внутренний ключ очереди в SyncQueues +func tenantQueueKey(tenantAccessKey, queueName string) string { + return tenantAccessKey + ":" + queueName +} + +// getTenantFromContext — извлекает тенанта из request context +func getTenantFromContext(r *http.Request) *tenant.Tenant { + t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant) + return t +} + +// tenantQueueUrl — формирует URL очереди для тенанта +func tenantQueueUrl(t *tenant.Tenant, queueName string) string { + return "http://" + models.CurrentEnvironment.Host + ":" + + models.CurrentEnvironment.Port + "/" + t.ID + "/" + queueName +} + +// tenantQueueArn — формирует ARN очереди +func tenantQueueArn(t *tenant.Tenant, queueName string) string { + return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName +} +``` + +**4.3 Изменения в КАЖДОМ handler (подробно)** + +**create_queue.go — CreateQueueV1:** +``` +БЫЛО: + queueName := requestBody.QueueName + key := queueName + url := http://host:port/accountID/queueName + +СТАЛО: + t := getTenantFromContext(req) + queueName := requestBody.QueueName + key := tenantQueueKey(t.AccessKey, queueName) + url := tenantQueueUrl(t, queueName) + arn := tenantQueueArn(t, queueName) + // Проверка лимита очередей: + if t.MaxQueues > 0 { + count := countTenantQueues(t.AccessKey) + if count >= t.MaxQueues { + return error "LimitExceeded" + } + } + models.SyncQueues.Queues[key] = queue +``` + +**send_message.go — SendMessageV1:** +``` +БЫЛО: + queueName = lastSegmentOfUrl(queueUrl) + _, ok := models.SyncQueues.Queues[queueName] + +СТАЛО: + t := getTenantFromContext(req) + queueName = lastSegmentOfUrl(queueUrl) + key := tenantQueueKey(t.AccessKey, queueName) + _, ok := models.SyncQueues.Queues[key] +``` + +**receive_message.go — ReceiveMessageV1:** — аналогично send_message + +**delete_message.go — DeleteMessageV1:** — аналогично + +**delete_message_batch.go — DeleteMessageBatchV1:** — аналогично + +**delete_queue.go — DeleteQueueV1:** +``` +СТАЛО: + t := getTenantFromContext(req) + key := tenantQueueKey(t.AccessKey, queueName) + delete(models.SyncQueues.Queues, key) +``` + +**list_queues.go — ListQueuesV1:** +``` +БЫЛО: + for _, queue := range models.SyncQueues.Queues { + urls = append(urls, queue.URL) + } + +СТАЛО: + t := getTenantFromContext(req) + prefix := t.AccessKey + ":" + for key, queue := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + urls = append(urls, queue.URL) + } + } +``` + +**get_queue_url.go — GetQueueUrlV1:** — добавить tenant prefix к lookup + +**get_queue_attributes.go — GetQueueAttributesV1:** — добавить tenant prefix + +**set_queue_attributes.go — SetQueueAttributesV1:** — добавить tenant prefix + +**purge_queue.go — PurgeQueueV1:** — добавить tenant prefix + +**change_message_visibility.go — ChangeMessageVisibilityV1:** — добавить tenant prefix + +**send_message_batch.go — SendMessageBatchV1:** — добавить tenant prefix + +**gosqs.go — PeriodicTasks:** — НЕ ТРОГАТЬ. Он итерирует ВСЕ очереди по полному ключу, не по имени. Работает корректно. + +**ЛОВУШКА #6:** `SendMessageV1` извлекает queueName из QueueUrl через `strings.Split(url, "/")` и берёт ПОСЛЕДНИЙ сегмент. Если URL = `http://host:port/tenantID/myqueue`, последний сегмент = `myqueue` — это ПРАВИЛЬНО, не ломается. + +**ЛОВУШКА #7:** `getQueueFromPath()` в gosqs.go тоже парсит URL. Убедиться что при `/{tenantID}/{queueName}` парсинг берёт queueName, а не tenantID. + +**ЛОВУШКА #8:** FIFO очереди имеют имена вида `myqueue.fifo`. Ключ будет `SSAK-xxx:myqueue.fifo` — это OK, `.fifo` стоит в конце имени, не ключа. Проверить что `utils.HasFIFOQueueName()` получает `queueName`, а не `key`. + +**ЛОВУШКА #9:** RedrivePolicy содержит ARN target очереди. При парсинге ARN в DLQ setup — извлечь имя очереди из ARN, затем добавить tenant prefix для lookup. ОБА (основная и DLQ) должны принадлежать одному тенанту. + +**ЛОВУШКА #10:** `QueueUrl` в ответах CreateQueue и GetQueueUrl используется AWS SDK для всех последующих вызовов. Если формат URL неправильный — SDK не сможет Send/Receive. URL ОБЯЗАН содержать tenantID в пути: `http://host:port/{tenantID}/{queueName}`. + +**4.4 Изменения в роутере** + +В `router.go` — маршрут `/{account}/{queueName}` уже существует. `{account}` = наш `{tenantID}`. Но нужно добавить middleware: + +```go +func New(tenantStore *tenant.TenantStore) http.Handler { + r := mux.NewRouter() + r.HandleFunc("/health", health).Methods("GET") + + // Admin API — отдельная auth (bearer token) + admin := r.PathPrefix("/admin").Subrouter() + // ... admin routes (см. Этап 5) + + // SQS API — tenant auth + sqsRouter := r.PathPrefix("/").Subrouter() + sqsRouter.Use(auth.AuthMiddleware(tenantStore)) + sqsRouter.HandleFunc("/", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST") + + return r +} +``` + +**Тест прохождения этапа 4:** +```bash +# Создать тенанта через Admin API +curl -X POST http://localhost:4100/admin/tenants -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d name:test-tenant +# → возвращает access_key, secret_key + +# Создать очередь как тенант +aws --endpoint-url http://localhost:4100 sqs create-queue --queue-name test-q +# Настроить AWS credentials = access_key/secret_key из шага выше +# → возвращает QueueUrl с tenantID + +# Отправить и получить сообщение +aws --endpoint-url http://localhost:4100 sqs send-message --queue-url $QUEUE_URL --message-body "hello" +aws --endpoint-url http://localhost:4100 sqs receive-message --queue-url $QUEUE_URL +# → сообщение получено + +# Второй тенант НЕ видит очереди первого +# Со вторым access_key: +aws --endpoint-url http://localhost:4100 sqs list-queues +# → пустой список +``` + +--- + +### Этап 5: Admin API (30 мин) + +**Создать файл `app/admin/admin.go`:** + +Эндпоинты: +``` +POST /admin/tenants — создать тенанта +GET /admin/tenants — список тенантов +GET /admin/tenants/{id} — детали тенанта +DELETE /admin/tenants/{id} — удалить тенанта (и ВСЕ его очереди!) +GET /admin/health — детальный health (кол-во тенантов, очередей, сообщений) +``` + +**Auth для Admin API:** Header `Authorization: Bearer {admin_token}`. Admin token задаётся через: +- Переменная окружения `SHARED_SQS_ADMIN_TOKEN` +- Или в конфиге YAML + +**ЛОВУШКА #11:** При DELETE тенанта — ОБЯЗАТЕЛЬНО удалить ВСЕ его очереди из SyncQueues. Иначе memory leak. Итерировать SyncQueues.Queues, удалить все ключи с prefix `{accessKey}:`. + +**ЛОВУШКА #12:** Admin API НЕ должен быть доступен через тот же ingress что SQS API. Либо другой path prefix, либо другой порт. Рекомендация: path prefix `/admin/`, защищённый bearer token. НЕ ЗАБЫТЬ в auth middleware пропускать `/admin/` пути. + +**Request/Response форматы:** + +POST /admin/tenants: +```json +Request: {"name": "customer-001", "max_queues": 50} +Response: {"id": "t-a1b2c3", "name": "customer-001", "access_key": "SSAK-...", "secret_key": "...", "max_queues": 50} +``` +Важно: secret_key показывается ТОЛЬКО при создании. В List/Get — не включать. + +--- + +### Этап 6: Entry Point + Configuration (20 мин) + +**Модифицировать `app/cmd/goaws.go` (переименовать в `app/cmd/main.go`):** + +```go +func main() { + // Флаги + var configFile string + var adminToken string + var port string + flag.StringVar(&configFile, "config", "", "config file") + flag.StringVar(&adminToken, "admin-token", "", "admin API token") + flag.StringVar(&port, "port", "4100", "listen port") + flag.Parse() + + // Admin token: flag > env > config + if adminToken == "" { + adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN") + } + if adminToken == "" { + log.Fatal("admin token required: use --admin-token or SHARED_SQS_ADMIN_TOKEN env") + } + + // Инициализация + tenantStore := tenant.NewTenantStore() + + // Загрузка конфига (если указан) — может содержать pre-created тенантов + if configFile != "" { + conf.LoadConfig(configFile, tenantStore) + } + + // Роутер + r := router.New(tenantStore, adminToken) + + // Periodic tasks + quit := make(chan bool) + go gosqs.PeriodicTasks(1*time.Second, quit) + + // Graceful shutdown + // ... (signal handling, quit channel) + + log.Infof("shared-sqs listening on 0.0.0.0:%s", port) + log.Fatal(http.ListenAndServe("0.0.0.0:"+port, r)) +} +``` + +**ЛОВУШКА #13:** GoAWS не обрабатывает сигналы. ДОБАВИТЬ graceful shutdown (SIGTERM/SIGINT → закрыть quit channel → остановить PeriodicTasks → дождаться завершения). + +--- + +### Этап 7: Dockerfile + Kubernetes (30 мин) + +**Dockerfile:** +```dockerfile +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs +EXPOSE 4100 +ENTRYPOINT ["shared-sqs"] +``` + +**Kubernetes manifests (`deployments/k8s/`):** + +deployment.yaml: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shared-sqs + namespace: shared-sqs +spec: + replicas: 1 + strategy: + type: Recreate # НЕ RollingUpdate! Урок из ERR-SQS-06. + selector: + matchLabels: + app: shared-sqs + template: + spec: + containers: + - name: shared-sqs + image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0 + ports: + - containerPort: 4100 + env: + - name: SHARED_SQS_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: shared-sqs-admin + key: token + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 4100 + readinessProbe: + httpGet: + path: /health + port: 4100 +``` + +**ЛОВУШКА #14:** strategy: Recreate, НЕ RollingUpdate. In-memory state не шарится между подами. При RollingUpdate новый pod стартует с пустым state, а старый ещё жив = split brain. + +service.yaml, ingress.yaml — стандартные. + +**Реестр:** `pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs` + +--- + +### Этап 8: Makefile (10 мин) + +```makefile +IMAGE_REPO=pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs +VERSION=v0.1.0 + +build: +CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +docker-build: +docker build -t $(IMAGE_REPO):$(VERSION) . + +docker-push: +docker push $(IMAGE_REPO):$(VERSION) + +test: +go test ./... + +run: +./shared-sqs --admin-token=dev-token-123 --port=4100 -debug + +clean: +rm -f shared-sqs +``` + +--- + +### Этап 9: Тесты (1 час) + +**Создать `tests/shared_sqs_test.sh`** — bash тесты (как в customer-sqs): + +1. Admin API: + - Создать тенанта + - Список тенантов + - Удалить тенанта + - Попытка без admin token → 401 + +2. Изоляция: + - Тенант A создаёт очередь "q1" + - Тенант B создаёт очередь "q1" (ДОЛЖНА создаться — разные тенанты!) + - Тенант A видит только свою q1 + - Тенант B видит только свою q1 + - Тенант A отправляет сообщение → Тенант B НЕ получает его + +3. CRUD: стандартный Create → Send → Receive → Delete flow + +4. Лимиты: тенант с max_queues=2 не может создать 3-ю очередь + +--- + +## 4. ИТОГОВАЯ СТРУКТУРА ПРОЕКТА + +``` +shared-sqs/ +├── app/ +│ ├── cmd/ +│ │ └── main.go # Entry point +│ ├── admin/ +│ │ └── admin_handlers.go # Admin API handlers +│ ├── auth/ +│ │ └── auth_middleware.go # Auth middleware +│ ├── tenant/ +│ │ └── tenant_store.go # Tenant model + in-memory store +│ ├── gosqs/ +│ │ ├── create_queue.go # Modified: tenant-scoped +│ │ ├── send_message.go # Modified: tenant-scoped +│ │ ├── receive_message.go # Modified: tenant-scoped +│ │ ├── delete_message.go # Modified: tenant-scoped +│ │ ├── ... (все handlers) +│ │ ├── tenant_helpers.go # NEW: tenant key/url/arn helpers +│ │ └── gosqs.go # PeriodicTasks (без изменений) +│ ├── models/ +│ │ ├── globals.go # Без SyncTopics +│ │ ├── models.go # Без SNS structs +│ │ ├── configuration.go # Упрощённый +│ │ ├── requests.go # Только SQS requests +│ │ └── responses.go # Только SQS responses +│ ├── router/ +│ │ └── router.go # С middleware и admin routes +│ ├── interfaces/ +│ └── utils/ +├── deployments/ +│ └── k8s/ +│ ├── deployment.yaml +│ ├── service.yaml +│ └── ingress.yaml +├── tests/ +│ └── shared_sqs_test.sh +├── doc/ +│ └── PLAN.md # Этот файл +├── Dockerfile +├── Makefile +├── go.mod +├── go.sum +├── .gitignore +└── README.md +``` + +--- + +## 5. ВСЕ ЛОВУШКИ (СВОДКА) + +| # | Ловушка | Где | Последствие если пропустить | +|---|---------|-----|---------------------------| +| 1 | `crypto/rand` vs `math/rand` для ключей | tenant.go | Предсказуемые ключи = уязвимость | +| 2 | Delete tenant: удалить из ОБОИХ индексов (byID + byAccessKey) | tenant.go | Orphaned данные | +| 3 | AWS CLI отправляет Signature V4 — нужно парсить | middleware.go | SDK не работает | +| 4 | Long polling до 20 сек — не ставить короткий таймаут | middleware.go | ReceiveMessage обрывается | +| 5 | X-Amz-Security-Token — игнорировать | middleware.go | Ложная ошибка auth | +| 6 | URL parsing: последний сегмент = queueName | send_message.go | Берёт tenantID вместо queueName | +| 7 | getQueueFromPath() парсит URL — проверить с новым форматом | gosqs.go | Неправильное имя очереди | +| 8 | FIFO: HasFIFOQueueName() должен получить queueName, не key | create_queue.go | FIFO не работает | +| 9 | DLQ ARN → queue name → tenant prefix | set_queue_attributes.go | Чужая DLQ или not found | +| 10 | QueueUrl в ответах ОБЯЗАН содержать tenantID | create_queue.go, get_queue_url.go | SDK не может send/receive | +| 11 | DELETE tenant → удалить ВСЕ очереди | admin.go | Memory leak | +| 12 | Admin API под отдельной auth (bearer token) | router.go | Тенант = admin | +| 13 | Graceful shutdown (SIGTERM) | main.go | Потеря данных при restart | +| 14 | Deployment strategy: Recreate, НЕ RollingUpdate | deployment.yaml | Split brain | +| 15 | .gitignore: бинарник shared-sqs | .gitignore | Бинарник в git | +| 16 | go mod tidy после чистки SNS | go.mod | Лишние зависимости | +| 17 | Все import paths: goaws → shared-sqs | *.go | Не компилируется | + +--- + +## 6. ПОРЯДОК ВЫПОЛНЕНИЯ + +1. Этап 1 → go build → /health работает +2. Этап 2 → tenant store готов (можно юнит-тестом проверить) +3. Этап 3 → middleware готов +4. Этап 4 → САМЫЙ БОЛЬШОЙ. Делать handler за handler, каждый раз проверяя go build +5. Этап 5 → Admin API +6. Этап 6 → Wiring всего вместе в main.go +7. Этап 7 → Docker + K8s +8. Этап 8 → Makefile +9. Этап 9 → Тесты + +**После каждого этапа: `go build` должен проходить. НЕ НАКАПЛИВАТЬ ошибки компиляции.** + +--- + +## 7. ЧЕГО НЕ ДЕЛАЕМ (scope out) + +- ❌ Persistence (восстановление после рестарта) — in-memory OK для MVP +- ❌ AWS Signature V4 verification — только извлечение AccessKeyId +- ❌ SNS — удаляем полностью +- ❌ HTTPS — TLS на ingress, не в приложении +- ❌ Rate limiting — можно добавить позже +- ❌ Metrics/Prometheus — можно добавить позже +- ❌ UI — нет UI, только API +- ❌ Scale-to-zero — один pod всегда работает +ENDOFPLAN cat > ~/terra/sless/shared-sqs/PLAN.md << 'ENDOFPLAN' +# shared-sqs — План реализации + +**Дата:** 2026-04-09 +**Исполнитель:** Claude Sonnet (или другой агент) +**Подготовил:** Claude Opus 4 (анализ GoAWS, архитектура, ловушки) + +--- + +## 1. ЧТО ЭТО + +Multi-tenant SQS-совместимый сервис на базе форка [GoAWS](https://github.com/Admiral-Piett/goaws) (Go, MIT, 835 stars). + +**Отличие от sqs-operator:** sqs-operator деплоит каждому тенанту ОТДЕЛЬНЫЙ pod с ElasticMQ (~300MB RAM каждый). shared-sqs — ОДИН pod обслуживает ВСЕХ тенантов (~50MB RAM base). + +**Что shared-sqs делает:** +- SQS-совместимый API (CreateQueue, SendMessage, ReceiveMessage, DeleteMessage и т.д.) +- Аутентификация по AccessKeyId (из AWS Authorization header) +- Изоляция очередей между тенантами (тенант видит ТОЛЬКО свои очереди) +- Admin API для управления тенантами (CRUD) +- Работает с AWS CLI и AWS SDK без модификаций + +--- + +## 2. АРХИТЕКТУРА GoAWS (то, что форкаем) + +### 2.1 Структура исходников +``` +app/ +├── cmd/goaws.go # Entry point (~40 LOC): флаги, загрузка конфига, HTTP сервер +├── conf/ # Загрузка YAML конфига +├── gosqs/ # SQS handlers (ЯДРО — ~20 файлов) +│ ├── create_queue.go # CreateQueueV1() +│ ├── send_message.go # SendMessageV1() +│ ├── receive_message.go # ReceiveMessageV1() +│ ├── delete_message.go # DeleteMessageV1() +│ ├── delete_message_batch.go +│ ├── delete_queue.go +│ ├── get_queue_attributes.go +│ ├── get_queue_url.go +│ ├── list_queues.go +│ ├── purge_queue.go +│ ├── send_message_batch.go +│ ├── set_queue_attributes.go +│ ├── change_message_visibility.go +│ ├── queue_attributes.go # Helpers для атрибутов +│ └── gosqs.go # PeriodicTasks (visibility timeout, DLQ, dedup) +├── gosns/ # SNS handlers — НЕ НУЖНЫ, УДАЛИТЬ +├── models/ +│ ├── globals.go # SyncQueues, SyncTopics — глобальные map + RWMutex +│ ├── models.go # Queue, SqsMessage, Topic structs +│ ├── configuration.go # Environment, EnvQueue, config structs +│ ├── constants.go +│ ├── conversions.go # Парсинг тел запросов +│ ├── errors.go # AWS-совместимые ошибки +│ ├── helpers.go +│ ├── requests.go # Request structs (CreateQueueRequest, SendMessageRequest и т.д.) +│ └── responses.go # Response structs (XML + JSON) +├── router/ +│ └── router.go # gorilla/mux, actionHandler, routingTableV1 +├── interfaces/ # AbstractResponseBody interface +├── utils/ # Hash, MD5, REQUEST_TRANSFORMER +├── mocks/ # Тестовые моки +├── fixtures/ # Тестовые данные +├── servertest/ +└── test/ +``` + +### 2.2 Критические архитектурные точки + +**Глобальный state** (`models/globals.go`): +```go +var SyncQueues = struct { + sync.RWMutex + Queues map[string]*Queue +}{Queues: make(map[string]*Queue)} +``` +Все очереди храняться В ОДНОМ map. Ключ = имя очереди (string). + +**Роутинг** (`router/router.go`): +```go +r.HandleFunc("/", actionHandler) +r.HandleFunc("/{account}", actionHandler) +r.HandleFunc("/queue/{queueName}", actionHandler) +r.HandleFunc("/{account}/{queueName}", actionHandler) +``` +Все запросы идут в `actionHandler`, который извлекает `Action` из: +- Query param `Action=CreateQueue` (AWS Query Protocol) +- Header `X-Amz-Target: AmazonSQS.CreateQueue` (AWS JSON Protocol) + +**Dispatch table** (`router/router.go`): +```go +var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": sqs.CreateQueueV1, + "SendMessage": sqs.SendMessageV1, + "ReceiveMessage": sqs.ReceiveMessageV1, + // ... все SQS + SNS actions +} +``` + +**URL конструкция** (в create_queue.go): +```go +queueUrl := "http://" + host + ":" + port + "/" + accountID + "/" + queueName +queueArn := "arn:aws:sqs:" + region + ":" + accountID + ":" + queueName +``` +`accountID` берётся из `models.CurrentEnvironment.AccountID` — ГЛОБАЛЬНАЯ переменная (одна на всех). + +**Зависимости** (go.mod): +- `gorilla/mux v1.8.0` — роутер +- `gorilla/schema v1.4.1` — form decoder +- `google/uuid v1.6.0` — UUID генерация +- `sirupsen/logrus` — логирование +- `ghodss/yaml` — YAML парсинг +- `aws/aws-sdk-go v1.47.3` — только для тестов + +--- + +## 3. ПЛАН ИЗМЕНЕНИЙ + +### 3.0 Общие правила работы + +**КРИТИЧНО — все команды ТОЛЬКО через SSH:** +``` +ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 КОМАНДА +``` + +**Go файлы редактировать ТОЛЬКО через Python patch скрипты на VM**, НЕ через sshfs/VS Code. + +**Markdown/yaml/conf файлы** можно создавать через `cat > file << EOF` по SSH. + +**Перед go build** — убедиться что бинарник в `.gitignore`. + +### Этап 1: Клон GoAWS + чистка (1 час) + +**Действия:** + +1. Клонировать GoAWS в `shared-sqs/`: +```bash +cd ~/terra/sless/shared-sqs +git clone https://github.com/Admiral-Piett/goaws.git _upstream +# Копируем ТОЛЬКО нужное: +cp -r _upstream/app ./app +cp _upstream/go.mod ./go.mod +cp _upstream/go.sum ./go.sum +cp _upstream/Dockerfile ./Dockerfile +rm -rf _upstream +``` + +2. Поменять module name в go.mod: +``` +module shared-sqs +go 1.22 +``` +(Повысить версию Go с 1.18 до 1.22+) + +3. Обновить все import paths: + - Заменить `github.com/Admiral-Piett/goaws/app/` → `shared-sqs/app/` + - Это во ВСЕХ .go файлах + +4. УДАЛИТЬ всё связанное с SNS: + - `app/gosns/` — целиком + - Из `router/router.go` — убрать все SNS записи из `routingTableV1` + - Из `models/globals.go` — убрать `SyncTopics` + - Из `models/models.go` — убрать `Topic`, `Subscription`, `SNSMessage`, `FilterPolicy` + - Из `models/configuration.go` — убрать `EnvTopic`, `EnvSubsciption` + - Из `models/requests.go` и `responses.go` — убрать SNS-related structs + +5. УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты): + - `app/mocks/` + - `app/fixtures/` + - `app/servertest/` + - `app/test/` + - `app/smoke_tests/` (если скопировалась) + +6. Проверить что компилируется: +```bash +cd ~/terra/sless/shared-sqs +go mod tidy +go build -o shared-sqs app/cmd/goaws.go +``` + +7. Проверить что стартует: +```bash +./shared-sqs -debug +# В другом окне: curl http://localhost:4100/health +# Ожидание: 200 OK +``` + +**Тест прохождения этапа:** `go build` успешен, `/health` возвращает 200. + +--- + +### Этап 2: Tenant Model + хранилище (30 мин) + +**Создать файл `app/tenant/tenant.go`:** + +```go +package tenant + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +// Tenant — модель тенанта shared-sqs +type Tenant struct { + ID string // уникальный идентификатор (генерируется) + Name string // имя тенанта (для отображения) + AccessKey string // аналог AWS AccessKeyId + SecretKey string // аналог AWS SecretAccessKey + MaxQueues int // лимит очередей (0 = безлимит) + CreatedAt time.Time + Active bool +} + +// TenantStore — in-memory хранилище тенантов +type TenantStore struct { + sync.RWMutex + // byID — поиск по ID + byID map[string]*Tenant + // byAccessKey — поиск по AccessKey (для auth) + byAccessKey map[string]*Tenant +} + +// NewTenantStore — создаёт пустое хранилище +func NewTenantStore() *TenantStore { ... } + +// Create — создаёт нового тенанта, генерирует ключи +func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { ... } + +// GetByAccessKey — поиск тенанта по AccessKeyId (для auth middleware) +func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) { ... } + +// GetByID — поиск по ID +func (s *TenantStore) GetByID(id string) (*Tenant, bool) { ... } + +// Delete — удаление тенанта +func (s *TenantStore) Delete(id string) bool { ... } + +// List — список всех тенантов +func (s *TenantStore) List() []*Tenant { ... } +``` + +**Генерация ключей (БЕЗОПАСНАЯ):** +```go +func generateAccessKey() string { + // Формат: SSAK-{random hex 12} (SS = Shared SQS) + b := make([]byte, 12) + rand.Read(b) + return "SSAK-" + hex.EncodeToString(b) +} + +func generateSecretKey() string { + // 32 байта random → 64 hex символа + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} +``` + +**ЛОВУШКА #1:** Используй `crypto/rand`, НЕ `math/rand` для ключей. `math/rand` предсказуем. + +**ЛОВУШКА #2:** Два индекса (byID и byAccessKey) — при Delete надо удалить из ОБОИХ. + +**Тест прохождения:** TenantStore создаёт/ищет/удаляет тенантов. Ключи уникальны. + +--- + +### Этап 3: Auth Middleware (30 мин) + +**Создать файл `app/auth/middleware.go`:** + +```go +package auth + +import ( + "context" + "net/http" + "strings" +) + +type contextKey string +const TenantContextKey contextKey = "tenant" + +// AuthMiddleware — извлекает AccessKeyId из AWS Authorization header +// и находит тенанта в store +func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // /health и /admin/** — пропускаем (разная auth) + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/admin/") { + next.ServeHTTP(w, r) + return + } + + accessKeyId := extractAccessKeyId(r) + if accessKeyId == "" { + writeAuthError(w, "MissingAuthenticationToken") + return + } + + t, ok := store.GetByAccessKey(accessKeyId) + if !ok || !t.Active { + writeAuthError(w, "InvalidClientTokenId") + return + } + + ctx := context.WithValue(r.Context(), TenantContextKey, t) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} +``` + +**Извлечение AccessKeyId из Authorization header:** +```go +// extractAccessKeyId — извлекает AWS AccessKeyId из запроса +// Формат header: "AWS4-HMAC-SHA256 Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request, ..." +func extractAccessKeyId(r *http.Request) string { + // Вариант 1: Authorization header (AWS Signature V4) + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { + // Ищем "Credential=" и берём всё до первого "/" + idx := strings.Index(auth, "Credential=") + if idx >= 0 { + rest := auth[idx+len("Credential="):] + slashIdx := strings.Index(rest, "/") + if slashIdx > 0 { + return rest[:slashIdx] + } + } + } + + // Вариант 2: Query parameter (presigned URLs) + if cred := r.URL.Query().Get("X-Amz-Credential"); cred != "" { + parts := strings.SplitN(cred, "/", 2) + if len(parts) > 0 { + return parts[0] + } + } + + return "" +} +``` + +**ЛОВУШКА #3:** AWS CLI ВСЕГДА отправляет Authorization header с Signature V4. Наш middleware должен УМЕТЬ парсить его, даже если мы НЕ ПРОВЕРЯЕМ подпись. + +**ЛОВУШКА #4:** Когда AWS SDK делает `ReceiveMessage` с long polling (WaitTimeSeconds > 0), соединение держится до 20 секунд. Auth middleware не должен ставить таймаут короче. + +**ЛОВУШКА #5:** AWS SDK может отправлять запросы с `X-Amz-Security-Token` (STS). Игнорировать этот header. + +**Тест прохождения:** Запрос с `Authorization: AWS4-HMAC-SHA256 Credential=SSAK-abc123.../...` → тенант найден в контексте. Запрос без header → 403. + +--- + +### Этап 4: Queue Isolation — САМЫЙ КРИТИЧНЫЙ (2 часа) + +Это ЯДРО всех изменений. Все SQS handlers должны работать с tenant-scoped очередями. + +**4.1 Изменение ключей в SyncQueues** + +Сейчас: `SyncQueues.Queues["my-queue"]` +Станет: `SyncQueues.Queues["SSAK-abc123:my-queue"]` + +Формат внутреннего ключа: `{tenantAccessKey}:{queueName}` + +Почему AccessKey а не TenantID: AccessKey уже есть в auth context, не надо лишний lookup. AccessKey уникален. + +**4.2 Helper функции (создать `app/gosqs/tenant_helpers.go`):** + +```go +// tenantQueueKey — внутренний ключ очереди в SyncQueues +func tenantQueueKey(tenantAccessKey, queueName string) string { + return tenantAccessKey + ":" + queueName +} + +// getTenantFromContext — извлекает тенанта из request context +func getTenantFromContext(r *http.Request) *tenant.Tenant { + t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant) + return t +} + +// tenantQueueUrl — формирует URL очереди для тенанта +func tenantQueueUrl(t *tenant.Tenant, queueName string) string { + return "http://" + models.CurrentEnvironment.Host + ":" + + models.CurrentEnvironment.Port + "/" + t.ID + "/" + queueName +} + +// tenantQueueArn — формирует ARN очереди +func tenantQueueArn(t *tenant.Tenant, queueName string) string { + return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName +} +``` + +**4.3 Изменения в КАЖДОМ handler (подробно)** + +**create_queue.go — CreateQueueV1:** +``` +БЫЛО: + queueName := requestBody.QueueName + key := queueName + url := http://host:port/accountID/queueName + +СТАЛО: + t := getTenantFromContext(req) + queueName := requestBody.QueueName + key := tenantQueueKey(t.AccessKey, queueName) + url := tenantQueueUrl(t, queueName) + arn := tenantQueueArn(t, queueName) + // Проверка лимита очередей: + if t.MaxQueues > 0 { + count := countTenantQueues(t.AccessKey) + if count >= t.MaxQueues { + return error "LimitExceeded" + } + } + models.SyncQueues.Queues[key] = queue +``` + +**send_message.go — SendMessageV1:** +``` +БЫЛО: + queueName = lastSegmentOfUrl(queueUrl) + _, ok := models.SyncQueues.Queues[queueName] + +СТАЛО: + t := getTenantFromContext(req) + queueName = lastSegmentOfUrl(queueUrl) + key := tenantQueueKey(t.AccessKey, queueName) + _, ok := models.SyncQueues.Queues[key] +``` + +**receive_message.go — ReceiveMessageV1:** — аналогично send_message + +**delete_message.go — DeleteMessageV1:** — аналогично + +**delete_message_batch.go — DeleteMessageBatchV1:** — аналогично + +**delete_queue.go — DeleteQueueV1:** +``` +СТАЛО: + t := getTenantFromContext(req) + key := tenantQueueKey(t.AccessKey, queueName) + delete(models.SyncQueues.Queues, key) +``` + +**list_queues.go — ListQueuesV1:** +``` +БЫЛО: + for _, queue := range models.SyncQueues.Queues { + urls = append(urls, queue.URL) + } + +СТАЛО: + t := getTenantFromContext(req) + prefix := t.AccessKey + ":" + for key, queue := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + urls = append(urls, queue.URL) + } + } +``` + +**get_queue_url.go — GetQueueUrlV1:** — добавить tenant prefix к lookup + +**get_queue_attributes.go — GetQueueAttributesV1:** — добавить tenant prefix + +**set_queue_attributes.go — SetQueueAttributesV1:** — добавить tenant prefix + +**purge_queue.go — PurgeQueueV1:** — добавить tenant prefix + +**change_message_visibility.go — ChangeMessageVisibilityV1:** — добавить tenant prefix + +**send_message_batch.go — SendMessageBatchV1:** — добавить tenant prefix + +**gosqs.go — PeriodicTasks:** — НЕ ТРОГАТЬ. Он итерирует ВСЕ очереди по полному ключу, не по имени. Работает корректно. + +**ЛОВУШКА #6:** `SendMessageV1` извлекает queueName из QueueUrl через `strings.Split(url, "/")` и берёт ПОСЛЕДНИЙ сегмент. Если URL = `http://host:port/tenantID/myqueue`, последний сегмент = `myqueue` — это ПРАВИЛЬНО, не ломается. + +**ЛОВУШКА #7:** `getQueueFromPath()` в gosqs.go тоже парсит URL. Убедиться что при `/{tenantID}/{queueName}` парсинг берёт queueName, а не tenantID. + +**ЛОВУШКА #8:** FIFO очереди имеют имена вида `myqueue.fifo`. Ключ будет `SSAK-xxx:myqueue.fifo` — это OK, `.fifo` стоит в конце имени, не ключа. Проверить что `utils.HasFIFOQueueName()` получает `queueName`, а не `key`. + +**ЛОВУШКА #9:** RedrivePolicy содержит ARN target очереди. При парсинге ARN в DLQ setup — извлечь имя очереди из ARN, затем добавить tenant prefix для lookup. ОБА (основная и DLQ) должны принадлежать одному тенанту. + +**ЛОВУШКА #10:** `QueueUrl` в ответах CreateQueue и GetQueueUrl используется AWS SDK для всех последующих вызовов. Если формат URL неправильный — SDK не сможет Send/Receive. URL ОБЯЗАН содержать tenantID в пути: `http://host:port/{tenantID}/{queueName}`. + +**4.4 Изменения в роутере** + +В `router.go` — маршрут `/{account}/{queueName}` уже существует. `{account}` = наш `{tenantID}`. Но нужно добавить middleware: + +```go +func New(tenantStore *tenant.TenantStore) http.Handler { + r := mux.NewRouter() + r.HandleFunc("/health", health).Methods("GET") + + // Admin API — отдельная auth (bearer token) + admin := r.PathPrefix("/admin").Subrouter() + // ... admin routes (см. Этап 5) + + // SQS API — tenant auth + sqsRouter := r.PathPrefix("/").Subrouter() + sqsRouter.Use(auth.AuthMiddleware(tenantStore)) + sqsRouter.HandleFunc("/", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST") + + return r +} +``` + +**Тест прохождения этапа 4:** +```bash +# Создать тенанта через Admin API +curl -X POST http://localhost:4100/admin/tenants -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d max_queues:10 +# → возвращает access_key, secret_key + +# Создать очередь как тенант +aws --endpoint-url http://localhost:4100 sqs create-queue --queue-name test-q +# Настроить AWS credentials = access_key/secret_key из шага выше +# → возвращает QueueUrl с tenantID + +# Отправить и получить сообщение +aws --endpoint-url http://localhost:4100 sqs send-message --queue-url $QUEUE_URL --message-body "hello" +aws --endpoint-url http://localhost:4100 sqs receive-message --queue-url $QUEUE_URL +# → сообщение получено + +# Второй тенант НЕ видит очереди первого +# Со вторым access_key: +aws --endpoint-url http://localhost:4100 sqs list-queues +# → пустой список +``` + +--- + +### Этап 5: Admin API (30 мин) + +**Создать файл `app/admin/admin.go`:** + +Эндпоинты: +``` +POST /admin/tenants — создать тенанта +GET /admin/tenants — список тенантов +GET /admin/tenants/{id} — детали тенанта +DELETE /admin/tenants/{id} — удалить тенанта (и ВСЕ его очереди!) +GET /admin/health — детальный health (кол-во тенантов, очередей, сообщений) +``` + +**Auth для Admin API:** Header `Authorization: Bearer {admin_token}`. Admin token задаётся через: +- Переменная окружения `SHARED_SQS_ADMIN_TOKEN` +- Или в конфиге YAML + +**ЛОВУШКА #11:** При DELETE тенанта — ОБЯЗАТЕЛЬНО удалить ВСЕ его очереди из SyncQueues. Иначе memory leak. Итерировать SyncQueues.Queues, удалить все ключи с prefix `{accessKey}:`. + +**ЛОВУШКА #12:** Admin API НЕ должен быть доступен через тот же ingress что SQS API. Либо другой path prefix, либо другой порт. Рекомендация: path prefix `/admin/`, защищённый bearer token. НЕ ЗАБЫТЬ в auth middleware пропускать `/admin/` пути. + +**Request/Response форматы:** + +POST /admin/tenants: +```json +Request: {"name": "customer-001", "max_queues": 50} +Response: {"id": "t-a1b2c3", "name": "customer-001", "access_key": "SSAK-...", "secret_key": "...", "max_queues": 50} +``` +Важно: secret_key показывается ТОЛЬКО при создании. В List/Get — не включать. + +--- + +### Этап 6: Entry Point + Configuration (20 мин) + +**Модифицировать `app/cmd/goaws.go` (переименовать в `app/cmd/main.go`):** + +```go +func main() { + // Флаги + var configFile string + var adminToken string + var port string + flag.StringVar(&configFile, "config", "", "config file") + flag.StringVar(&adminToken, "admin-token", "", "admin API token") + flag.StringVar(&port, "port", "4100", "listen port") + flag.Parse() + + // Admin token: flag > env > config + if adminToken == "" { + adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN") + } + if adminToken == "" { + log.Fatal("admin token required: use --admin-token or SHARED_SQS_ADMIN_TOKEN env") + } + + // Инициализация + tenantStore := tenant.NewTenantStore() + + // Загрузка конфига (если указан) — может содержать pre-created тенантов + if configFile != "" { + conf.LoadConfig(configFile, tenantStore) + } + + // Роутер + r := router.New(tenantStore, adminToken) + + // Periodic tasks + quit := make(chan bool) + go gosqs.PeriodicTasks(1*time.Second, quit) + + // Graceful shutdown + // ... (signal handling, quit channel) + + log.Infof("shared-sqs listening on 0.0.0.0:%s", port) + log.Fatal(http.ListenAndServe("0.0.0.0:"+port, r)) +} +``` + +**ЛОВУШКА #13:** GoAWS не обрабатывает сигналы. ДОБАВИТЬ graceful shutdown (SIGTERM/SIGINT → закрыть quit channel → остановить PeriodicTasks → дождаться завершения). + +--- + +### Этап 7: Dockerfile + Kubernetes (30 мин) + +**Dockerfile:** +```dockerfile +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs +EXPOSE 4100 +ENTRYPOINT ["shared-sqs"] +``` + +**Kubernetes manifests (`deployments/k8s/`):** + +deployment.yaml: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shared-sqs + namespace: shared-sqs +spec: + replicas: 1 + strategy: + type: Recreate # НЕ RollingUpdate! Урок из ERR-SQS-06. + selector: + matchLabels: + app: shared-sqs + template: + spec: + containers: + - name: shared-sqs + image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0 + ports: + - containerPort: 4100 + env: + - name: SHARED_SQS_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: shared-sqs-admin + key: token + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 4100 + readinessProbe: + httpGet: + path: /health + port: 4100 +``` + +**ЛОВУШКА #14:** strategy: Recreate, НЕ RollingUpdate. In-memory state не шарится между подами. При RollingUpdate новый pod стартует с пустым state, а старый ещё жив = split brain. + +service.yaml, ingress.yaml — стандартные. + +**Реестр:** `pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs` + +--- + +### Этап 8: Makefile (10 мин) + +```makefile +IMAGE_REPO=pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs +VERSION=v0.1.0 + +build: +CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +docker-build: +docker build -t $(IMAGE_REPO):$(VERSION) . + +docker-push: +docker push $(IMAGE_REPO):$(VERSION) + +test: +go test ./... + +run: +./shared-sqs --admin-token=dev-token-123 --port=4100 -debug + +clean: +rm -f shared-sqs +``` + +--- + +### Этап 9: Тесты (1 час) + +**Создать `tests/shared_sqs_test.sh`** — bash тесты (как в customer-sqs): + +1. Admin API: + - Создать тенанта + - Список тенантов + - Удалить тенанта + - Попытка без admin token → 401 + +2. Изоляция: + - Тенант A создаёт очередь "q1" + - Тенант B создаёт очередь "q1" (ДОЛЖНА создаться — разные тенанты!) + - Тенант A видит только свою q1 + - Тенант B видит только свою q1 + - Тенант A отправляет сообщение → Тенант B НЕ получает его + +3. CRUD: стандартный Create → Send → Receive → Delete flow + +4. Лимиты: тенант с max_queues=2 не может создать 3-ю очередь + +--- + +## 4. ИТОГОВАЯ СТРУКТУРА ПРОЕКТА + +``` +shared-sqs/ +├── app/ +│ ├── cmd/ +│ │ └── main.go # Entry point +│ ├── admin/ +│ │ └── admin_handlers.go # Admin API handlers +│ ├── auth/ +│ │ └── auth_middleware.go # Auth middleware +│ ├── tenant/ +│ │ └── tenant_store.go # Tenant model + in-memory store +│ ├── gosqs/ +│ │ ├── create_queue.go # Modified: tenant-scoped +│ │ ├── send_message.go # Modified: tenant-scoped +│ │ ├── receive_message.go # Modified: tenant-scoped +│ │ ├── delete_message.go # Modified: tenant-scoped +│ │ ├── ... (все handlers) +│ │ ├── tenant_helpers.go # NEW: tenant key/url/arn helpers +│ │ └── gosqs.go # PeriodicTasks (без изменений) +│ ├── models/ +│ │ ├── globals.go # Без SyncTopics +│ │ ├── models.go # Без SNS structs +│ │ ├── configuration.go # Упрощённый +│ │ ├── requests.go # Только SQS requests +│ │ └── responses.go # Только SQS responses +│ ├── router/ +│ │ └── router.go # С middleware и admin routes +│ ├── interfaces/ +│ └── utils/ +├── deployments/ +│ └── k8s/ +│ ├── deployment.yaml +│ ├── service.yaml +│ └── ingress.yaml +├── tests/ +│ └── shared_sqs_test.sh +├── doc/ +│ └── PLAN.md # Этот файл +├── Dockerfile +├── Makefile +├── go.mod +├── go.sum +├── .gitignore +└── README.md +``` + +--- + +## 5. ВСЕ ЛОВУШКИ (СВОДКА) + +| # | Ловушка | Где | Последствие если пропустить | +|---|---------|-----|---------------------------| +| 1 | `crypto/rand` vs `math/rand` для ключей | tenant.go | Предсказуемые ключи = уязвимость | +| 2 | Delete tenant: удалить из ОБОИХ индексов (byID + byAccessKey) | tenant.go | Orphaned данные | +| 3 | AWS CLI отправляет Signature V4 — нужно парсить | middleware.go | SDK не работает | +| 4 | Long polling до 20 сек — не ставить короткий таймаут | middleware.go | ReceiveMessage обрывается | +| 5 | X-Amz-Security-Token — игнорировать | middleware.go | Ложная ошибка auth | +| 6 | URL parsing: последний сегмент = queueName | send_message.go | Берёт tenantID вместо queueName | +| 7 | getQueueFromPath() парсит URL — проверить с новым форматом | gosqs.go | Неправильное имя очереди | +| 8 | FIFO: HasFIFOQueueName() должен получить queueName, не key | create_queue.go | FIFO не работает | +| 9 | DLQ ARN → queue name → tenant prefix | set_queue_attributes.go | Чужая DLQ или not found | +| 10 | QueueUrl в ответах ОБЯЗАН содержать tenantID | create_queue.go, get_queue_url.go | SDK не может send/receive | +| 11 | DELETE tenant → удалить ВСЕ очереди | admin.go | Memory leak | +| 12 | Admin API под отдельной auth (bearer token) | router.go | Тенант = admin | +| 13 | Graceful shutdown (SIGTERM) | main.go | Потеря данных при restart | +| 14 | Deployment strategy: Recreate, НЕ RollingUpdate | deployment.yaml | Split brain | +| 15 | .gitignore: бинарник shared-sqs | .gitignore | Бинарник в git | +| 16 | go mod tidy после чистки SNS | go.mod | Лишние зависимости | +| 17 | Все import paths: goaws → shared-sqs | *.go | Не компилируется | + +--- + +## 6. ПОРЯДОК ВЫПОЛНЕНИЯ + +1. Этап 1 → go build → /health работает +2. Этап 2 → tenant store готов (можно юнит-тестом проверить) +3. Этап 3 → middleware готов +4. Этап 4 → САМЫЙ БОЛЬШОЙ. Делать handler за handler, каждый раз проверяя go build +5. Этап 5 → Admin API +6. Этап 6 → Wiring всего вместе в main.go +7. Этап 7 → Docker + K8s +8. Этап 8 → Makefile +9. Этап 9 → Тесты + +**После каждого этапа: `go build` должен проходить. НЕ НАКАПЛИВАТЬ ошибки компиляции.** + +--- + +## 7. ЧЕГО НЕ ДЕЛАЕМ (scope out) + +- ❌ Persistence (восстановление после рестарта) — in-memory OK для MVP +- ❌ AWS Signature V4 verification — только извлечение AccessKeyId +- ❌ SNS — удаляем полностью +- ❌ HTTPS — TLS на ingress, не в приложении +- ❌ Rate limiting — можно добавить позже +- ❌ Metrics/Prometheus — можно добавить позже +- ❌ UI — нет UI, только API +- ❌ Scale-to-zero — один pod всегда работает diff --git a/README.md b/README.md new file mode 100644 index 0000000..f69e53b --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# SQS-service — Multi-Tenant Message Queue Service + +Совместимый с AWS SQS сервис очередей сообщений с поддержкой нескольких изолированных тенантов, developed from GoAws. + +## Возможности + +- **AWS SQS API-совместимый** — работает с AWS SDK без изменений кода +- **Многотенантность** — каждый тенант имеет отдельный Access Key и Secret Key +- **Redis persistence** — очереди и сообщения сохраняются при перезапуске сервиса +- **Web UI Console** — управление тенантами и просмотр сообщений +- **Kubernetes-ready** — Helm/манифесты, TLS Ingress, здоровье-checks +- **Admin API** — управление тенантами программно + +## Быстрый старт + +### Локально + +```bash +docker run -d -p 9090:9090 naeel/shared-sqs:latest +# Откройте http://localhost:9090 +``` + +### На Kubernetes + +```bash +kubectl apply -k deployments/k8s/ +``` + +### Сборка локально + +```bash +go build -o shared-sqs ./app/cmd +./shared-sqs --port 9090 +``` + +## Архитектура + +``` +app/ +├── cmd/ — точка входа +├── models/ — структуры данных (Queue, Message, Tenant) +├── gosqs/ — реализация SQS API +├── admin/ — Admin API (Create Tenant, Get Tenant и т.д.) +├── tenant/ — изоляция и авторизация по тенанту +├── auth/ — AWS Signature V4 верификация +├── persistence/ — Redis adapter +├── router/ — HTTP маршруты +└── ui/ — Web Console (HTML/JS) + +deployments/k8s/ +├── deployment.yaml — Deployment с Redis sidecar +├── ingress.yaml — TLS, qu.kube5s.ru +└── redis.yaml — Redis Persistent Volume +``` + +## Окружение + +| Переменная | Значение | Описание | +|-----------|---------|---------| +| `PORT` | 9090 | Порт HTTP сервера | +| `REDIS_URL` | localhost:6379 | Redis для persistence | +| `SHARED_SQS_SEED_DEMO` | true/false | Загрузить demo данные на старте | + +## API примеры + +### Создать очередь (как тенант) + +```bash +curl -X POST "http://localhost:9090/" \ + -H "Authorization: AWS4-HMAC-SHA256 Credential=SSAK-demo-shared-sqs/20260410/us-east-1/sqs/aws4_request" \ + -d "Action=CreateQueue&QueueName=myqueue&Version=2012-11-05" +``` + +### Отправить сообщение + +```bash +aws sqs send-message \ + --endpoint-url http://localhost:9090 \ + --queue-url http://localhost:9090/demo-shared-sqs/myqueue \ + --message-body "Hello World" +``` + +## История + +- **v0.1.11** — Redis write-through persistence +- **v0.1.13** — Фикс критического дедлока в CreateQueue +- **v0.1.14** — Фикс UI (sent_at поле), TLS Ingress + +## Документация + +- [PLAN.md](PLAN.md) — детальный план разработки +- [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция + +## Ссылки + +- Репо: https://gitea.services.ngcloud.ru/Nail/SQS-service +- Demo (dev): https://qu.kube5s.ru +- Основана на: [GoAws](https://github.com/Admiral-Piett/goaws) diff --git a/app/admin/admin.go b/app/admin/admin.go new file mode 100644 index 0000000..8e3c1ed --- /dev/null +++ b/app/admin/admin.go @@ -0,0 +1,510 @@ +// app/admin/admin.go +// Admin API handlers for shared-sqs management +// Created: 2026-04-09 +// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений +package admin + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "shared-sqs/app/models" + "shared-sqs/app/tenant" + + "github.com/google/uuid" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +// ─── вспомогательная функция: найти очередь тенанта по имени ─────────────── +// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено. +func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) { + key := tenantAccessKey + ":" + queueName + models.SyncQueues.RLock() + q, ok := models.SyncQueues.Queues[key] + models.SyncQueues.RUnlock() + if !ok { + return "", nil + } + return key, q +} + +// Handler — admin API handler, holds TenantStore и admin token +type Handler struct { + store *tenant.TenantStore + adminToken string +} + +// NewHandler — создаёт admin handler +func NewHandler(store *tenant.TenantStore, adminToken string) *Handler { + return &Handler{store: store, adminToken: adminToken} +} + +// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth) +func (h *Handler) RegisterRoutes(r *mux.Router) { + adminRouter := r.PathPrefix("/admin").Subrouter() + adminRouter.Use(h.bearerAuthMiddleware) + adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST") + adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET") + adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") + adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") + adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET") + adminRouter.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST") + adminRouter.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE") + adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET") + adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST") + adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE") + adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET") +} + +// RegisterPublicRoutes — публичные маршруты для UI console (без auth) +// Дублируют admin API, но доступны без bearer token для удобства демо +// TODO: убрать или заменить на session-auth перед production +func (h *Handler) RegisterPublicRoutes(r *mux.Router) { + ui := r.PathPrefix("/ui/api").Subrouter() + ui.HandleFunc("/health", h.detailedHealth).Methods("GET") + ui.HandleFunc("/tenants", h.listTenants).Methods("GET") + ui.HandleFunc("/tenants", h.createTenant).Methods("POST") + ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") + ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") + ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET") + ui.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST") + ui.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE") + ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET") + ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST") + ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE") +} + +// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12) +func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + expected := "Bearer " + h.adminToken + if authHeader != expected { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) +} + +// createTenantRequest — тело запроса POST /admin/tenants +type createTenantRequest struct { + Name string `json:"name"` + MaxQueues int `json:"max_queues"` +} + +// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании) +type tenantCreateResponse struct { + ID string `json:"id"` + Name string `json:"name"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + MaxQueues int `json:"max_queues"` + CreatedAt time.Time `json:"created_at"` + Active bool `json:"active"` +} + +// tenantListItem — данные тенанта без secret_key (для List/Get) +type tenantListItem struct { + ID string `json:"id"` + Name string `json:"name"` + AccessKey string `json:"access_key"` + MaxQueues int `json:"max_queues"` + CreatedAt time.Time `json:"created_at"` + Active bool `json:"active"` +} + +// createTenant — POST /admin/tenants +func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) { + var req createTenantRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) + return + } + if req.Name == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "name is required"}) + return + } + t, err := h.store.Create(req.Name, req.MaxQueues) + if err != nil { + log.Errorf("admin: failed to create tenant: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"}) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(tenantCreateResponse{ + ID: t.ID, + Name: t.Name, + AccessKey: t.AccessKey, + SecretKey: t.SecretKey, + MaxQueues: t.MaxQueues, + CreatedAt: t.CreatedAt, + Active: t.Active, + }) +} + +// listTenants — GET /admin/tenants +func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) { + tenants := h.store.List() + items := make([]tenantListItem, 0, len(tenants)) + for _, t := range tenants { + items = append(items, tenantListItem{ + ID: t.ID, + Name: t.Name, + AccessKey: t.AccessKey, + MaxQueues: t.MaxQueues, + CreatedAt: t.CreatedAt, + Active: t.Active, + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} + +// getTenant — GET /admin/tenants/{id} +func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + t, ok := h.store.GetByID(id) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tenantListItem{ + ID: t.ID, + Name: t.Name, + AccessKey: t.AccessKey, + MaxQueues: t.MaxQueues, + CreatedAt: t.CreatedAt, + Active: t.Active, + }) +} + +// deleteTenant — DELETE /admin/tenants/{id} +// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak) +func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + t, ok := h.store.GetByID(id) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) + return + } + // Удаляем все очереди тенанта из SyncQueues + prefix := t.AccessKey + ":" + models.SyncQueues.Lock() + for key := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + delete(models.SyncQueues.Queues, key) + } + } + models.SyncQueues.Unlock() + + h.store.Delete(id) + w.WriteHeader(http.StatusNoContent) +} + +// listTenantQueues — GET /admin/tenants/{id}/queues +// Возвращает список очередей тенанта с количеством сообщений. +func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + t, ok := h.store.GetByID(id) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) + return + } + prefix := t.AccessKey + ":" + type queueInfo struct { + Name string `json:"name"` + Messages int `json:"messages"` + NotVisible int `json:"not_visible"` + VisibilityTimeout int `json:"visibility_timeout"` + MaxMessageSize int `json:"max_message_size"` + RetentionPeriod int `json:"retention_period"` + } + queues := make([]queueInfo, 0) + models.SyncQueues.RLock() + for key, q := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + name := strings.TrimPrefix(key, prefix) + visible := 0 + notVisible := 0 + for _, msg := range q.Messages { + if msg.ReceiptHandle != "" { + notVisible++ + } else { + visible++ + } + } + queues = append(queues, queueInfo{ + Name: name, + Messages: visible, + NotVisible: notVisible, + VisibilityTimeout: q.VisibilityTimeout, + MaxMessageSize: q.MaximumMessageSize, + RetentionPeriod: q.MessageRetentionPeriod, + }) + } + } + models.SyncQueues.RUnlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(queues) +} + +// ─── QUEUE MANAGEMENT HANDLERS ──────────────────────────────────────────── + +// createQueueRequest — тело запроса POST .../queues +type createQueueRequest struct { + Name string `json:"name"` +} + +// createTenantQueue — POST /admin/tenants/{id}/queues +// Создаёт новую очередь для тенанта прямо в SyncQueues (без SQS-протокола). +// Проверяет лимит MaxQueues тенанта и уникальность имени. +func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + tid := vars["id"] + t, ok := h.store.GetByID(tid) + if !ok { + jsonErr(w, http.StatusNotFound, "tenant not found") + return + } + var req createQueueRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { + jsonErr(w, http.StatusBadRequest, "name is required") + return + } + key := t.AccessKey + ":" + req.Name + models.SyncQueues.Lock() + if _, exists := models.SyncQueues.Queues[key]; exists { + models.SyncQueues.Unlock() + jsonErr(w, http.StatusConflict, "queue already exists") + return + } + // Проверяем лимит очередей тенанта + count := 0 + for k := range models.SyncQueues.Queues { + if strings.HasPrefix(k, t.AccessKey+":") { + count++ + } + } + if t.MaxQueues > 0 && count >= t.MaxQueues { + models.SyncQueues.Unlock() + jsonErr(w, http.StatusForbidden, "queue limit exceeded") + return + } + models.SyncQueues.Queues[key] = &models.Queue{ + Name: req.Name, + VisibilityTimeout: 30, + MaximumMessageSize: 262144, + MessageRetentionPeriod: 345600, + Messages: []models.SqsMessage{}, + Duplicates: make(map[string]time.Time), + } + models.SyncQueues.Unlock() + log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"name": req.Name, "status": "created"}) +} + +// deleteTenantQueue — DELETE /admin/tenants/{id}/queues/{queue} +// Удаляет очередь тенанта из SyncQueues вместе со всеми её сообщениями. +func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + tid, queueName := vars["id"], vars["queue"] + t, ok := h.store.GetByID(tid) + if !ok { + jsonErr(w, http.StatusNotFound, "tenant not found") + return + } + key := t.AccessKey + ":" + queueName + models.SyncQueues.Lock() + if _, exists := models.SyncQueues.Queues[key]; !exists { + models.SyncQueues.Unlock() + jsonErr(w, http.StatusNotFound, "queue not found") + return + } + delete(models.SyncQueues.Queues, key) + models.SyncQueues.Unlock() + log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID) + w.WriteHeader(http.StatusNoContent) +} + +// peekMessageItem — одно сообщение в ответе peekQueueMessages (без receipt handle) +type peekMessageItem struct { + ID string `json:"id"` + Body string `json:"body"` + MD5 string `json:"md5"` + SentAt string `json:"sent_at"` + Receives int `json:"receives"` + InFlight bool `json:"in_flight"` +} + +// peekQueueMessages — GET /admin/tenants/{id}/queues/{queue}/messages?limit=50 +// Peek-просмотр сообщений: НЕ удаляет, НЕ выставляет ReceiptHandle — только чтение. +// Это принципиальное отличие от SQS ReceiveMessage (который скрывает сообщения). +func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + tid, queueName := vars["id"], vars["queue"] + t, ok := h.store.GetByID(tid) + if !ok { + jsonErr(w, http.StatusNotFound, "tenant not found") + return + } + _, q := findQueue(t.AccessKey, queueName) + if q == nil { + jsonErr(w, http.StatusNotFound, "queue not found") + return + } + // Лимит по умолчанию 50, максимум 1000 + limit := 50 + if lv := r.URL.Query().Get("limit"); lv != "" { + if n := 0; len(lv) > 0 { + for _, c := range lv { + if c < '0' || c > '9' { + n = -1 + break + } + n = n*10 + int(c-'0') + } + if n > 0 && n <= 1000 { + limit = n + } + } + } + models.SyncQueues.RLock() + result := make([]peekMessageItem, 0, len(q.Messages)) + for i, msg := range q.Messages { + if i >= limit { + break + } + result = append(result, peekMessageItem{ + ID: msg.Uuid, + Body: msg.MessageBody, + MD5: msg.MD5OfMessageBody, + SentAt: msg.SentTime.Format(time.RFC3339), + Receives: msg.NumberOfReceives, + InFlight: msg.ReceiptHandle != "", + }) + } + models.SyncQueues.RUnlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// sendMessageRequest — тело запроса POST .../messages +type sendMessageRequest struct { + Body string `json:"body"` +} + +// sendMessageToQueue — POST /admin/tenants/{id}/queues/{queue}/messages +// Отправляет сообщение напрямую в очередь минуя SQS-протокол. +// Используется только из UI console — для prod нужен нормальный SQS send. +func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + tid, queueName := vars["id"], vars["queue"] + t, ok := h.store.GetByID(tid) + if !ok { + jsonErr(w, http.StatusNotFound, "tenant not found") + return + } + key, q := findQueue(t.AccessKey, queueName) + if q == nil { + jsonErr(w, http.StatusNotFound, "queue not found") + return + } + var req sendMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Body == "" { + jsonErr(w, http.StatusBadRequest, "body is required") + return + } + msg := models.SqsMessage{ + MessageBody: req.Body, + Uuid: uuid.NewString(), + SentTime: time.Now(), + } + models.SyncQueues.Lock() + models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) + models.SyncQueues.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"}) +} + +// purgeQueue — DELETE /admin/tenants/{id}/queues/{queue}/messages +// Удаляет все сообщения из очереди (purge). Сама очередь остаётся. +func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + tid, queueName := vars["id"], vars["queue"] + t, ok := h.store.GetByID(tid) + if !ok { + jsonErr(w, http.StatusNotFound, "tenant not found") + return + } + key, q := findQueue(t.AccessKey, queueName) + if q == nil { + jsonErr(w, http.StatusNotFound, "queue not found") + return + } + models.SyncQueues.Lock() + models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0] + models.SyncQueues.Unlock() + log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID) + w.WriteHeader(http.StatusNoContent) +} + +// jsonErr — вспомогательная функция: ответ с ошибкой в JSON +func jsonErr(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// adminHealthDetail — ответ GET /admin/health +type adminHealthDetail struct { + Status string `json:"status"` + TenantCount int `json:"tenant_count"` + QueueCount int `json:"queue_count"` + MessageCount int `json:"message_count"` +} + +// detailedHealth — GET /admin/health +func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) { + tenants := h.store.List() + models.SyncQueues.RLock() + queueCount := len(models.SyncQueues.Queues) + msgCount := 0 + for _, q := range models.SyncQueues.Queues { + msgCount += len(q.Messages) + } + models.SyncQueues.RUnlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(adminHealthDetail{ + Status: "ok", + TenantCount: len(tenants), + QueueCount: queueCount, + MessageCount: msgCount, + }) +} diff --git a/app/auth/auth_middleware.go b/app/auth/auth_middleware.go new file mode 100644 index 0000000..997363d --- /dev/null +++ b/app/auth/auth_middleware.go @@ -0,0 +1,114 @@ +// Изменено: 2026-04-09 +// Auth middleware для shared-sqs: извлекает AccessKeyId из AWS Authorization header +// и помещает найденного тенанта в context запроса. +package auth + +import ( +"context" +"encoding/xml" +"net/http" +"strings" + +"shared-sqs/app/tenant" +) + +// TenantContextKey — ключ для хранения тенанта в request context. +// Тип contextKey предотвращает конфликты с другими пакетами. +type contextKey string + +const TenantContextKey contextKey = "tenant" + +// AuthMiddleware — middleware: ищет тенанта по AccessKeyId из AWS Authorization header. +// Пропускает /health и /admin/** без tenant-аутентификации. +// Ловушка #4: не ставим короткий таймаут — ReceiveMessage с long polling держит соединение до 20 сек. +func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler { +return func(next http.Handler) http.Handler { +return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// /health — без auth +if r.URL.Path == "/health" { +next.ServeHTTP(w, r) +return +} +// /admin/** — отдельная auth (bearer token, см. admin_handlers.go) +if strings.HasPrefix(r.URL.Path, "/admin/") { +next.ServeHTTP(w, r) +return +} + +accessKeyID := extractAccessKeyID(r) +if accessKeyID == "" { +writeSQSAuthError(w, "MissingAuthenticationToken", "Request must contain either AccessKeyId or X-Amz-Credential") +return +} + +t, ok := store.GetByAccessKey(accessKeyID) +if !ok || !t.Active { +writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid") +return +} + +ctx := context.WithValue(r.Context(), TenantContextKey, t) +next.ServeHTTP(w, r.WithContext(ctx)) +}) +} +} + +// extractAccessKeyID — извлекает AWS AccessKeyId из запроса. +// Поддерживает оба варианта: Authorization header (Signature V4) и X-Amz-Credential query param (presigned URLs). +// Ловушка #3: AWS CLI ВСЕГДА отправляет Signature V4 — нужно парсить, даже не проверяя подпись. +// Ловушка #5: X-Amz-Security-Token (STS) — игнорируем. +func extractAccessKeyID(r *http.Request) string { +// Вариант 1: Authorization header +// Формат: "AWS4-HMAC-SHA256 Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request, ..." +auth := r.Header.Get("Authorization") +if strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { +idx := strings.Index(auth, "Credential=") +if idx >= 0 { +rest := auth[idx+len("Credential="):] +slashIdx := strings.Index(rest, "/") +if slashIdx > 0 { +return rest[:slashIdx] +} +} +} + +// Вариант 2: Query parameter (presigned URLs) +// Формат: X-Amz-Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request +if cred := r.URL.Query().Get("X-Amz-Credential"); cred != "" { +parts := strings.SplitN(cred, "/", 2) +if len(parts) > 0 && parts[0] != "" { +return parts[0] +} +} + +return "" +} + +// sqsAuthError — AWS-совместимый XML ответ об ошибке аутентификации. +type sqsAuthError struct { +XMLName xml.Name `xml:"ErrorResponse"` +Error sqsErrorBody `xml:"Error"` +RequestID string `xml:"RequestId"` +} + +type sqsErrorBody struct { +Type string `xml:"Type"` +Code string `xml:"Code"` +Message string `xml:"Message"` +} + +// writeSQSAuthError — отвечает AWS-совместимым XML с кодом 403. +func writeSQSAuthError(w http.ResponseWriter, code, message string) { +w.Header().Set("Content-Type", "application/xml") +w.WriteHeader(http.StatusForbidden) +resp := sqsAuthError{ +Error: sqsErrorBody{ +Type: "Sender", +Code: code, +Message: message, +}, +RequestID: "00000000-0000-0000-0000-000000000000", +} +data, _ := xml.Marshal(resp) +w.Write(data) +} diff --git a/app/cmd/goaws.go b/app/cmd/goaws.go new file mode 100644 index 0000000..d097e43 --- /dev/null +++ b/app/cmd/goaws.go @@ -0,0 +1,173 @@ +// app/cmd/goaws.go +// Entry point — shared-sqs server +// Updated: 2026-04-10 — добавлена Redis persistence (write-through cache) +package main + +import ( + "context" + "encoding/json" + "flag" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "shared-sqs/app/conf" + "shared-sqs/app/gosqs" + "shared-sqs/app/models" + "shared-sqs/app/persistence" + "shared-sqs/app/router" + "shared-sqs/app/tenant" + + log "github.com/sirupsen/logrus" +) + +func main() { + var configFile string + var adminToken string + var port string + var debug bool + var loglevel string + + flag.StringVar(&configFile, "config", "", "config file location") + flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token") + flag.StringVar(&port, "port", "4100", "listen port") + flag.BoolVar(&debug, "debug", false, "set debug log level") + flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)") + flag.Parse() + + log.SetFormatter(&log.JSONFormatter{}) + log.SetOutput(os.Stdout) + + if debug { + log.SetLevel(log.DebugLevel) + } else { + level, err := log.ParseLevel(loglevel) + if err != nil { + log.SetLevel(log.InfoLevel) + log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel) + } else { + log.SetLevel(level) + } + } + + // Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > fatal (Trap #13) + if adminToken == "" { + adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN") + } + if adminToken == "" { + log.Fatal("admin token required: use --admin-token flag or SHARED_SQS_ADMIN_TOKEN env var") + } + + // Загрузить конфиг (очереди, env — без SNS) + env := "Local" + if flag.NArg() > 0 { + env = flag.Arg(0) + } + conf.LoadYamlConfig(configFile, env) + + if models.CurrentEnvironment.LogToFile { + filename := models.CurrentEnvironment.LogFile + file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err == nil { + log.SetOutput(file) + } else { + log.Infof("Failed to log to file: %s, using default stdout", filename) + } + } + + // Инициализация in-memory TenantStore + tenantStore := tenant.NewTenantStore() + + // Подключение к Redis (если задан REDIS_ADDR) + // При ошибке — предупреждение, но продолжаем в memory-only режиме + redisAddr := os.Getenv("REDIS_ADDR") + redisUser := os.Getenv("REDIS_USER") + redisPass := os.Getenv("REDIS_PASSWORD") + if redisAddr != "" { + if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil { + log.Warnf("Не удалось подключиться к Redis: %v — работаем в memory-only режиме", err) + } + } + + // Восстановление состояния из Redis (тенанты + очереди) + if persistence.Client != nil { + // Загружаем тенантов + tenantsRaw, err := persistence.LoadAllTenantsRaw() + if err != nil { + log.Warnf("Ошибка загрузки тенантов из Redis: %v", err) + } else { + for _, jsonBytes := range tenantsRaw { + var t tenant.Tenant + if err := json.Unmarshal(jsonBytes, &t); err != nil { + log.Errorf("Ошибка десериализации тенанта: %v", err) + continue + } + tenantStore.LoadTenant(&t) + } + } + // Загружаем очереди + queues, err := persistence.LoadAllQueues() + if err != nil { + log.Warnf("Ошибка загрузки очередей из Redis: %v", err) + } else { + models.SyncQueues.Lock() + for k, q := range queues { + models.SyncQueues.Queues[k] = q + } + models.SyncQueues.Unlock() + } + } + + // Автосид демо-данных при SHARED_SQS_SEED_DEMO=true + if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" { + seedDemoData(tenantStore) + } + // Роутер с tenant auth и admin API + r := router.New(tenantStore, adminToken) + + // PeriodicTasks — visibility timeout, DLQ, deduplication + quit := make(chan bool) + go gosqs.PeriodicTasks(1*time.Second, quit) + + // HTTP сервер с таймаутами + srv := &http.Server{ + Addr: "0.0.0.0:" + port, + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s) + IdleTimeout: 60 * time.Second, + } + + // Запуск в горутине для graceful shutdown + serverErr := make(chan error, 1) + go func() { + log.Infof("shared-sqs listening on 0.0.0.0:%s", port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErr <- err + } + }() + + // Graceful shutdown по SIGTERM/SIGINT (Trap #13) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + + select { + case sig := <-sigCh: + log.Infof("Received signal %s, shutting down", sig) + case err := <-serverErr: + log.Fatalf("Server error: %v", err) + } + + // Остановить PeriodicTasks + close(quit) + + // Дать 10 секунд на завершение текущих HTTP запросов + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Errorf("Server shutdown error: %v", err) + } + log.Info("shared-sqs stopped") +} diff --git a/app/cmd/seed.go b/app/cmd/seed.go new file mode 100644 index 0000000..34f6374 --- /dev/null +++ b/app/cmd/seed.go @@ -0,0 +1,128 @@ +// app/cmd/seed.go +// Created: 2026-04-09 +// Updated: 2026-04-09 — фиксированные credentials для demo-tenant (BYOC) +// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true. +// Создаёт тенанта demo-service с 5 очередями и демо-сообщениями. +// +// DEMO CREDENTIALS — только для тестового стенда. +// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к +// admin API и к очередям других тенантов. Credentials открыты намеренно — стенд публичный. +package main + +import ( + "crypto/md5" //nolint:gosec — MD5 используется для SQS-совместимости, не для безопасности + "fmt" + "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + + "shared-sqs/app/models" + "shared-sqs/app/tenant" +) + +// Фиксированные credentials демо-тенанта. +// Открыты намеренно — тестовый стенд. +// Тенант ограничен 10 очередями и не имеет прав admin API. +const ( + demoTenantID = "t-demo-shared-sqs-ngcloud" + demoAccessKey = "SSAK-demo-shared-sqs" + demoSecretKey = "demo-secret-key-shared-sqs-ngcloud-2026" + demoTenantName = "demo-service" + demoMaxQueues = 10 +) + +// seedDemoData создаёт тенанта demo-service с очередями и сообщениями. +// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера. +func seedDemoData(store *tenant.TenantStore) { + t, err := store.CreateFixed(demoTenantName, demoMaxQueues, demoTenantID, demoAccessKey, demoSecretKey) + if err != nil { + log.Warnf("seed: не удалось создать demo-tenant: %v", err) + return + } + log.Infof("seed: создан тенант %s (AccessKey=%s)", t.ID, t.AccessKey) + + // Демо-очереди с набором сообщений + queues := []struct { + name string + msgs []string + }{ + { + "orders", + []string{ + `{"order_id":"1001","amount":99.99,"status":"pending"}`, + `{"order_id":"1002","amount":14.50,"status":"completed"}`, + `{"order_id":"1003","amount":299.00,"status":"processing"}`, + }, + }, + { + "notifications", + []string{ + `{"to":"user@example.com","text":"Welcome to the service!"}`, + `{"to":"admin@example.com","text":"New user signed up"}`, + }, + }, + { + "emails", + []string{ + `{"subject":"Invoice #42","body":"See attachment","to":"billing@example.com"}`, + }, + }, + { + "uploads", + nil, + }, + { + "user-events", + []string{ + `{"event":"login","user_id":"u-123","ts":1744000000}`, + `{"event":"logout","user_id":"u-123","ts":1744003600}`, + `{"event":"purchase","user_id":"u-456","item_id":"prod-7","ts":1744005000}`, + }, + }, + } + + for _, q := range queues { + key := t.AccessKey + ":" + q.name + + // URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN + // Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью. + env := models.CurrentEnvironment + var queueURL string + if env.Region != "" { + queueURL = "http://" + env.Region + "." + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name + } else { + queueURL = "http://" + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name + } + queueARN := "arn:aws:sqs:" + env.Region + ":" + t.ID + ":" + q.name + + msgs := make([]models.SqsMessage, 0, len(q.msgs)) + for _, body := range q.msgs { + //nolint:gosec — MD5 здесь для совместимости с AWS SQS протоколом + sum := md5.Sum([]byte(body)) //nolint:gosec + msgs = append(msgs, models.SqsMessage{ + MessageBody: body, + Uuid: uuid.NewString(), + MD5OfMessageBody: fmt.Sprintf("%x", sum), + SentTime: time.Now(), + }) + } + + models.SyncQueues.Lock() + models.SyncQueues.Queues[key] = &models.Queue{ + Name: q.name, + URL: queueURL, + Arn: queueARN, + VisibilityTimeout: 30, + MaximumMessageSize: 262144, + MessageRetentionPeriod: 345600, + Messages: msgs, + Duplicates: make(map[string]time.Time), + } + models.SyncQueues.Unlock() + + log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs)) + } + + log.Infof("seed: демо-данные готовы — тенант %s, 5 очередей", t.Name) +} diff --git a/app/conf/config.go b/app/conf/config.go new file mode 100644 index 0000000..fa02bea --- /dev/null +++ b/app/conf/config.go @@ -0,0 +1,196 @@ +package conf + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "shared-sqs/app/models" + "shared-sqs/app/utils" + + log "github.com/sirupsen/logrus" + + "github.com/ghodss/yaml" +) + +var envs map[string]models.Environment + +func LoadYamlConfig(filename string, env string) []string { + ports := []string{"4100"} + + // Гарантируем что дефолты всегда применяются, даже если конфиг не найден + defer applyEnvironmentDefaults() + + if filename == "" { + root, _ := filepath.Abs(".") + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if "goaws.yaml" == d.Name() { + filename = path + } + return nil + }) + if err != nil || filename == "" { + log.Warn("Failure to find default config file") + return ports + } + } + + filename, _ = filepath.Abs(filename) + if _, err := os.Stat(filename); err != nil { + log.Warnf("Failure to find config file: %s", filename) + return ports + } + + log.Infof("Loading config file: %s", filename) + yamlFile, err := os.ReadFile(filename) + if err != nil { + return ports + } + + err = yaml.Unmarshal(yamlFile, &envs) + if err != nil { + log.Errorf("err: %v\n", err) + return ports + } + if env == "" { + env = "Local" + } + + if envs[env].Region == "" { + models.CurrentEnvironment.Region = "local" + } + + models.CurrentEnvironment = envs[env] + + if envs[env].Port != "" { + ports = []string{envs[env].Port} + } + + models.LogMessages = false + models.LogFile = "./goaws_messages.log" + if envs[env].LogToFile == true { + models.LogMessages = true + if envs[env].LogFile != "" { + models.LogFile = envs[env].LogFile + } + } + + // Дефолты применяются через defer applyEnvironmentDefaults() в начале функции + + models.SyncQueues.Lock() + for _, queue := range envs[env].Queues { + queueUrl := "http://" + models.CurrentEnvironment.Host + ":" + models.CurrentEnvironment.Port + + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name + if models.CurrentEnvironment.Region != "" { + queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" + + models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name + } + queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name + + if queue.ReceiveMessageWaitTimeSeconds == 0 { + queue.ReceiveMessageWaitTimeSeconds = models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds + } + if queue.MaximumMessageSize == 0 { + queue.MaximumMessageSize = models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize + } + if queue.VisibilityTimeout == 0 { + queue.VisibilityTimeout = models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout + } + if queue.MessageRetentionPeriod == 0 { + queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod + } + + models.SyncQueues.Queues[queue.Name] = &models.Queue{ + Name: queue.Name, + VisibilityTimeout: queue.VisibilityTimeout, + Arn: queueArn, + URL: queueUrl, + ReceiveMessageWaitTimeSeconds: queue.ReceiveMessageWaitTimeSeconds, + MaximumMessageSize: queue.MaximumMessageSize, + MessageRetentionPeriod: queue.MessageRetentionPeriod, + IsFIFO: utils.HasFIFOQueueName(queue.Name), + EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, + Duplicates: make(map[string]time.Time), + } + } + + // Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка + for _, queue := range envs[env].Queues { + q := models.SyncQueues.Queues[queue.Name] + if queue.RedrivePolicy != "" { + err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy) + if err != nil { + log.Errorf("err: %s", err) + return ports + } + } + } + + models.SyncQueues.Unlock() + + return ports +} + +// applyEnvironmentDefaults — применяет дефолтные значения для QueueAttributeDefaults, +// AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты +// устанавливались при любом раннем return (например, если конфиг не найден). +func applyEnvironmentDefaults() { + if models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout <= 0 { + models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30 + } + if models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize <= 0 { + models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize = 262144 // 256K + } + if models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod <= 0 { + models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod = 345600 // 4 days + } + if models.CurrentEnvironment.AccountID == "" { + models.CurrentEnvironment.AccountID = "queue" + } + if models.CurrentEnvironment.Host == "" { + models.CurrentEnvironment.Host = "localhost" + models.CurrentEnvironment.Port = "4100" + } +} + +func setQueueRedrivePolicy(queues map[string]*models.Queue, q *models.Queue, strRedrivePolicy string) error { + // Поддерживаем maxReceiveCount как int и как string (AWS SDK использует string) + redrivePolicy1 := struct { + MaxReceiveCount int `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` + }{} + redrivePolicy2 := struct { + MaxReceiveCount string `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` + }{} + err1 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy1) + err2 := json.Unmarshal([]byte(strRedrivePolicy), &redrivePolicy2) + maxReceiveCount := redrivePolicy1.MaxReceiveCount + deadLetterQueueArn := redrivePolicy1.DeadLetterTargetArn + if err1 != nil && err2 != nil { + return fmt.Errorf("invalid json for queue redrive policy ") + } else if err1 != nil { + maxReceiveCount, _ = strconv.Atoi(redrivePolicy2.MaxReceiveCount) + deadLetterQueueArn = redrivePolicy2.DeadLetterTargetArn + } + + if (deadLetterQueueArn != "" && maxReceiveCount == 0) || + (deadLetterQueueArn == "" && maxReceiveCount != 0) { + return fmt.Errorf("invalid redrive policy values") + } + dlt := strings.Split(deadLetterQueueArn, ":") + deadLetterQueueName := dlt[len(dlt)-1] + deadLetterQueue, ok := queues[deadLetterQueueName] + if !ok { + return fmt.Errorf("deadletter queue not found") + } + q.DeadLetterQueue = deadLetterQueue + q.MaxReceiveCount = maxReceiveCount + + return nil +} diff --git a/app/conf/config_test.go b/app/conf/config_test.go new file mode 100644 index 0000000..f7d8053 --- /dev/null +++ b/app/conf/config_test.go @@ -0,0 +1,166 @@ +package conf + +import ( + "testing" + + "shared-sqs/app/models" + + "github.com/stretchr/testify/assert" +) + +func TestConfig_NoQueuesOrTopics(t *testing.T) { + env := "NoQueuesOrTopics" + port := LoadYamlConfig("./mock-data/mock-config.yaml", env) + if port[0] != "4100" { + t.Errorf("Expected port number 4200 but got %s\n", port) + } + + numQueues := len(envs[env].Queues) + if numQueues != 0 { + t.Errorf("Expected zero queues to be in the environment but got %d\n", numQueues) + } + numQueues = len(models.SyncQueues.Queues) + if numQueues != 0 { + t.Errorf("Expected zero queues to be in the sqs topics but got %d\n", numQueues) + } + + numTopics := len(envs[env].Topics) + if numTopics != 0 { + t.Errorf("Expected zero topics to be in the environment but got %d\n", numTopics) + } + numTopics = len(models.SyncTopics.Topics) + if numTopics != 0 { + t.Errorf("Expected zero topics to be in the sns topics but got %d\n", numTopics) + } +} + +func TestConfig_CreateQueuesTopicsAndSubscriptions(t *testing.T) { + env := "Local" + port := LoadYamlConfig("./mock-data/mock-config.yaml", env) + if port[0] != "4100" { + t.Errorf("Expected port number 4100 but got %s\n", port) + } + + numQueues := len(envs[env].Queues) + if numQueues != 4 { + t.Errorf("Expected three queues to be in the environment but got %d\n", numQueues) + } + numQueues = len(models.SyncQueues.Queues) + if numQueues != 6 { + t.Errorf("Expected five queues to be in the sqs topics but got %d\n", numQueues) + } + + numTopics := len(envs[env].Topics) + if numTopics != 2 { + t.Errorf("Expected two topics to be in the environment but got %d\n", numTopics) + } + numTopics = len(models.SyncTopics.Topics) + if numTopics != 2 { + t.Errorf("Expected two topics to be in the sns topics but got %d\n", numTopics) + } +} + +func TestConfig_QueueAttributes(t *testing.T) { + var emptyQueue *models.Queue + env := "Local" + port := LoadYamlConfig("./mock-data/mock-config.yaml", env) + if port[0] != "4100" { + t.Errorf("Expected port number 4100 but got %s\n", port) + } + + assert.Equal(t, 10, models.SyncQueues.Queues["local-queue1"].ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 10, models.SyncQueues.Queues["local-queue1"].VisibilityTimeout) + assert.Equal(t, 1024, models.SyncQueues.Queues["local-queue1"].MaximumMessageSize) + assert.Equal(t, emptyQueue, models.SyncQueues.Queues["local-queue1"].DeadLetterQueue) + assert.Equal(t, 0, models.SyncQueues.Queues["local-queue1"].MaxReceiveCount) + assert.Equal(t, 345600, models.SyncQueues.Queues["local-queue1"].MessageRetentionPeriod) + assert.Equal(t, 100, models.SyncQueues.Queues["local-queue3"].MaxReceiveCount) + + assert.Equal(t, "local-queue3-dlq", models.SyncQueues.Queues["local-queue3"].DeadLetterQueue.Name) + assert.Equal(t, 128, models.SyncQueues.Queues["local-queue2"].MaximumMessageSize) + assert.Equal(t, 150, models.SyncQueues.Queues["local-queue2"].VisibilityTimeout) + assert.Equal(t, 245600, models.SyncQueues.Queues["local-queue2"].MessageRetentionPeriod) +} + +func TestConfig_NoQueueAttributeDefaults(t *testing.T) { + env := "NoQueueAttributeDefaults" + LoadYamlConfig("./mock-data/mock-config.yaml", env) + + receiveWaitTime := models.SyncQueues.Queues["local-queue1"].ReceiveMessageWaitTimeSeconds + if receiveWaitTime != 0 { + t.Errorf("Expected local-queue1 Queue to be configured with ReceiveMessageWaitTimeSeconds: 0 but got %d\n", receiveWaitTime) + } + timeoutSecs := models.SyncQueues.Queues["local-queue1"].VisibilityTimeout + if timeoutSecs != 30 { + t.Errorf("Expected local-queue1 Queue to be configured with VisibilityTimeout: 30 but got %d\n", timeoutSecs) + } + + receiveWaitTime = models.SyncQueues.Queues["local-queue2"].ReceiveMessageWaitTimeSeconds + if receiveWaitTime != 20 { + t.Errorf("Expected local-queue2 Queue to be configured with ReceiveMessageWaitTimeSeconds: 20 but got %d\n", receiveWaitTime) + } + + messageRetentionPeriod := models.SyncQueues.Queues["local-queue1"].MessageRetentionPeriod + if messageRetentionPeriod != 345600 { + t.Errorf("Expected local-queue2 Queue to be configured with VisibilityTimeout: 150 but got %d\n", timeoutSecs) + } +} + +func TestConfig_invalid_config_resorts_to_default_queue_attributes(t *testing.T) { + env := "missing" + port := LoadYamlConfig("./mock-data/mock-config.yaml", env) + if port[0] != "4100" { + t.Errorf("Expected port number 4100 but got %s\n", port) + } + + assert.Equal(t, 262144, models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize) + assert.Equal(t, 345600, models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod) + assert.Equal(t, 0, models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 30, models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout) +} + +func TestConfig_LoadYamlConfig_finds_default_config(t *testing.T) { + expectedQueues := []string{ + "local-queue1", + "local-queue2", + "local-queue3", + "local-queue3-dlq", + "local-queue4", + } + expectedTopics := []string{ + "local-topic1", + "local-topic2", + "local-topic3", + "local-topic4", + } + + env := "Local" + LoadYamlConfig("", env) + + queues := models.SyncQueues.Queues + topics := models.SyncTopics.Topics + for _, expectedName := range expectedQueues { + _, ok := queues[expectedName] + assert.True(t, ok) + } + for _, expectedName := range expectedTopics { + _, ok := topics[expectedName] + assert.True(t, ok) + } +} + +func TestConfig_LoadYamlConfig_missing_config_loads_nothing(t *testing.T) { + models.CurrentEnvironment = models.Environment{} + ports := LoadYamlConfig("/garbage", "Local") + + assert.Equal(t, []string{"4100"}, ports) + assert.Equal(t, models.CurrentEnvironment, models.Environment{}) +} + +func TestConfig_LoadYamlConfig_invalid_config_loads_nothing(t *testing.T) { + models.CurrentEnvironment = models.Environment{} + ports := LoadYamlConfig("../common/common.go", "Local") + + assert.Equal(t, []string{"4100"}, ports) + assert.Equal(t, models.CurrentEnvironment, models.Environment{}) +} diff --git a/app/conf/goaws.yaml b/app/conf/goaws.yaml new file mode 100755 index 0000000..e926598 --- /dev/null +++ b/app/conf/goaws.yaml @@ -0,0 +1,44 @@ +Local: # Environment name that can be passed on the command line + # (i.e.: ./goaws [Local | Dev] -- defaults to 'Local') + Host: goaws.com # hostname of the goaws system (for docker-compose this is the tag name of the container) + # you can now use either 1 port for both sns and sqs or alternatively you can comment out Port and use SqsPort + SnsPort for compatibilyt with + # yopa and (fage-sns + face-sqs). If both ways are in the config file on the one "Port" will be used by GoAws + Port: 4100 # port to listen on. + # SqsPort: 9324 # alterante Sqs Port + # SnsPort: 9292 # alternate Sns Port + Region: us-east-1 + AccountId: "100010001000" + LogToFile: false # Log messages (true/false) + LogFile: .st/goaws_messages.log # Log filename (for message logging + EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId + QueueAttributeDefaults: # default attributes for all queues + VisibilityTimeout: 30 # message visibility timeout + ReceiveMessageWaitTimeSeconds: 0 # receive message max wait time + MaximumMessageSize: 262144 # maximum message size (bytes) +# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented + Queues: [] # No default queues — created via Admin API / AWS CLI by tenants + Topics: [] # No default topics + RandomLatency: # Parameters for introducing random latency into message queuing + Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied. + Max: 0 # Desired latency in milliseconds + +Dev: # Another environment + Host: localhost + Port: 4100 + # SqsPort: 9324 + # SnsPort: 9292 + AccountId: "794373491471" + LogToFile: false + LogFile: ./goaws_messages.log + Queues: + - Name: dev-queue1 + - Name: dev-queue2 + Topics: + - Name: dev-topic1 + Subscriptions: + - QueueName: dev-queue3 + Raw: false + - QueueName: dev-queue4 + Raw: true + - Name: dev-topic2 + diff --git a/app/conf/mock-data/mock-config.yaml b/app/conf/mock-data/mock-config.yaml new file mode 100644 index 0000000..d3215cf --- /dev/null +++ b/app/conf/mock-data/mock-config.yaml @@ -0,0 +1,79 @@ +Local: + Host: localhost + Port: 4100 + Region: us-east-1 + AccountId: "100010001000" + LogMessages: true + LogFile: ./goaws_messages.log + QueueAttributeDefaults: + VisibilityTimeout: 10 + ReceiveMessageWaitTimeSeconds: 10 + MaximumMessageSize: 1024 + Queues: + - Name: local-queue1 + - Name: local-queue2 + ReceiveMessageWaitTimeSeconds: 20 + MaximumMessageSize: 128 + VisibilityTimeout: 150 + MessageRetentionPeriod: 245600 + - Name: local-queue3 + RedrivePolicy: '{"maxReceiveCount": 100, "deadLetterTargetArn":"arn:aws:sqs:us-east-1:100010001000:local-queue3-dlq"}' + - Name: local-queue3-dlq + Topics: + - Name: local-topic1 + Subscriptions: + - QueueName: local-queue4 + Raw: false + - QueueName: local-queue5 + Raw: true + FilterPolicy: '{"foo":["bar"]}' + - Name: local-topic2 + +NoQueuesOrTopics: + Host: localhost + Port: 4100 + LogMessages: true + LogFile: ./goaws_messages.log + Region: eu-west-1 + +NoQueueAttributeDefaults: + Host: localhost + Port: 4100 + LogMessages: true + LogFile: ./goaws_messages.log + Region: eu-west-1 + Queues: + - Name: local-queue1 + - Name: local-queue2 + ReceiveMessageWaitTimeSeconds: 20 + +BaseUnitTests: + Host: host + Port: port + Region: region + AccountId: accountID + LogMessages: true + LogFile: ./goaws_messages.log + Queues: + - Name: unit-queue1 + - Name: unit-queue2 + RedrivePolicy: '{"maxReceiveCount": 1, "deadLetterTargetArn":"arn:aws:sqs:us-east-1:100010001000:dead-letter-queue1"}' + - Name: dead-letter-queue1 + - Name: subscribed-queue1 + - Name: subscribed-queue3 + Topics: + - Name: unit-topic1 + Subscriptions: + - QueueName: subscribed-queue1 + Raw: true + - Name: unit-topic2 + - Name: unit-topic3 + Subscriptions: + - QueueName: subscribed-queue3 + Raw: false + - Name: unit-topic-http + Subscriptions: + - Protocol: http + EndPoint: http://over.ride.me/for/tests + TopicArn: arn:aws:sqs:region:accountID:unit-topic-http + Raw: true diff --git a/app/gosqs/change_message_visibility.go b/app/gosqs/change_message_visibility.go new file mode 100644 index 0000000..f34e9e5 --- /dev/null +++ b/app/gosqs/change_message_visibility.go @@ -0,0 +1,87 @@ +// Изменено: 2026-04-09 +// ChangeMessageVisibilityV1 — меняет visibility timeout сообщения в очереди тенанта. +package gosqs + +import ( +"net/http" +"strings" +"time" + +"shared-sqs/app/interfaces" +"shared-sqs/app/models" +"shared-sqs/app/utils" +"github.com/gorilla/mux" +log "github.com/sirupsen/logrus" +) + +func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractResponseBody) { +requestBody := models.NewChangeMessageVisibilityRequest() +ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) +if !ok { +log.Error("Invalid Request - ChangeMessageVisibilityV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} + +t := getTenantFromContext(req) +if t == nil { +return utils.CreateErrorResponseV1("InvalidClientTokenId", true) +} + +vars := mux.Vars(req) +queueUrl := requestBody.QueueUrl +queueName := "" +if queueUrl == "" { +queueName = vars["queueName"] +} else { +uriSegments := strings.Split(queueUrl, "/") +queueName = uriSegments[len(uriSegments)-1] +} + +key := tenantQueueKey(t.AccessKey, queueName) +receiptHandle := requestBody.ReceiptHandle +visibilityTimeout := requestBody.VisibilityTimeout + +if visibilityTimeout > 43200 { +return utils.CreateErrorResponseV1("ValidationError", true) +} + +if _, ok := models.SyncQueues.Queues[key]; !ok { +return utils.CreateErrorResponseV1("QueueNotFound", true) +} + +models.SyncQueues.Lock() +messageFound := false +for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ { +queue := models.SyncQueues.Queues[key] +msgs := queue.Messages +if msgs[i].ReceiptHandle == receiptHandle { +timeout := models.SyncQueues.Queues[key].VisibilityTimeout +if visibilityTimeout == 0 { +msgs[i].ReceiptTime = time.Now().UTC() +msgs[i].ReceiptHandle = "" +msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(timeout) * time.Second) +msgs[i].Retry++ +if queue.MaxReceiveCount > 0 && +queue.DeadLetterQueue != nil && +msgs[i].Retry >= queue.MaxReceiveCount { +queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i]) +queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...) +} +} else { +msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second) +} +messageFound = true +break +} +} +models.SyncQueues.Unlock() +if !messageFound { +return utils.CreateErrorResponseV1("MessageNotInFlight", true) +} + +respStruct := models.ChangeMessageVisibilityResult{ +Xmlns: models.BaseXmlns, +Metadata: models.BaseResponseMetadata, +} +return http.StatusOK, &respStruct +} diff --git a/app/gosqs/change_message_visibility_test.go b/app/gosqs/change_message_visibility_test.go new file mode 100644 index 0000000..2e9adac --- /dev/null +++ b/app/gosqs/change_message_visibility_test.go @@ -0,0 +1,75 @@ +package gosqs + +import ( + "net/http" + "testing" + + "shared-sqs/app/test" + + "shared-sqs/app/fixtures" + "shared-sqs/app/models" + "github.com/stretchr/testify/assert" +) + +func TestChangeMessageVisibility_success(t *testing.T) { + // create a queue + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + q := &models.Queue{ + Name: "testing", + Messages: []models.SqsMessage{{ + MessageBody: "test1", + ReceiptHandle: "123", + }}, + } + models.SyncQueues.Queues["testing"] = q + + // The default value for the VisibilityTimeout is the zero value of time.Time + assert.Zero(t, q.Messages[0].VisibilityTimeout) + + _, r := test.GenerateRequestInfo("POST", "/", models.ChangeMessageVisibilityRequest{ + QueueUrl: "http://localhost:4100/queue/testing", + ReceiptHandle: "123", + VisibilityTimeout: 0, + }, true) + status, _ := ChangeMessageVisibilityV1(r) + assert.Equal(t, status, http.StatusOK) + + // Changing the message visibility increments the time.Time by N seconds + // from the current time. + // + // Given that the current time is relative between calling the endpoint and + // the time being set, we can't reliably assert an exact value. So assert + // that the time.Time value is no longer the default zero value. + assert.NotZero(t, q.Messages[0].VisibilityTimeout) + assert.NotZero(t, q.Messages[0].ReceiptTime) + assert.Equal(t, "", q.Messages[0].ReceiptHandle) + assert.Equal(t, 1, q.Messages[0].Retry) +} + +func TestChangeMessageVisibility_success_adds_to_existing_visibility_timeout(t *testing.T) { + // TODO +} + +func TestChangeMessageVisibility_success_transfers_to_dead_letter_queue(t *testing.T) { + // TODO +} + +func TestChangeMessageVisibility_request_transformer_error(t *testing.T) { + // TODO +} + +func TestChangeMessageVisibility_visibility_timeout_too_large(t *testing.T) { + // TODO +} + +func TestChangeMessageVisibility_missing_queue(t *testing.T) { + // TODO +} + +func TestChangeMessageVisibility_missing_message(t *testing.T) { + // TODO - mismatch receipt handle +} diff --git a/app/gosqs/create_queue.go b/app/gosqs/create_queue.go new file mode 100644 index 0000000..357d27b --- /dev/null +++ b/app/gosqs/create_queue.go @@ -0,0 +1,68 @@ +// Изменено: 2026-04-10 — добавлена Redis persistence +// CreateQueueV1 — создаёт очередь для тенанта из request context. +// Ключ в SyncQueues: "{tenantAccessKey}:{queueName}" для изоляции между тенантами. +package gosqs + +import ( + "net/http" + "time" + + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/persistence" + "shared-sqs/app/utils" + + log "github.com/sirupsen/logrus" +) + +func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) { + requestBody := models.NewCreateQueueRequest() + ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) + if !ok { + log.Error("Invalid Request - CreateQueueV1") + return utils.CreateErrorResponseV1("InvalidParameterValue", true) + } + + t := getTenantFromContext(req) + if t == nil { + return utils.CreateErrorResponseV1("InvalidClientTokenId", true) + } + + // Ловушка #8: передаём queueName (не key) в HasFIFOQueueName — иначе .fifo не определится + queueName := requestBody.QueueName + key := tenantQueueKey(t.AccessKey, queueName) + queueUrl := tenantQueueURL(t, queueName) + queueArn := tenantQueueARN(t, queueName) + + models.SyncQueues.Lock() + if _, exists := models.SyncQueues.Queues[key]; !exists { + // Проверка лимита очередей тенанта + if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues { + models.SyncQueues.Unlock() + return utils.CreateErrorResponseV1("LimitExceeded", true) + } + log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID) + queue := &models.Queue{ + Name: queueName, + URL: queueUrl, + Arn: queueArn, + IsFIFO: utils.HasFIFOQueueName(queueName), + EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, + Duplicates: make(map[string]time.Time), + } + if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil { + models.SyncQueues.Unlock() + return utils.CreateErrorResponseV1(err.Error(), true) + } + models.SyncQueues.Queues[key] = queue + } + // Сохраняем очередь в Redis пока держим Lock — консистентный снапшот + persistence.SaveQueue(key, models.SyncQueues.Queues[key]) + models.SyncQueues.Unlock() + respStruct := models.CreateQueueResponse{ + Xmlns: models.BaseXmlns, + Result: models.CreateQueueResult{QueueUrl: queueUrl}, + Metadata: models.BaseResponseMetadata, + } + return http.StatusOK, respStruct +} diff --git a/app/gosqs/create_queue_test.go b/app/gosqs/create_queue_test.go new file mode 100644 index 0000000..0e47ec7 --- /dev/null +++ b/app/gosqs/create_queue_test.go @@ -0,0 +1,254 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + "time" + + "shared-sqs/app/test" + + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/mitchellh/copystructure" + "github.com/stretchr/testify/assert" +) + +func TestCreateQueueV1_success(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.CreateQueueRequest) + *v = fixtures.CreateQueueRequest + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := CreateQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, fixtures.CreateQueueResponse, response) + + actualQueue := models.SyncQueues.Queues[fixtures.QueueName] + assert.Equal(t, fixtures.FullyPopulatedQueue, actualQueue) +} + +func TestCreateQueueV1_success_with_redrive_policy(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest) + c, _ := dupe.(models.CreateQueueRequest) + c.Attributes.RedrivePolicy = models.RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", fixtures.DeadLetterQueueName), + } + + v := resultingStruct.(*models.CreateQueueRequest) + *v = c + return true + } + + dlq := &models.Queue{ + Name: fixtures.DeadLetterQueueName, + } + models.SyncQueues.Queues[fixtures.DeadLetterQueueName] = dlq + + expectedQueue := &models.Queue{ + Name: fixtures.QueueName, + URL: fmt.Sprintf("http://%s.%s:%s/%s/%s", + fixtures.LOCAL_ENVIRONMENT.Region, + fixtures.LOCAL_ENVIRONMENT.Host, + fixtures.LOCAL_ENVIRONMENT.Port, + fixtures.LOCAL_ENVIRONMENT.AccountID, + fixtures.QueueName, + ), + Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s", + fixtures.LOCAL_ENVIRONMENT.Region, + fixtures.LOCAL_ENVIRONMENT.AccountID, + fixtures.QueueName, + ), + VisibilityTimeout: 5, + ReceiveMessageWaitTimeSeconds: 4, + DelaySeconds: 1, + MaximumMessageSize: 2, + MessageRetentionPeriod: 3, + DeadLetterQueue: dlq, + MaxReceiveCount: 100, + Duplicates: make(map[string]time.Time), + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := CreateQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, fixtures.CreateQueueResponse, response) + + actualQueue := models.SyncQueues.Queues[fixtures.QueueName] + assert.Equal(t, expectedQueue, actualQueue) +} + +func TestCreateQueueV1_success_with_existing_queue(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.CreateQueueRequest) + *v = fixtures.CreateQueueRequest + return true + } + + q := &models.Queue{ + Name: fixtures.QueueName, + } + models.SyncQueues.Queues[fixtures.QueueName] = q + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := CreateQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, fixtures.CreateQueueResponse, response) + + actualQueue := models.SyncQueues.Queues[fixtures.QueueName] + assert.Equal(t, q, actualQueue) +} + +func TestCreateQueueV1_success_with_no_request_attributes_falls_back_to_default(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest) + c, _ := dupe.(models.CreateQueueRequest) + c.Attributes = models.QueueAttributes{} + + v := resultingStruct.(*models.CreateQueueRequest) + *v = c + return true + } + + expectedQueue := &models.Queue{ + Name: fixtures.QueueName, + URL: fmt.Sprintf("http://%s.%s:%s/%s/%s", + fixtures.LOCAL_ENVIRONMENT.Region, + fixtures.LOCAL_ENVIRONMENT.Host, + fixtures.LOCAL_ENVIRONMENT.Port, + fixtures.LOCAL_ENVIRONMENT.AccountID, + fixtures.QueueName, + ), + Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s", + fixtures.LOCAL_ENVIRONMENT.Region, + fixtures.LOCAL_ENVIRONMENT.AccountID, + fixtures.QueueName, + ), + VisibilityTimeout: 0, + ReceiveMessageWaitTimeSeconds: 0, + DelaySeconds: 0, + MaximumMessageSize: 0, + MessageRetentionPeriod: 0, + Duplicates: make(map[string]time.Time), + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := CreateQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, fixtures.CreateQueueResponse, response) + + actualQueue := models.SyncQueues.Queues[fixtures.QueueName] + assert.Equal(t, expectedQueue, actualQueue) +} + +func TestCreateQueueV1_success_no_configured_region_for_queue_url(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + models.CurrentEnvironment.Region = "" + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest) + c, _ := dupe.(models.CreateQueueRequest) + c.Attributes = models.QueueAttributes{} + + v := resultingStruct.(*models.CreateQueueRequest) + *v = c + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := CreateQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + + actualQueue := models.SyncQueues.Queues[fixtures.QueueName] + assert.Equal(t, + fmt.Sprintf("http://%s:%s/%s/%s", + fixtures.LOCAL_ENVIRONMENT.Host, + fixtures.LOCAL_ENVIRONMENT.Port, + fixtures.LOCAL_ENVIRONMENT.AccountID, + fixtures.QueueName, + ), + actualQueue.URL, + ) +} + +func TestCreateQueueV1_request_transformer_error(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := CreateQueueV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestCreateQueueV1_invalid_dead_letter_queue_error(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest) + c, _ := dupe.(models.CreateQueueRequest) + c.Attributes.RedrivePolicy = models.RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"), + } + + v := resultingStruct.(*models.CreateQueueRequest) + *v = c + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := CreateQueueV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} diff --git a/app/gosqs/delete_message.go b/app/gosqs/delete_message.go new file mode 100644 index 0000000..0f5e2f1 --- /dev/null +++ b/app/gosqs/delete_message.go @@ -0,0 +1,68 @@ +// Изменено: 2026-04-10 — добавлена Redis persistence +// DeleteMessageV1 — удаляет сообщение из очереди тенанта по ReceiptHandle. +package gosqs + +import ( + "net/http" + "strings" + + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/persistence" + "shared-sqs/app/utils" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) { + requestBody := models.NewDeleteMessageRequest() + ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) + if !ok { + log.Error("Invalid Request - DeleteMessageV1") + return utils.CreateErrorResponseV1("InvalidParameterValue", true) + } + + t := getTenantFromContext(req) + if t == nil { + return utils.CreateErrorResponseV1("InvalidClientTokenId", true) + } + + receiptHandle := requestBody.ReceiptHandle + queueUrl := requestBody.QueueUrl + queueName := "" + if queueUrl == "" { + vars := mux.Vars(req) + queueName = vars["queueName"] + } else { + uriSegments := strings.Split(queueUrl, "/") + queueName = uriSegments[len(uriSegments)-1] + } + + key := tenantQueueKey(t.AccessKey, queueName) + log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle) + + models.SyncQueues.Lock() + defer models.SyncQueues.Unlock() + if _, ok := models.SyncQueues.Queues[key]; ok { + for i, msg := range models.SyncQueues.Queues[key].Messages { + if msg.ReceiptHandle == receiptHandle { + models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID) + models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...) + delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID) + // Сохраняем очередь в Redis пока держим Lock + persistence.SaveQueue(key, models.SyncQueues.Queues[key]) + respStruct := models.DeleteMessageResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + return 200, &respStruct + } + } + log.Warning("Receipt Handle not found") + } else { + log.Warning("Queue not found") + } + + return utils.CreateErrorResponseV1("MessageDoesNotExist", true) +} diff --git a/app/gosqs/delete_message_batch.go b/app/gosqs/delete_message_batch.go new file mode 100644 index 0000000..ebafc99 --- /dev/null +++ b/app/gosqs/delete_message_batch.go @@ -0,0 +1,119 @@ +// Изменено: 2026-04-09 +// DeleteMessageBatchV1 — пакетное удаление сообщений из очереди тенанта. +package gosqs + +import ( +"net/http" +"strings" + +"shared-sqs/app/interfaces" +"shared-sqs/app/models" +"shared-sqs/app/utils" +"github.com/gorilla/mux" +log "github.com/sirupsen/logrus" +) + +func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody) { +requestBody := models.NewDeleteMessageBatchRequest() +ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) +if !ok { +log.Error("Invalid Request - DeleteMessageBatchV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} + +t := getTenantFromContext(req) +if t == nil { +return utils.CreateErrorResponseV1("InvalidClientTokenId", true) +} + +queueUrl := requestBody.QueueUrl +queueName := "" +if queueUrl == "" { +vars := mux.Vars(req) +queueName = vars["queueName"] +} else { +uriSegments := strings.Split(queueUrl, "/") +queueName = uriSegments[len(uriSegments)-1] +} + +key := tenantQueueKey(t.AccessKey, queueName) + +if _, ok := models.SyncQueues.Queues[key]; !ok { +return utils.CreateErrorResponseV1("QueueNotFound", true) +} + +if len(requestBody.Entries) == 0 { +return utils.CreateErrorResponseV1("EmptyBatchRequest", true) +} + +if len(requestBody.Entries) > 10 { +return utils.CreateErrorResponseV1("TooManyEntriesInBatchRequest", true) +} + +ids := map[string]bool{} +for _, v := range requestBody.Entries { +if _, found := ids[v.Id]; found { +return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true) +} +ids[v.Id] = true +} + +models.SyncQueues.Lock() +defer models.SyncQueues.Unlock() + +deleteMessageMap := make(map[string]*deleteEntry) +for _, entry := range requestBody.Entries { +deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{ +Id: entry.Id, +ReceiptHandle: entry.ReceiptHandle, +Deleted: false, +} +} + +deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0) +remainingMessages := make([]models.SqsMessage, 0, len(models.SyncQueues.Queues[key].Messages)) + +for _, message := range models.SyncQueues.Queues[key].Messages { +if de, found := deleteMessageMap[message.ReceiptHandle]; found { +log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID) +models.SyncQueues.Queues[key].UnlockGroup(message.GroupID) +delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID) +de.Deleted = true +deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id}) +} else { +remainingMessages = append(remainingMessages, message) +} +} + +models.SyncQueues.Queues[key].Messages = remainingMessages + +notFoundEntries := make([]models.BatchResultErrorEntry, 0) +for _, de := range deleteMessageMap { +if !de.Deleted { +notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{ +Code: "1", +Id: de.Id, +Message: "Message not found", +SenderFault: true, +}) +} +} + +respStruct := models.DeleteMessageBatchResponse{ +Xmlns: models.BaseXmlns, +Result: models.DeleteMessageBatchResult{ +Successful: deletedEntries, +Failed: notFoundEntries, +}, +Metadata: models.BaseResponseMetadata, +} + +return http.StatusOK, respStruct +} + +type deleteEntry struct { +Id string +ReceiptHandle string +Error string +Deleted bool +} diff --git a/app/gosqs/delete_message_batch_test.go b/app/gosqs/delete_message_batch_test.go new file mode 100644 index 0000000..87ff69c --- /dev/null +++ b/app/gosqs/delete_message_batch_test.go @@ -0,0 +1,329 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + + "shared-sqs/app/conf" + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/test" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestDeleteMessageBatchV1_success_all_message(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + q := &models.Queue{ + Name: "testing", + Messages: []models.SqsMessage{ + { + MessageBody: "test%20message%20body%201", + ReceiptHandle: "test1", + }, + { + MessageBody: "test%20message%20body%202", + ReceiptHandle: "test2", + }, + { + MessageBody: "test%20message%20body%203", + ReceiptHandle: "test3", + }, + }, + } + models.SyncQueues.Queues["testing"] = q + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteMessageBatchRequest) + *v = models.DeleteMessageBatchRequest{ + Entries: []models.DeleteMessageBatchRequestEntry{ + { + Id: "delete-test-1", + ReceiptHandle: "test1", + }, + { + Id: "delete-test-2", + ReceiptHandle: "test2", + }, + { + Id: "delete-test-3", + ReceiptHandle: "test3", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"), + } + return true + } + _, request2 := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + + status, response2 := DeleteMessageBatchV1(request2) + deleteMessageBatchResponse := response2.(models.DeleteMessageBatchResponse) + assert.Equal(t, status, http.StatusOK) + assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id) + assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Successful[1].Id) + assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[2].Id) + assert.Empty(t, deleteMessageBatchResponse.Result.Failed) + assert.Empty(t, models.SyncQueues.Queues["testing"].Messages) +} +func TestDeleteMessageBatchV1_success_not_found_message(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + q := &models.Queue{ + Name: "testing", + Messages: []models.SqsMessage{ + { + MessageBody: "test%20message%20body%201", + ReceiptHandle: "test1", + }, + { + MessageBody: "test%20message%20body%203", + ReceiptHandle: "test3", + }, + }, + } + models.SyncQueues.Queues["testing"] = q + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteMessageBatchRequest) + *v = models.DeleteMessageBatchRequest{ + Entries: []models.DeleteMessageBatchRequestEntry{ + { + Id: "delete-test-1", + ReceiptHandle: "test1", + }, + { + Id: "delete-test-2", + ReceiptHandle: "test2", + }, + { + Id: "delete-test-3", + ReceiptHandle: "test3", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"), + } + return true + } + _, request := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + + status, response := DeleteMessageBatchV1(request) + deleteMessageBatchResponse := response.(models.DeleteMessageBatchResponse) + assert.Equal(t, status, http.StatusOK) + assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id) + assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[1].Id) + assert.Equal(t, "1", deleteMessageBatchResponse.Result.Failed[0].Code) + assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Failed[0].Id) + assert.Equal(t, "Message not found", deleteMessageBatchResponse.Result.Failed[0].Message) + assert.True(t, deleteMessageBatchResponse.Result.Failed[0].SenderFault) + assert.Empty(t, models.SyncQueues.Queues["testing"].Messages) +} + +func TestDeleteMessageBatchV1_error_not_found_queue(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteMessageBatchRequest) + *v = models.DeleteMessageBatchRequest{ + Entries: []models.DeleteMessageBatchRequestEntry{ + { + Id: "delete-test-1", + ReceiptHandle: "test1", + }, + { + Id: "delete-test-2", + ReceiptHandle: "test2", + }, + { + Id: "delete-test-3", + ReceiptHandle: "test3", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue"), + } + return true + } + _, r := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + + status, _ := DeleteMessageBatchV1(r) + assert.Equal(t, status, http.StatusBadRequest) + +} + +func TestDeleteMessageBatchV1_error_no_entry(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteMessageBatchRequest) + *v = models.DeleteMessageBatchRequest{ + Entries: make([]models.DeleteMessageBatchRequestEntry, 0), + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + return true + } + _, r := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + + status, _ := DeleteMessageBatchV1(r) + assert.Equal(t, status, http.StatusBadRequest) +} + +func TestDeleteMessageBatchV1_error_too_many_entries(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteMessageBatchRequest) + *v = models.DeleteMessageBatchRequest{ + Entries: []models.DeleteMessageBatchRequestEntry{ + { + Id: "test-1", + ReceiptHandle: "test-1", + }, + { + Id: "test-2", + ReceiptHandle: "test-2", + }, + { + Id: "test-3", + ReceiptHandle: "test-3", + }, + { + Id: "test-4", + ReceiptHandle: "test-4", + }, + { + Id: "test-5", + ReceiptHandle: "test-5", + }, + { + Id: "test-6", + ReceiptHandle: "test-6", + }, + { + Id: "test-7", + ReceiptHandle: "test-7", + }, + { + Id: "test-8", + ReceiptHandle: "test-8", + }, + { + Id: "test-9", + ReceiptHandle: "test-9", + }, + { + Id: "test-10", + ReceiptHandle: "test-10", + }, + { + Id: "test-11", + ReceiptHandle: "test-11", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + return true + } + _, r := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + + status, _ := DeleteMessageBatchV1(r) + assert.Equal(t, status, http.StatusBadRequest) +} + +func TestDeleteMessageBatchV1_Error_IdNotDistinct(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteMessageBatchRequest) + *v = models.DeleteMessageBatchRequest{ + Entries: []models.DeleteMessageBatchRequestEntry{ + { + Id: "delete-test-1", + ReceiptHandle: "test1", + }, + { + Id: "delete-test-1", + ReceiptHandle: "test2", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + return true + } + _, r := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + + status, _ := DeleteMessageBatchV1(r) + assert.Equal(t, http.StatusBadRequest, status) +} + +func TestDeleteMessageBatchV1_Error_transformer(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, _ := DeleteMessageBatchV1(r) + + assert.Equal(t, http.StatusBadRequest, status) + +} diff --git a/app/gosqs/delete_message_test.go b/app/gosqs/delete_message_test.go new file mode 100644 index 0000000..0722f72 --- /dev/null +++ b/app/gosqs/delete_message_test.go @@ -0,0 +1,38 @@ +package gosqs + +import ( + "net/http" + "testing" + + "shared-sqs/app/test" + + "shared-sqs/app/fixtures" + "shared-sqs/app/models" + "github.com/stretchr/testify/assert" +) + +func TestDeleteMessage(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + q := &models.Queue{ + Name: "testing", + Messages: []models.SqsMessage{{ + MessageBody: "test1", + ReceiptHandle: "123", + }}, + } + + models.SyncQueues.Queues["testing"] = q + + _, r := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{ + QueueUrl: "http://localhost:4100/queue/testing", + ReceiptHandle: "123", + }, true) + status, _ := DeleteMessageV1(r) + + assert.Equal(t, status, http.StatusOK) + assert.Empty(t, q.Messages) +} diff --git a/app/gosqs/delete_queue.go b/app/gosqs/delete_queue.go new file mode 100644 index 0000000..674181f --- /dev/null +++ b/app/gosqs/delete_queue.go @@ -0,0 +1,48 @@ +// Изменено: 2026-04-10 — добавлена Redis persistence +// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу. +package gosqs + +import ( + "net/http" + "strings" + + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/persistence" + "shared-sqs/app/utils" + + log "github.com/sirupsen/logrus" +) + +func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) { + requestBody := models.NewDeleteQueueRequest() + ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) + if !ok { + log.Error("Invalid Request - DeleteQueueV1") + return utils.CreateErrorResponseV1("InvalidParameterValue", true) + } + + t := getTenantFromContext(req) + if t == nil { + return utils.CreateErrorResponseV1("InvalidClientTokenId", true) + } + + uriSegments := strings.Split(requestBody.QueueUrl, "/") + queueName := uriSegments[len(uriSegments)-1] + key := tenantQueueKey(t.AccessKey, queueName) + + log.Infof("Deleting Queue: %s (tenant: %s)", queueName, t.ID) + + models.SyncQueues.Lock() + delete(models.SyncQueues.Queues, key) + models.SyncQueues.Unlock() + + // Удаляем из Redis асинхронно + persistence.DeleteQueue(key) + + respStruct := models.DeleteQueueResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + return http.StatusOK, respStruct +} diff --git a/app/gosqs/delete_queue_test.go b/app/gosqs/delete_queue_test.go new file mode 100644 index 0000000..fc279d6 --- /dev/null +++ b/app/gosqs/delete_queue_test.go @@ -0,0 +1,91 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + + "shared-sqs/app/test" + + "github.com/stretchr/testify/assert" + + "shared-sqs/app/conf" + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" +) + +func TestDeleteQueueV1_success(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteQueueRequest) + *v = models.DeleteQueueRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + return true + } + + expectedResponse := models.DeleteQueueResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := DeleteQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) + + _, ok := models.SyncQueues.Queues["unit-queue1"] + assert.False(t, ok) +} + +func TestDeleteQueueV1_success_unknown_queue(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.DeleteQueueRequest) + *v = models.DeleteQueueRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unknown-queue1"), + } + return true + } + + expectedResponse := models.DeleteQueueResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := DeleteQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) +} + +func TestDeleteQueueV1_error_invalid_request(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := DeleteQueueV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} diff --git a/app/gosqs/get_queue_attributes.go b/app/gosqs/get_queue_attributes.go new file mode 100644 index 0000000..c0f9cba --- /dev/null +++ b/app/gosqs/get_queue_attributes.go @@ -0,0 +1,118 @@ +// Изменено: 2026-04-09 +// GetQueueAttributesV1 — возвращает атрибуты очереди тенанта. +package gosqs + +import ( +"fmt" +"net/http" +"strconv" +"strings" + +"shared-sqs/app/interfaces" +"shared-sqs/app/models" +"shared-sqs/app/utils" +"github.com/mitchellh/copystructure" +log "github.com/sirupsen/logrus" +) + +func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) { +requestBody := models.NewGetQueueAttributesRequest() +ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) +if !ok { +log.Error("Invalid Request - GetQueueAttributesV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} +if requestBody.QueueUrl == "" { +log.Error("Missing QueueUrl - GetQueueAttributesV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} + +t := getTenantFromContext(req) +if t == nil { +return utils.CreateErrorResponseV1("InvalidClientTokenId", true) +} + +requestedAttributes := func() map[string]bool { +attrs := map[string]bool{} +if len(requestBody.AttributeNames) == 0 { +return map[string]bool{"All": true} +} +for _, attr := range requestBody.AttributeNames { +if "All" == attr { +return map[string]bool{"All": true} +} +attrs[attr] = true +} +return attrs +}() + +dupe, _ := copystructure.Copy(models.AvailableQueueAttributes) +includedAttributes, _ := dupe.(map[string]bool) +_, ok = requestedAttributes["All"] +if !ok { +for attr := range includedAttributes { +if _, ok := requestedAttributes[attr]; !ok { +delete(includedAttributes, attr) +} +} +} + +uriSegments := strings.Split(requestBody.QueueUrl, "/") +queueName := uriSegments[len(uriSegments)-1] +key := tenantQueueKey(t.AccessKey, queueName) + +log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID) +queueAttributes := make([]models.Attribute, 0) + +models.SyncQueues.RLock() +defer models.SyncQueues.RUnlock() +queue, ok := models.SyncQueues.Queues[key] +if !ok { +log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID) +return utils.CreateErrorResponseV1("QueueNotFound", true) +} + +if _, ok := includedAttributes["DelaySeconds"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)}) +} +if _, ok := includedAttributes["MaximumMessageSize"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "MaximumMessageSize", Value: strconv.Itoa(queue.MaximumMessageSize)}) +} +if _, ok := includedAttributes["MessageRetentionPeriod"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "MessageRetentionPeriod", Value: strconv.Itoa(queue.MessageRetentionPeriod)}) +} +if _, ok := includedAttributes["ReceiveMessageWaitTimeSeconds"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "ReceiveMessageWaitTimeSeconds", Value: strconv.Itoa(queue.ReceiveMessageWaitTimeSeconds)}) +} +if _, ok := includedAttributes["VisibilityTimeout"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)}) +} +if _, ok := includedAttributes["ApproximateNumberOfMessages"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))}) +} +if _, ok := includedAttributes["ApproximateNumberOfMessagesNotVisible"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(*queue))}) +} +if _, ok := includedAttributes["CreatedTimestamp"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"}) +} +if _, ok := includedAttributes["LastModifiedTimestamp"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"}) +} +if _, ok := includedAttributes["QueueArn"]; ok { +queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn}) +} +if _, ok := includedAttributes["RedrivePolicy"]; ok && queue.DeadLetterQueue != nil { +queueAttributes = append(queueAttributes, models.Attribute{ +Name: "RedrivePolicy", +Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn), +}) +} + +respStruct := models.GetQueueAttributesResponse{ +Xmlns: models.BaseXmlns, +Result: models.GetQueueAttributesResult{Attrs: queueAttributes}, +Metadata: models.BaseResponseMetadata, +} +return http.StatusOK, respStruct +} diff --git a/app/gosqs/get_queue_attributes_test.go b/app/gosqs/get_queue_attributes_test.go new file mode 100644 index 0000000..885dc3c --- /dev/null +++ b/app/gosqs/get_queue_attributes_test.go @@ -0,0 +1,180 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + + "shared-sqs/app/test" + + "github.com/mitchellh/copystructure" + + "shared-sqs/app/conf" + + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestGetQueueAttributesV1_success_all(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueAttributesRequest) + *v = fixtures.GetQueueAttributesRequest + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := GetQueueAttributesV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, fixtures.GetQueueAttributesResponse, response) +} + +func TestGetQueueAttributesV1_success_no_request_attrs_returns_all(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueAttributesRequest) + *v = models.GetQueueAttributesRequest{ + QueueUrl: "unit-queue1", + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := GetQueueAttributesV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, fixtures.GetQueueAttributesResponse, response) +} + +func TestGetQueueAttributesV1_success_all_with_redrive_queue(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueAttributesRequest) + *v = models.GetQueueAttributesRequest{ + QueueUrl: "unit-queue2", + AttributeNames: []string{"All"}, + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := GetQueueAttributesV1(r) + + dupe, _ := copystructure.Copy(fixtures.GetQueueAttributesResponse) + expectedResponse, _ := dupe.(models.GetQueueAttributesResponse) + expectedResponse.Result.Attrs[9].Value = fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, "unit-queue2") + expectedResponse.Result.Attrs = append(expectedResponse.Result.Attrs, + models.Attribute{ + Name: "RedrivePolicy", + Value: fmt.Sprintf(`{"maxReceiveCount":"1", "deadLetterTargetArn":"%s:%s"}`, fixtures.BASE_SQS_ARN, "dead-letter-queue1"), + }, + ) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) +} + +func TestGetQueueAttributesV1_success_specific_fields(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueAttributesRequest) + *v = models.GetQueueAttributesRequest{ + QueueUrl: fmt.Sprintf("%s/unit-queue1", fixtures.BASE_URL), + AttributeNames: []string{"DelaySeconds"}, + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := GetQueueAttributesV1(r) + + expectedResponse := models.GetQueueAttributesResponse{ + Xmlns: models.BaseXmlns, + Result: models.GetQueueAttributesResult{Attrs: []models.Attribute{ + models.Attribute{ + Name: "DelaySeconds", + Value: "0", + }, + }}, + Metadata: models.BaseResponseMetadata, + } + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) +} + +func TestGetQueueAttributesV1_request_transformer_error(t *testing.T) { + defer func() { + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := GetQueueAttributesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestGetQueueAttributesV1_missing_queue_url_in_request_returns_error(t *testing.T) { + defer func() { + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueAttributesRequest) + *v = models.GetQueueAttributesRequest{ + QueueUrl: "", + AttributeNames: []string{}, + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := GetQueueAttributesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestGetQueueAttributesV1_missing_queue_returns_error(t *testing.T) { + defer func() { + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueAttributesRequest) + *v = fixtures.GetQueueAttributesRequest + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := GetQueueAttributesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} diff --git a/app/gosqs/get_queue_url.go b/app/gosqs/get_queue_url.go new file mode 100644 index 0000000..6a243f0 --- /dev/null +++ b/app/gosqs/get_queue_url.go @@ -0,0 +1,44 @@ +// Изменено: 2026-04-09 +// GetQueueUrlV1 — возвращает URL очереди тенанта по имени. +package gosqs + +import ( +"net/http" + +"shared-sqs/app/interfaces" +"shared-sqs/app/models" +"shared-sqs/app/utils" +log "github.com/sirupsen/logrus" +) + +func GetQueueUrlV1(req *http.Request) (int, interfaces.AbstractResponseBody) { +requestBody := models.NewGetQueueUrlRequest() +ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) +if !ok { +log.Error("Invalid Request - GetQueueUrlV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} + +t := getTenantFromContext(req) +if t == nil { +return utils.CreateErrorResponseV1("InvalidClientTokenId", true) +} + +queueName := requestBody.QueueName +key := tenantQueueKey(t.AccessKey, queueName) + +if _, ok := models.SyncQueues.Queues[key]; !ok { +log.Errorf("Get Queue URL: %s, queue does not exist for tenant %s", queueName, t.ID) +return utils.CreateErrorResponseV1("QueueNotFound", true) +} + +queue := models.SyncQueues.Queues[key] +log.Debug("Get Queue URL:", queue.Name) + +respStruct := models.GetQueueUrlResponse{ +Xmlns: models.BaseXmlns, +Result: models.GetQueueUrlResult{QueueUrl: queue.URL}, +Metadata: models.BaseResponseMetadata, +} +return http.StatusOK, respStruct +} diff --git a/app/gosqs/get_queue_url_test.go b/app/gosqs/get_queue_url_test.go new file mode 100644 index 0000000..d3c5615 --- /dev/null +++ b/app/gosqs/get_queue_url_test.go @@ -0,0 +1,104 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + + "shared-sqs/app/test" + + "shared-sqs/app/conf" + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestGetQueueUrlV1_success(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueUrlRequest) + *v = models.GetQueueUrlRequest{ + QueueName: "unit-queue1", + QueueOwnerAWSAccountId: "fugafuga", + } + return true + } + + _, r := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + code, response := GetQueueUrlV1(r) + + get_queue_url_response := response.(models.GetQueueUrlResponse) + + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, get_queue_url_response.Result.QueueUrl, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1")) + +} + +func TestGetQueueUrlV1_error_no_queue(t *testing.T) { + + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.GetQueueUrlRequest) + *v = models.GetQueueUrlRequest{ + QueueName: "not-exist-unit-queue1", + QueueOwnerAWSAccountId: "fugafuga", + } + return true + } + + _, r := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + code, response := GetQueueUrlV1(r) + + expected := models.ErrorResult{ + Type: "Not Found", + Code: "AWS.SimpleQueueService.NonExistentQueue", + Message: "The specified queue does not exist for this wsdl version.", + } + + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal(t, response.GetResult().(models.ErrorResult), expected) +} + +func TestGetQueueUrlV1_error_request_transformer(t *testing.T) { + + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo( + "POST", + "/", + nil, + true) + code, _ := GetQueueUrlV1(r) + assert.Equal(t, http.StatusBadRequest, code) +} diff --git a/app/gosqs/gosqs.go b/app/gosqs/gosqs.go new file mode 100644 index 0000000..6a45977 --- /dev/null +++ b/app/gosqs/gosqs.go @@ -0,0 +1,82 @@ +package gosqs + +import ( + "net/url" + "time" + + "shared-sqs/app/models" + + log "github.com/sirupsen/logrus" +) + +func init() { + models.SyncQueues.Queues = make(map[string]*models.Queue) +} + +func PeriodicTasks(d time.Duration, quit chan bool) { + ticker := time.NewTicker(d) + for { + select { + case <-ticker.C: + models.SyncQueues.Lock() + for qName := range models.SyncQueues.Queues { + queue := models.SyncQueues.Queues[qName] + + // Reset deduplication period + for dedupId, startTime := range queue.Duplicates { + if time.Now().After(startTime.Add(models.DeduplicationPeriod)) { + log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId) + delete(queue.Duplicates, dedupId) + } + } + + log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages)) + for i := 0; i < len(queue.Messages); i++ { + msg := &queue.Messages[i] + + if msg.ReceiptHandle != "" { + if msg.VisibilityTimeout.Before(time.Now()) { + log.Debugf("Making message visible again %s", msg.ReceiptHandle) + queue.UnlockGroup(msg.GroupID) + msg.ReceiptHandle = "" + msg.ReceiptTime = time.Now().UTC() + msg.Retry++ + if queue.MaxReceiveCount > 0 && + queue.DeadLetterQueue != nil && + msg.Retry >= queue.MaxReceiveCount { + queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg) + queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...) + i-- + } + } + } + } + } + models.SyncQueues.Unlock() + case <-quit: + ticker.Stop() + return + } + } +} + +func numberOfHiddenMessagesInQueue(queue models.Queue) int { + num := 0 + for _, m := range queue.Messages { + if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) { + num++ + } + } + return num +} + +func getQueueFromPath(formVal string, theUrl string) string { + if formVal != "" { + return formVal + } + u, err := url.Parse(theUrl) + if err != nil { + return "" + } + return u.Path +} diff --git a/app/gosqs/gosqs_test.go b/app/gosqs/gosqs_test.go new file mode 100644 index 0000000..4256703 --- /dev/null +++ b/app/gosqs/gosqs_test.go @@ -0,0 +1,668 @@ +package gosqs + +import ( + "fmt" + "net/http" + "net/url" + "sync" + "testing" + "time" + + "shared-sqs/app/conf" + + "shared-sqs/app/fixtures" + + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +// TODO - Admiral-Piett these are better but still screwy. It's easy to have race conditions in here, so +// we have to name all the queues uniquely and leave them around so we're not resetting ourselves. +// Stupid. Handle the global memory issues and this can be easily resolved. +func Test_PeriodicTasks_deletes_deduplication_period_upon_expiration(t *testing.T) { + models.DeduplicationPeriod = 20 * time.Millisecond + quit := make(chan bool) + defer func() { + models.ResetApp() + quit <- true + models.DeduplicationPeriod = 5 * time.Minute + }() + + qName := "gosqs-dedupe-queue1" + mainQueue := &models.Queue{ + Name: qName, + URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName), + Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName), + Duplicates: map[string]time.Time{ + "12345": time.Now(), + }, + } + models.SyncQueues.Lock() + models.SyncQueues.Queues[qName] = mainQueue + models.SyncQueues.Unlock() + + go PeriodicTasks(10*time.Millisecond, quit) + + assertions := func() bool { + models.SyncQueues.Lock() + defer models.SyncQueues.Unlock() + + ok := 0 == len(mainQueue.Duplicates) + if !ok { + return false + } + return true + } + assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond) +} + +func Test_PeriodicTasks_VisibilityTimeout_expires(t *testing.T) { + quit := make(chan bool) + defer func() { + models.ResetApp() + quit <- true + }() + qName := "gosqs-visibility-queue1" + mainQueue := &models.Queue{ + Name: qName, + URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName), + Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName), + } + mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{ + MessageBody: "1", + ReceiptHandle: "12345", + VisibilityTimeout: time.Now().Add(30 * time.Millisecond), + }) + + models.SyncQueues.Lock() + models.SyncQueues.Queues[qName] = mainQueue + models.SyncQueues.Unlock() + + go PeriodicTasks(10*time.Millisecond, quit) + + assertions := func() bool { + models.SyncQueues.Lock() + defer models.SyncQueues.Unlock() + + ok := !mainQueue.Messages[0].ReceiptTime.IsZero() + if !ok { + return false + } + ok = "1" == mainQueue.Messages[0].MessageBody + if !ok { + return false + } + ok = "" == mainQueue.Messages[0].ReceiptHandle + if !ok { + return false + } + ok = 1 == mainQueue.Messages[0].Retry + if !ok { + return false + } + return true + } + assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond) +} + +func Test_PeriodicTasks_moves_single_message_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) { + quit := make(chan bool) + defer func() { + models.ResetApp() + quit <- true + }() + + qName := "gosqs-main-queue1" + dlqName := "gosqs-dead-letter-queue1" + dlqQueue := &models.Queue{ + Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, dlqName), + Name: dlqName, + URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, dlqName), + } + mainQueue := &models.Queue{ + Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, qName), + DeadLetterQueue: dlqQueue, + MaxReceiveCount: 1, + Name: qName, + URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName), + } + + go PeriodicTasks(10*time.Millisecond, quit) + + models.SyncQueues.Lock() + mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{ + MessageBody: "1", + Retry: 100, + ReceiptHandle: "12345", + VisibilityTimeout: time.Now().Add(10 * time.Millisecond), + }) + models.SyncQueues.Queues[qName] = mainQueue + models.SyncQueues.Queues[dlqName] = dlqQueue + models.SyncQueues.Unlock() + + assertions := func() bool { + models.SyncQueues.Lock() + defer models.SyncQueues.Unlock() + + ok := len(dlqQueue.Messages) == 1 + if !ok { + return false + } + ok = "1" == dlqQueue.Messages[0].MessageBody + if !ok { + return false + } + return true + } + assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond) +} + +func Test_PeriodicTasks_moves_multiple_messages_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) { + quit := make(chan bool) + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + quit <- true + }() + + mainQueue := models.SyncQueues.Queues["unit-queue2"] + dlqQueue := models.SyncQueues.Queues["dead-letter-queue1"] + + assert.Len(t, dlqQueue.Messages, 0) + + go PeriodicTasks(10*time.Millisecond, quit) + + models.SyncQueues.Lock() + mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{ + MessageBody: "1", + Retry: 100, + ReceiptHandle: "12345", + }) + mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{ + MessageBody: "2", + Retry: 100, + ReceiptHandle: "23456", + }) + models.SyncQueues.Unlock() + + assertions := func() bool { + models.SyncQueues.Lock() + defer models.SyncQueues.Unlock() + + ok := len(dlqQueue.Messages) == 2 + if !ok { + return false + } + ok = "1" == dlqQueue.Messages[0].MessageBody + if !ok { + return false + } + ok = "2" == dlqQueue.Messages[1].MessageBody + if !ok { + return false + } + return true + } + assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond) +} + +// TODO - I think all these below belong in handler tests, not in here. Double check the relevant +// handlers for coverage and delete. +func TestSendingAndReceivingFromFIFOQueueReturnsSameMessageOnError(t *testing.T) { + done := make(chan bool) + go PeriodicTasks(1*time.Second, done) + + // create a queue + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "requeue-reset.fifo") + form.Add("Attribute.1.Name", "VisibilityTimeout") + form.Add("Attribute.1.Value", "2") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ := CreateQueueV1(req) + assert.Equal(t, status, http.StatusOK) + + // send a message + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo") + form.Add("MessageBody", "1") + form.Add("MessageGroupId", "GROUP-X") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + + // send a message + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo") + form.Add("MessageBody", "2") + form.Add("MessageGroupId", "GROUP-X") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + + // receive message + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "ReceiveMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, resp := ReceiveMessageV1(req) + assert.Equal(t, status, http.StatusOK) + + result := resp.GetResult().(models.ReceiveMessageResult) + receiptHandleFirst := result.Messages[0].ReceiptHandle + if string(result.Messages[0].Body) != "1" { + t.Fatalf("should have received body 1: %s", err) + } + + // try to receive another message and we should get none + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "ReceiveMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = ReceiveMessageV1(req) + assert.Equal(t, status, http.StatusOK) + + if len(models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages) != 1 { + t.Fatal("there should be only 1 group locked") + } + + if models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages["GROUP-X"] != 0 { + t.Fatal("there should be GROUP-X locked") + } + + // remove message + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "DeleteMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo") + form.Add("ReceiptHandle", receiptHandleFirst) + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = DeleteMessageV1(req) + assert.Equal(t, status, http.StatusOK) + + if len(models.SyncQueues.Queues["requeue-reset.fifo"].Messages) != 1 { + t.Fatal("there should be only 1 message in queue") + } + + // receive message - loop until visibility timeouts + for { + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "ReceiveMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, resp := ReceiveMessageV1(req) + assert.Equal(t, status, http.StatusOK) + + result := resp.GetResult().(models.ReceiveMessageResult) + if len(result.Messages) == 0 { + continue + } + + if string(result.Messages[0].Body) != "2" { + t.Fatalf("should have received body 2: %s", err) + } + break + } + + done <- true +} + +func TestSendMessage_POST_DuplicatationNotAppliedToStandardQueue(t *testing.T) { + done := make(chan bool) + go PeriodicTasks(1*time.Second, done) + + // create a queue + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "stantdard-testing") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ := CreateQueueV1(req) + + assert.Equal(t, status, http.StatusOK) + + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing") + form.Add("MessageBody", "Test1") + form.Add("MessageDeduplicationId", "123") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + + // Check the status code is what we expect. + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 0 { + t.Fatal("there should be 1 message in queue") + } + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing") + form.Add("MessageBody", "Test2") + form.Add("MessageDeduplicationId", "123") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + + // Check the status code is what we expect. + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 1 { + t.Fatal("there should be 2 messages in queue") + } + done <- true +} + +func TestSendMessage_POST_DuplicatationDisabledOnFifoQueue(t *testing.T) { + done := make(chan bool) + go PeriodicTasks(1*time.Second, done) + + // create a queue + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "no-dup-testing.fifo") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ := CreateQueueV1(req) + + assert.Equal(t, status, http.StatusOK) + + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo") + form.Add("MessageBody", "Test1") + form.Add("MessageDeduplicationId", "123") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + + // Check the status code is what we expect. + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) == 0 { + t.Fatal("there should be 1 message in queue") + } + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo") + form.Add("MessageBody", "Test2") + form.Add("MessageDeduplicationId", "123") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + + // Check the status code is what we expect. + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) != 2 { + t.Fatal("there should be 2 message in queue") + } + done <- true +} + +func TestSendMessage_POST_DuplicatationEnabledOnFifoQueue(t *testing.T) { + done := make(chan bool) + go PeriodicTasks(1*time.Second, done) + + // create a queue + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "dup-testing.fifo") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ := CreateQueueV1(req) + + assert.Equal(t, status, http.StatusOK) + + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + models.SyncQueues.Queues["dup-testing.fifo"].EnableDuplicates = true + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo") + form.Add("MessageBody", "Test1") + form.Add("MessageDeduplicationId", "123") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + + // Check the status code is what we expect. + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) == 0 { + t.Fatal("there should be 1 message in queue") + } + + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo") + form.Add("MessageBody", "Test2") + form.Add("MessageDeduplicationId", "123") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + + // Check the status code is what we expect. + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) != 1 { + t.Fatal("there should be 1 message in queue") + } + if body := models.SyncQueues.Queues["dup-testing.fifo"].Messages[0].MessageBody; string(body) == "Test2" { + t.Fatal("duplicate message should not be added to queue") + } + done <- true +} + +func TestSendMessage_POST_DelaySeconds(t *testing.T) { + // create a queue + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "sendmessage-delay") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ := CreateQueueV1(req) + + assert.Equal(t, status, http.StatusOK) + + // send a message + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + form = url.Values{} + form.Add("Action", "SendMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay") + form.Add("MessageBody", "1") + form.Add("DelaySeconds", "2") + form.Add("Version", "2012-11-05") + req.PostForm = form + + status, _ = SendMessageV1(req) + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", + status, http.StatusOK) + } + + // receive message before delay is up + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + form = url.Values{} + form.Add("Action", "ReceiveMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay") + form.Add("Version", "2012-11-05") + req.PostForm = form + status, _ = ReceiveMessageV1(req) + assert.Equal(t, status, http.StatusOK) + + // receive message with wait should return after delay + req, err = http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + form = url.Values{} + form.Add("Action", "ReceiveMessage") + form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay") + form.Add("WaitTimeSeconds", "10") + form.Add("Version", "2012-11-05") + req.PostForm = form + start := time.Now() + status, _ = ReceiveMessageV1(req) + elapsed := time.Since(start) + assert.Equal(t, status, http.StatusOK) + if elapsed < 1*time.Second { + t.Errorf("handler didn't wait at all") + } + if elapsed > 4*time.Second { + t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2") + } +} + +func TestCreateErrorResponseV1(t *testing.T) { + expectedResponse := models.ErrorResponse{ + Result: models.ErrorResult{ + Type: "Not Found", + Code: "AWS.SimpleQueueService.NonExistentQueue", + Message: "The specified queue does not exist for this wsdl version.", + }, + RequestId: "00000000-0000-0000-0000-000000000000", + } + status, response := utils.CreateErrorResponseV1("QueueNotFound", true) + + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, expectedResponse, response) +} + +// waitTimeout waits for the waitgroup for the specified max timeout. +// Returns true if waiting timed out. +// credits: https://stackoverflow.com/questions/32840687/timeout-for-waitgroup-wait +func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool { + c := make(chan struct{}) + go func() { + defer close(c) + wg.Wait() + }() + select { + case <-c: + return false // completed normally + case <-time.After(timeout): + return true // timed out + } +} diff --git a/app/gosqs/list_queues.go b/app/gosqs/list_queues.go new file mode 100644 index 0000000..8bb098f --- /dev/null +++ b/app/gosqs/list_queues.go @@ -0,0 +1,51 @@ +// Изменено: 2026-04-09 +// ListQueuesV1 — возвращает только очереди текущего тенанта. +// Изоляция: фильтруем SyncQueues по префиксу "{tenantAccessKey}:". +package gosqs + +import ( +"net/http" +"strings" + +"shared-sqs/app/interfaces" +"shared-sqs/app/models" +"shared-sqs/app/utils" +log "github.com/sirupsen/logrus" +) + +func ListQueuesV1(req *http.Request) (int, interfaces.AbstractResponseBody) { +requestBody := models.NewListQueuesRequest() +ok := utils.REQUEST_TRANSFORMER(requestBody, req, true) +if !ok { +log.Error("Invalid Request - ListQueuesV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} + +t := getTenantFromContext(req) +if t == nil { +return utils.CreateErrorResponseV1("InvalidClientTokenId", true) +} + +log.Infof("Listing Queues for tenant: %s", t.ID) +queueUrls := make([]string, 0) +prefix := t.AccessKey + ":" + +models.SyncQueues.Lock() +for key, queue := range models.SyncQueues.Queues { +// Показываем только очереди этого тенанта +if strings.HasPrefix(key, prefix) { +if strings.HasPrefix(queue.Name, requestBody.QueueNamePrefix) { +queueUrls = append(queueUrls, queue.URL) +} +} +} +models.SyncQueues.Unlock() + +respStruct := models.ListQueuesResponse{ +Xmlns: models.BaseXmlns, +Metadata: models.BaseResponseMetadata, +Result: models.ListQueuesResult{QueueUrls: queueUrls}, +} + +return http.StatusOK, respStruct +} diff --git a/app/gosqs/list_queues_test.go b/app/gosqs/list_queues_test.go new file mode 100644 index 0000000..ab85647 --- /dev/null +++ b/app/gosqs/list_queues_test.go @@ -0,0 +1,116 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + + "shared-sqs/app/test" + + "shared-sqs/app/conf" + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestListQueuesV1_success(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.ListQueueRequest) + *v = models.ListQueueRequest{} + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := ListQueuesV1(r) + r1 := response.(models.ListQueuesResponse) + + assert.Equal(t, http.StatusOK, code) + assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1")) + assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue2")) + assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1")) +} + +func TestListQueuesV1_success_no_queues(t *testing.T) { + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.ListQueueRequest) + *v = models.ListQueueRequest{} + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := ListQueuesV1(r) + r1 := response.(models.ListQueuesResponse) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, r1.Result.QueueUrls, []string{}) +} + +func TestListQueuesV1_success_with_queue_name_prefix(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.ListQueueRequest) + *v = models.ListQueueRequest{QueueNamePrefix: "dead-letter"} + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := ListQueuesV1(r) + r1 := response.(models.ListQueuesResponse) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, []string{fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1")}, r1.Result.QueueUrls) +} + +func TestListQueuesV1_success_with_queue_name_prefix_no_matching_queues(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.ListQueueRequest) + *v = models.ListQueueRequest{QueueNamePrefix: "garbage"} + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := ListQueuesV1(r) + r1 := response.(models.ListQueuesResponse) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, []string{}, r1.Result.QueueUrls) +} + +func TestListQueuesV1_request_transformer_error(t *testing.T) { + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := ListQueuesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} diff --git a/app/gosqs/purge_queue.go b/app/gosqs/purge_queue.go new file mode 100644 index 0000000..e661640 --- /dev/null +++ b/app/gosqs/purge_queue.go @@ -0,0 +1,53 @@ +// Изменено: 2026-04-10 — добавлена Redis persistence +// PurgeQueueV1 — очищает все сообщения в очереди тенанта. +package gosqs + +import ( + "net/http" + "strings" + "time" + + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/persistence" + "shared-sqs/app/utils" + + log "github.com/sirupsen/logrus" +) + +func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) { + requestBody := models.NewPurgeQueueRequest() + ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) + if !ok { + log.Error("Invalid Request - PurgeQueueV1") + return utils.CreateErrorResponseV1("InvalidParameterValue", true) + } + + t := getTenantFromContext(req) + if t == nil { + return utils.CreateErrorResponseV1("InvalidClientTokenId", true) + } + + uriSegments := strings.Split(requestBody.QueueUrl, "/") + queueName := uriSegments[len(uriSegments)-1] + key := tenantQueueKey(t.AccessKey, queueName) + + models.SyncQueues.Lock() + defer models.SyncQueues.Unlock() + if _, ok := models.SyncQueues.Queues[key]; !ok { + log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID) + return utils.CreateErrorResponseV1("QueueNotFound", true) + } + + log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID) + models.SyncQueues.Queues[key].Messages = nil + models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time) + // Сохраняем пустую очередь в Redis пока держим Lock + persistence.SaveQueue(key, models.SyncQueues.Queues[key]) + + respStruct := models.PurgeQueueResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + return http.StatusOK, respStruct +} diff --git a/app/gosqs/purge_queue_test.go b/app/gosqs/purge_queue_test.go new file mode 100644 index 0000000..9677dab --- /dev/null +++ b/app/gosqs/purge_queue_test.go @@ -0,0 +1,126 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + "time" + + "shared-sqs/app/test" + + "shared-sqs/app/conf" + + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestPurgeQueueV1_success(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.PurgeQueueRequest) + *v = models.PurgeQueueRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + return true + } + + // Put a message on the queue + targetQueue := models.SyncQueues.Queues["unit-queue1"] + models.SyncQueues.Lock() + targetQueue.Messages = []models.SqsMessage{models.SqsMessage{}} + targetQueue.Duplicates = map[string]time.Time{ + "dedupe-id": time.Now(), + } + models.SyncQueues.Unlock() + + expectedResponse := models.PurgeQueueResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := PurgeQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) + + assert.Nil(t, targetQueue.Messages) + assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates) +} + +func TestPurgeQueueV1_success_no_messages_on_queue(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.PurgeQueueRequest) + *v = models.PurgeQueueRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + return true + } + + expectedResponse := models.PurgeQueueResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := PurgeQueueV1(r) + + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) + + targetQueue := models.SyncQueues.Queues["unit-queue1"] + assert.Nil(t, targetQueue.Messages) + assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates) +} + +func TestPurgeQueueV1_request_transformer_error(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := PurgeQueueV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestPurgeQueueV1_requested_queue_does_not_exist(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.PurgeQueueRequest) + *v = models.PurgeQueueRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "garbage"), + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := PurgeQueueV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} diff --git a/app/gosqs/queue_attributes.go b/app/gosqs/queue_attributes.go new file mode 100644 index 0000000..aa1f352 --- /dev/null +++ b/app/gosqs/queue_attributes.go @@ -0,0 +1,47 @@ +package gosqs + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + + "shared-sqs/app/models" +) + +// TODO - Support: +// - attr.MessageRetentionPeriod +// - attr.Policy +// - attr.RedriveAllowPolicy +func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error { + // FIXME - are there better places to put these bottom-limit validations? + if attr.DelaySeconds >= 0 { + q.DelaySeconds = attr.DelaySeconds.Int() + } + if attr.MaximumMessageSize >= 0 { + q.MaximumMessageSize = attr.MaximumMessageSize.Int() + } + // TODO - bottom limit should be the AWS limits + // The following 2 don't support zero values + if attr.MessageRetentionPeriod > 0 { + q.MessageRetentionPeriod = attr.MessageRetentionPeriod.Int() + } + if attr.ReceiveMessageWaitTimeSeconds > 0 { + q.ReceiveMessageWaitTimeSeconds = attr.ReceiveMessageWaitTimeSeconds.Int() + } + if attr.VisibilityTimeout >= 0 { + q.VisibilityTimeout = attr.VisibilityTimeout.Int() + } + if attr.RedrivePolicy != (models.RedrivePolicy{}) { + arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":") + queueName := arnArray[len(arnArray)-1] + deadLetterQueue, ok := models.SyncQueues.Queues[queueName] + if !ok { + log.Error("Invalid RedrivePolicy Attribute") + return fmt.Errorf("InvalidAttributeValue") + } + q.DeadLetterQueue = deadLetterQueue + q.MaxReceiveCount = attr.RedrivePolicy.MaxReceiveCount.Int() + } + return nil +} diff --git a/app/gosqs/queue_attributes_test.go b/app/gosqs/queue_attributes_test.go new file mode 100644 index 0000000..d79058e --- /dev/null +++ b/app/gosqs/queue_attributes_test.go @@ -0,0 +1,123 @@ +package gosqs + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "shared-sqs/app/models" +) + +func TestSetQueueAttributesV1_success_no_redrive_policy(t *testing.T) { + var emptyQueue *models.Queue + q := &models.Queue{} + attrs := models.QueueAttributes{ + DelaySeconds: 1, + MaximumMessageSize: 2, + MessageRetentionPeriod: 3, + ReceiveMessageWaitTimeSeconds: 4, + VisibilityTimeout: 5, + } + err := setQueueAttributesV1(q, attrs) + + assert.Nil(t, err) + assert.Equal(t, 1, q.DelaySeconds) + assert.Equal(t, 2, q.MaximumMessageSize) + assert.Equal(t, 3, q.MessageRetentionPeriod) + assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 5, q.VisibilityTimeout) + assert.Equal(t, emptyQueue, q.DeadLetterQueue) + assert.Equal(t, 0, q.MaxReceiveCount) +} + +func TestSetQueueAttributesV1_success_no_request_attributes(t *testing.T) { + var emptyQueue *models.Queue + q := &models.Queue{} + attrs := models.QueueAttributes{} + err := setQueueAttributesV1(q, attrs) + + assert.Nil(t, err) + assert.Equal(t, 0, q.DelaySeconds) + assert.Equal(t, 0, q.MaximumMessageSize) + assert.Equal(t, 0, q.MessageRetentionPeriod) + assert.Equal(t, 0, q.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 0, q.VisibilityTimeout) + assert.Equal(t, emptyQueue, q.DeadLetterQueue) + assert.Equal(t, 0, q.MaxReceiveCount) +} + +func TestSetQueueAttributesV1_success_can_set_0_values_where_applicable(t *testing.T) { + var emptyQueue *models.Queue + q := &models.Queue{ + DelaySeconds: 1, + MaximumMessageSize: 2, + MessageRetentionPeriod: 3, + ReceiveMessageWaitTimeSeconds: 4, + VisibilityTimeout: 5, + } + attrs := models.QueueAttributes{} + err := setQueueAttributesV1(q, attrs) + + assert.Nil(t, err) + assert.Equal(t, 0, q.DelaySeconds) + assert.Equal(t, 0, q.MaximumMessageSize) + assert.Equal(t, 3, q.MessageRetentionPeriod) + assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 0, q.VisibilityTimeout) + assert.Equal(t, emptyQueue, q.DeadLetterQueue) + assert.Equal(t, 0, q.MaxReceiveCount) +} + +func TestSetQueueAttributesV1_success_with_redrive_policy(t *testing.T) { + defer func() { + models.ResetApp() + }() + + existingQueueName := "existing-queue" + existingQueue := &models.Queue{Name: existingQueueName} + models.SyncQueues.Queues[existingQueueName] = existingQueue + + q := &models.Queue{} + attrs := models.QueueAttributes{ + DelaySeconds: 1, + MaximumMessageSize: 2, + MessageRetentionPeriod: 3, + ReceiveMessageWaitTimeSeconds: 4, + VisibilityTimeout: 5, + RedrivePolicy: models.RedrivePolicy{ + MaxReceiveCount: 10, + DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName), + }, + } + err := setQueueAttributesV1(q, attrs) + + assert.Nil(t, err) + assert.Equal(t, 1, q.DelaySeconds) + assert.Equal(t, 2, q.MaximumMessageSize) + assert.Equal(t, 3, q.MessageRetentionPeriod) + assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 5, q.VisibilityTimeout) + assert.Equal(t, existingQueue, q.DeadLetterQueue) + assert.Equal(t, 10, q.MaxReceiveCount) +} + +func TestSetQueueAttributesV1_error_redrive_policy_targets_missing_queue(t *testing.T) { + existingQueueName := "existing-queue" + + q := &models.Queue{} + attrs := models.QueueAttributes{ + DelaySeconds: 1, + MaximumMessageSize: 2, + MessageRetentionPeriod: 3, + ReceiveMessageWaitTimeSeconds: 4, + VisibilityTimeout: 5, + RedrivePolicy: models.RedrivePolicy{ + MaxReceiveCount: 10, + DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName), + }, + } + err := setQueueAttributesV1(q, attrs) + + assert.Error(t, err) +} diff --git a/app/gosqs/receive_message.go b/app/gosqs/receive_message.go new file mode 100644 index 0000000..792aa81 --- /dev/null +++ b/app/gosqs/receive_message.go @@ -0,0 +1,168 @@ +// Изменено: 2026-04-09 +// ReceiveMessageV1 — получает сообщения из очереди тенанта с поддержкой long polling. +// Ловушка #4: long polling держит соединение до 20 сек — не прерываем принудительно. +package gosqs + +import ( +"fmt" +"net/http" +"strings" +"time" + +"github.com/google/uuid" + +"shared-sqs/app/interfaces" +"shared-sqs/app/models" +"shared-sqs/app/utils" +"github.com/gorilla/mux" +log "github.com/sirupsen/logrus" +) + +func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) { +requestBody := models.NewReceiveMessageRequest() +ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) +if !ok { +log.Error("Invalid Request - ReceiveMessageV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} + +t := getTenantFromContext(req) +if t == nil { +return utils.CreateErrorResponseV1("InvalidClientTokenId", true) +} + +maxNumberOfMessages := requestBody.MaxNumberOfMessages +if maxNumberOfMessages == 0 { +maxNumberOfMessages = 1 +} + +queueName := "" +if requestBody.QueueUrl == "" { +vars := mux.Vars(req) +queueName = vars["queueName"] +} else { +uriSegments := strings.Split(requestBody.QueueUrl, "/") +queueName = uriSegments[len(uriSegments)-1] +} + +key := tenantQueueKey(t.AccessKey, queueName) + +if _, ok := models.SyncQueues.Queues[key]; !ok { +return utils.CreateErrorResponseV1("QueueNotFound", true) +} + +var messages []*models.ResultMessage +respStruct := models.ReceiveMessageResponse{} + +waitTimeSeconds := requestBody.WaitTimeSeconds +if waitTimeSeconds == 0 { +models.SyncQueues.RLock() +waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds +models.SyncQueues.RUnlock() +} + +// Long polling: ждём появления сообщения до waitTimeSeconds*10 итераций по 100ms +loops := waitTimeSeconds * 10 +for loops > 0 { +models.SyncQueues.RLock() +_, queueFound := models.SyncQueues.Queues[key] +if !queueFound { +models.SyncQueues.RUnlock() +return utils.CreateErrorResponseV1("QueueNotFound", true) +} +messageFound := len(models.SyncQueues.Queues[key].Messages)-numberOfHiddenMessagesInQueue(*models.SyncQueues.Queues[key]) != 0 +models.SyncQueues.RUnlock() +if !messageFound { +continueTimer := time.NewTimer(100 * time.Millisecond) +select { +case <-req.Context().Done(): +continueTimer.Stop() +return http.StatusOK, models.ReceiveMessageResponse{ +Xmlns: models.BaseXmlns, +Result: models.ReceiveMessageResult{}, +Metadata: models.BaseResponseMetadata, +} +case <-continueTimer.C: +continueTimer.Stop() +} +loops-- +} else { +break +} +} +log.Debugf("Getting Message from Queue:%s (tenant: %s)", queueName, t.ID) + +models.SyncQueues.Lock() +defer models.SyncQueues.Unlock() + +if len(models.SyncQueues.Queues[key].Messages) > 0 { +numMsg := 0 +messages = make([]*models.ResultMessage, 0) +for i := range models.SyncQueues.Queues[key].Messages { +if numMsg >= maxNumberOfMessages { +break +} + +if models.SyncQueues.Queues[key].Messages[i].ReceiptHandle != "" { +continue +} + +msg := &models.SyncQueues.Queues[key].Messages[i] +if !msg.IsReadyForReceipt() { +continue +} + +if models.SyncQueues.Queues[key].IsFIFO { +if models.SyncQueues.Queues[key].IsLocked(msg.GroupID) { +continue +} +models.SyncQueues.Queues[key].LockGroup(msg.GroupID) +} + +randomId := uuid.NewString() +msg.ReceiptHandle = msg.Uuid + "#" + randomId +msg.ReceiptTime = time.Now().UTC() + +if requestBody.VisibilityTimeout != 0 { +msg.VisibilityTimeout = time.Now().Add(time.Duration(requestBody.VisibilityTimeout) * time.Second) +} else { +msg.VisibilityTimeout = time.Now().Add(time.Duration(models.SyncQueues.Queues[key].VisibilityTimeout) * time.Second) +} + +messages = append(messages, buildResultMessage(msg)) +numMsg++ +} + +respStruct = models.ReceiveMessageResponse{ +"http://queue.amazonaws.com/doc/2012-11-05/", +models.ReceiveMessageResult{Messages: messages}, +models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"}, +} +} else { +log.Warning("No messages in Queue:", queueName) +respStruct = models.ReceiveMessageResponse{ +Xmlns: "http://queue.amazonaws.com/doc/2012-11-05/", +Result: models.ReceiveMessageResult{}, +Metadata: models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"}, +} +} + +return http.StatusOK, respStruct +} + +func buildResultMessage(m *models.SqsMessage) *models.ResultMessage { +return &models.ResultMessage{ +MessageId: m.Uuid, +Body: m.MessageBody, +ReceiptHandle: m.ReceiptHandle, +MD5OfBody: utils.GetMD5Hash(m.MessageBody), +MD5OfMessageAttributes: m.MD5OfMessageAttributes, +MessageAttributes: m.MessageAttributes, +Attributes: map[string]string{ +"ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)), +"SenderId": models.CurrentEnvironment.AccountID, +"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives+1), +"SentTimestamp": fmt.Sprintf("%d", time.Now().UTC().UnixNano()/int64(time.Millisecond)), +}, +} +} diff --git a/app/gosqs/receive_message_test.go b/app/gosqs/receive_message_test.go new file mode 100644 index 0000000..d8dd970 --- /dev/null +++ b/app/gosqs/receive_message_test.go @@ -0,0 +1,404 @@ +package gosqs + +import ( + "context" + "fmt" + "net/http" + "sync" + "testing" + "time" + + "shared-sqs/app/test" + + "shared-sqs/app/fixtures" + "shared-sqs/app/models" + "github.com/stretchr/testify/assert" +) + +// TODO Admiral-Piett - fix all these tests, we don't want to be waiting for 5 seconds plus like this. +func TestReceiveMessageV1_with_WaitTimeEnforced(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + q := &models.Queue{ + Name: "waiting-queue", + ReceiveMessageWaitTimeSeconds: 2, + //MaximumMessageSize: 262144, + } + models.SyncQueues.Queues["waiting-queue"] = q + + // receive message ensure delay + _, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: "http://localhost:4100/queue/waiting-queue", + }, true) + + start := time.Now() + status, response := ReceiveMessageV1(r) + elapsed := time.Since(start) + + assert.Equal(t, http.StatusOK, status) + if elapsed < 2*time.Second { + t.Fatalf("handler didn't wait ReceiveMessageWaitTimeSeconds %s", response) + } + + // mock sending a message + q.Messages = append(q.Messages, models.SqsMessage{MessageBody: "1"}) + + // receive message + _, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: "http://localhost:4100/queue/waiting-queue", + }, true) + start = time.Now() + status, resp := ReceiveMessageV1(r) + elapsed = time.Since(start) + + assert.Equal(t, http.StatusOK, status) + if elapsed > 1*time.Second { + t.Fatal("handler waited when message was available, expected not to wait") + } + + assert.Equal(t, "1", string(resp.GetResult().(models.ReceiveMessageResult).Messages[0].Body)) +} + +func TestReceiveMessageV1_CanceledByClient(t *testing.T) { + // create a queue + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + q := &models.Queue{ + Name: "cancel-queue", + ReceiveMessageWaitTimeSeconds: 20, + } + models.SyncQueues.Queues["cancel-queue"] = q + + var wg sync.WaitGroup + ctx, cancelReceive := context.WithCancel(context.Background()) + + wg.Add(1) + go func() { + defer wg.Done() + // receive message (that will be canceled) + _, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: "http://localhost:4100/queue/cancel-queue", + }, true) + r = r.WithContext(ctx) + + status, resp := ReceiveMessageV1(r) + assert.Equal(t, http.StatusOK, status) + + if len(resp.GetResult().(models.ReceiveMessageResult).Messages) != 0 { + t.Fatal("expecting this ReceiveMessage() to not pickup this message as it should canceled before the Send()") + } + }() + time.Sleep(100 * time.Millisecond) // let enought time for the Receive go to wait mode + cancelReceive() // cancel the first ReceiveMessage(), make sure it will not pickup the sent message below + time.Sleep(5 * time.Millisecond) + + // send a message + _, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{ + QueueUrl: "http://localhost:4100/queue/cancel-queue", + MessageBody: "12345", + }, true) + status, _ := SendMessageV1(r) + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK) + } + + // receive message + _, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: "http://localhost:4100/queue/cancel-queue", + }, true) + start := time.Now() + status, resp := ReceiveMessageV1(r) + assert.Equal(t, http.StatusOK, status) + elapsed := time.Since(start) + + result, ok := resp.GetResult().(models.ReceiveMessageResult) + if !ok { + t.Fatal("handler should return a message") + } + + if len(result.Messages) == 0 || string(result.Messages[0].Body) == "12345\n" { + t.Fatal("handler should return a message") + } + if elapsed > 1*time.Second { + t.Fatal("handler waited when message was available, expected not to wait") + } + + if timedout := waitTimeout(&wg, 2*time.Second); timedout { + t.Errorf("expected ReceiveMessage() in goroutine to exit quickly due to cancelReceive() called") + } +} + +func TestReceiveMessageV1_with_DelaySeconds(t *testing.T) { + // create a queue + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + q := &models.Queue{ + Name: "delay-seconds-queue", + DelaySeconds: 2, + } + models.SyncQueues.Queues["delay-seconds-queue"] = q + + // send a message + _, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{ + QueueUrl: "http://localhost:4100/queue/delay-seconds-queue", + MessageBody: "1", + }, true) + status, _ := SendMessageV1(r) + if status != http.StatusOK { + t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK) + } + + // receive message before delay is up + _, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/delay-seconds-queue"}, true) + status, _ = ReceiveMessageV1(r) + assert.Equal(t, http.StatusOK, status) + + // receive message with wait should return after delay + _, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: "http://localhost:4100/queue/delay-seconds-queue", + WaitTimeSeconds: 10, + }, true) + start := time.Now() + status, _ = ReceiveMessageV1(r) + elapsed := time.Since(start) + assert.Equal(t, http.StatusOK, status) + if elapsed < 1*time.Second { + t.Errorf("handler didn't wait at all") + } + if elapsed > 4*time.Second { + t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2") + } +} + +func TestReceiveMessageV1_with_MessageAttributes(t *testing.T) { + // create a queue + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + q := &models.Queue{Name: "waiting-queue"} + models.SyncQueues.Queues["waiting-queue"] = q + + // send a message + q.Messages = append(q.Messages, models.SqsMessage{ + MessageBody: "1", + MessageAttributes: map[string]models.MessageAttribute{ + "TestMessageAttrName": { + DataType: "String", + StringValue: "TestMessageAttrValue", + }, + }, + }) + + // receive message + _, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/waiting-queue"}, true) + status, resp := ReceiveMessageV1(r) + result := resp.GetResult().(models.ReceiveMessageResult) + + assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateFirstReceiveTimestamp"]) + assert.NotEmpty(t, result.Messages[0].Attributes["SenderId"]) + assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateReceiveCount"]) + assert.NotEmpty(t, result.Messages[0].Attributes["SentTimestamp"]) + + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, "1", string(result.Messages[0].Body)) + assert.Equal(t, 1, len(result.Messages[0].MessageAttributes)) + assert.Equal(t, "String", result.Messages[0].MessageAttributes["TestMessageAttrName"].DataType) + assert.Equal(t, "TestMessageAttrValue", result.Messages[0].MessageAttributes["TestMessageAttrName"].StringValue) +} + +func TestReceiveMessageV1_request_transformer_error(t *testing.T) { + // TODO +} + +func TestReceiveMessageV1_with_CustomVisibilityTimeout(t *testing.T) { + // create a queue + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + // Create a queue with a default visibility timeout of 30 seconds + q := &models.Queue{ + Name: "custom-visibility-queue", + VisibilityTimeout: 30, + } + models.SyncQueues.Queues["custom-visibility-queue"] = q + + // Add a message to the queue + q.Messages = append(q.Messages, models.SqsMessage{ + MessageBody: "test-message", + Uuid: "test-uuid", + }) + + // Test 1: Receive message with custom visibility timeout + customTimeout := 60 // 60 seconds + _, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: "http://localhost:4100/queue/custom-visibility-queue", + VisibilityTimeout: customTimeout, + }, true) + + status, resp := ReceiveMessageV1(r) + assert.Equal(t, http.StatusOK, status) + + result := resp.GetResult().(models.ReceiveMessageResult) + assert.Equal(t, 1, len(result.Messages)) + assert.Equal(t, "test-message", string(result.Messages[0].Body)) + + // Verify the message in the queue has the custom visibility timeout + // We can't directly check the exact time, but we can verify it's not using the queue's default + // by checking that the visibility timeout is greater than now + default timeout - 1 second + // and less than now + custom timeout + 1 second + now := time.Now() + defaultExpiry := now.Add(time.Duration(q.VisibilityTimeout) * time.Second) + customExpiry := now.Add(time.Duration(customTimeout) * time.Second) + + // The first message should have the custom visibility timeout + msgVisibilityTimeout := q.Messages[0].VisibilityTimeout + assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)), + "Message visibility timeout should be greater than default timeout") + assert.True(t, msgVisibilityTimeout.Before(customExpiry.Add(1*time.Second)), + "Message visibility timeout should be less than custom timeout + 1 second") + + // Test 2: Reset the queue and test with zero visibility timeout (should use queue default) + models.SyncQueues.Queues["custom-visibility-queue"] = &models.Queue{ + Name: "custom-visibility-queue", + VisibilityTimeout: 30, + } + q = models.SyncQueues.Queues["custom-visibility-queue"] + q.Messages = append(q.Messages, models.SqsMessage{ + MessageBody: "test-message-2", + Uuid: "test-uuid-2", + }) + + // Receive message with zero visibility timeout (should use queue default) + _, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: "http://localhost:4100/queue/custom-visibility-queue", + VisibilityTimeout: 0, // Zero should use queue default + }, true) + + status, resp = ReceiveMessageV1(r) + assert.Equal(t, http.StatusOK, status) + + // Verify the message in the queue has the default visibility timeout + now = time.Now() + defaultExpiry = now.Add(time.Duration(q.VisibilityTimeout) * time.Second) + + // The message should have the default visibility timeout + msgVisibilityTimeout = q.Messages[0].VisibilityTimeout + assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)), + "Message visibility timeout should be greater than default timeout - 1 second") + assert.True(t, msgVisibilityTimeout.Before(defaultExpiry.Add(1*time.Second)), + "Message visibility timeout should be less than default timeout + 1 second") +} + +func TestReceiveMessageV1_FIFOSecondMessageAvailableAfterDelete(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + }() + + queueName := "fifo-delay-queue" + queueURL := fmt.Sprintf("http://localhost:4100/queue/%s", queueName) + now := time.Now().Add(-1 * time.Minute) + + // create a queue with a visibility timeout of 10 seconds + q := &models.Queue{ + Name: queueName, + VisibilityTimeout: 10, + IsFIFO: true, + FIFOMessages: map[string]int{}, + FIFOSequenceNumbers: map[string]int{}, + Duplicates: map[string]time.Time{}, + Messages: []models.SqsMessage{ + { + MessageBody: "first", + Uuid: "first-uuid", + GroupID: "company#worker", + SentTime: now, + }, + { + MessageBody: "second", + Uuid: "second-uuid", + GroupID: "company#worker", + SentTime: now, + }, + }, + } + models.SyncQueues.Queues[queueName] = q + + // receive the first FIFO message only + _, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: queueURL, + MaxNumberOfMessages: 1, + }, true) + status, resp := ReceiveMessageV1(r) + assert.Equal(t, http.StatusOK, status) + result := resp.GetResult().(models.ReceiveMessageResult) + if len(result.Messages) != 1 { + t.Fatalf("expected to receive the first FIFO message, got %d", len(result.Messages)) + } + assert.Equal(t, "first", result.Messages[0].Body) + + firstReceipt := result.Messages[0].ReceiptHandle + + // verify the second FIFO message is blocked while the first is in flight + _, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: queueURL, + }, true) + status, resp = ReceiveMessageV1(r) + assert.Equal(t, http.StatusOK, status) + result = resp.GetResult().(models.ReceiveMessageResult) + if len(result.Messages) != 0 { + t.Fatalf("expected no FIFO message while the first is outstanding, got %d", len(result.Messages)) + } + + // delete the first FIFO message + _, deleteReq := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{ + QueueUrl: queueURL, + ReceiptHandle: firstReceipt, + }, true) + deleteStatus, _ := DeleteMessageV1(deleteReq) + assert.Equal(t, http.StatusOK, deleteStatus) + + // receive the second FIFO message and ensure it does not wait for full 10 second visibility timeout + _, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{ + QueueUrl: queueURL, + }, true) + start := time.Now() + status, resp = ReceiveMessageV1(r) + elapsed := time.Since(start) + assert.Equal(t, http.StatusOK, status) + result = resp.GetResult().(models.ReceiveMessageResult) + if len(result.Messages) != 1 { + t.Fatalf("expected second FIFO message to be available immediately, got %d", len(result.Messages)) + } + if elapsed > time.Second { + t.Fatalf("expected second FIFO message without waiting on visibility timeout, took %s", elapsed) + } + assert.Equal(t, "second", result.Messages[0].Body) + + // delete the second FIFO message + _, deleteReq = test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{ + QueueUrl: queueURL, + ReceiptHandle: result.Messages[0].ReceiptHandle, + }, true) + deleteStatus, _ = DeleteMessageV1(deleteReq) + assert.Equal(t, http.StatusOK, deleteStatus) + + if len(q.Messages) != 0 { + t.Fatalf("expected all FIFO messages to be deleted, remaining %d", len(q.Messages)) + } +} + +// TODO - other tests diff --git a/app/gosqs/send_message.go b/app/gosqs/send_message.go new file mode 100644 index 0000000..d0ea550 --- /dev/null +++ b/app/gosqs/send_message.go @@ -0,0 +1,111 @@ +// Изменено: 2026-04-10 — добавлена Redis persistence +// SendMessageV1 — добавляет сообщение в очередь тенанта. +// Ловушка #6: queueName извлекается как ПОСЛЕДНИЙ сегмент URL — при URL вида +// http://host/tenantID/queueName последний сегмент = queueName (правильно). +package gosqs + +import ( + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/persistence" + "shared-sqs/app/utils" + + log "github.com/sirupsen/logrus" + + "github.com/gorilla/mux" +) + +func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) { + requestBody := models.NewSendMessageRequest() + ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) + if !ok { + log.Error("Invalid Request - SendMessageV1") + return utils.CreateErrorResponseV1("InvalidParameterValue", true) + } + + t := getTenantFromContext(req) + if t == nil { + return utils.CreateErrorResponseV1("InvalidClientTokenId", true) + } + + messageBody := requestBody.MessageBody + messageGroupID := requestBody.MessageGroupId + messageDeduplicationID := requestBody.MessageDeduplicationId + + queueUrl := getQueueFromPath(requestBody.QueueUrl, req.URL.String()) + queueName := "" + if queueUrl == "" { + vars := mux.Vars(req) + queueName = vars["queueName"] + } else { + // Ловушка #6: берём последний сегмент — это queueName, не tenantID + uriSegments := strings.Split(queueUrl, "/") + queueName = uriSegments[len(uriSegments)-1] + } + + key := tenantQueueKey(t.AccessKey, queueName) + + if _, ok := models.SyncQueues.Queues[key]; !ok { + return utils.CreateErrorResponseV1("QueueNotFound", true) + } + + if models.SyncQueues.Queues[key].MaximumMessageSize > 0 && + len(messageBody) > models.SyncQueues.Queues[key].MaximumMessageSize { + return utils.CreateErrorResponseV1("MessageTooBig", true) + } + + delaySecs := models.SyncQueues.Queues[key].DelaySeconds + if requestBody.DelaySeconds != 0 { + delaySecs = requestBody.DelaySeconds + } + + log.Debugf("Putting Message in Queue: [%s] tenant: [%s]", queueName, t.ID) + msg := models.SqsMessage{MessageBody: messageBody} + if len(requestBody.MessageAttributes) > 0 { + msg.MessageAttributes = requestBody.MessageAttributes + msg.MD5OfMessageAttributes = utils.HashAttributes(requestBody.MessageAttributes) + } + msg.MD5OfMessageBody = utils.GetMD5Hash(messageBody) + msg.Uuid = uuid.NewString() + msg.GroupID = messageGroupID + msg.DeduplicationID = messageDeduplicationID + msg.SentTime = time.Now() + msg.DelaySecs = delaySecs + + models.SyncQueues.Lock() + fifoSeqNumber := "" + if models.SyncQueues.Queues[key].IsFIFO { + fifoSeqNumber = models.SyncQueues.Queues[key].NextSequenceNumber(messageGroupID) + } + + if !models.SyncQueues.Queues[key].IsDuplicate(messageDeduplicationID) { + models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) + } else { + log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName) + } + + models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID) + // Сохраняем очередь в Redis пока держим Lock + persistence.SaveQueue(key, models.SyncQueues.Queues[key]) + models.SyncQueues.Unlock() + log.Infof("%s: Queue: %s, Message: %s\n", time.Now().Format("2006-01-02 15:04:05"), queueName, msg.MessageBody) + + respStruct := models.SendMessageResponse{ + Xmlns: models.BaseXmlns, + Result: models.SendMessageResult{ + MD5OfMessageAttributes: msg.MD5OfMessageAttributes, + MD5OfMessageBody: msg.MD5OfMessageBody, + MessageId: msg.Uuid, + SequenceNumber: fifoSeqNumber, + }, + Metadata: models.BaseResponseMetadata, + } + + return http.StatusOK, respStruct +} diff --git a/app/gosqs/send_message_batch.go b/app/gosqs/send_message_batch.go new file mode 100644 index 0000000..e6a3348 --- /dev/null +++ b/app/gosqs/send_message_batch.go @@ -0,0 +1,109 @@ +// Изменено: 2026-04-09 +// SendMessageBatchV1 — пакетная отправка сообщений в очередь тенанта. +package gosqs + +import ( +"net/http" +"strings" +"time" + +"github.com/google/uuid" + +"shared-sqs/app/interfaces" +"shared-sqs/app/models" +"shared-sqs/app/utils" +"github.com/gorilla/mux" +log "github.com/sirupsen/logrus" +) + +func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody) { +requestBody := models.NewSendMessageBatchRequest() +ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) +if !ok { +log.Error("Invalid Request - SendMessageBatchV1") +return utils.CreateErrorResponseV1("InvalidParameterValue", true) +} + +t := getTenantFromContext(req) +if t == nil { +return utils.CreateErrorResponseV1("InvalidClientTokenId", true) +} + +queueUrl := requestBody.QueueUrl +queueName := "" +if queueUrl == "" { +vars := mux.Vars(req) +queueName = vars["queueName"] +} else { +uriSegments := strings.Split(queueUrl, "/") +queueName = uriSegments[len(uriSegments)-1] +} + +key := tenantQueueKey(t.AccessKey, queueName) + +if _, ok := models.SyncQueues.Queues[key]; !ok { +return utils.CreateErrorResponseV1("QueueNotFound", true) +} + +sendEntries := requestBody.Entries + +if len(sendEntries) == 0 { +return utils.CreateErrorResponseV1("EmptyBatchRequest", true) +} + +if len(sendEntries) > 10 { +return utils.CreateErrorResponseV1("TooManyEntriesInBatchRequest", true) +} +ids := map[string]struct{}{} +for _, v := range sendEntries { +if _, ok := ids[v.Id]; ok { +return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true) +} +ids[v.Id] = struct{}{} +} + +sentEntries := make([]models.SendMessageBatchResultEntry, 0) +log.Debugf("Batch sending to Queue: %s (tenant: %s)", queueName, t.ID) +for _, sendEntry := range sendEntries { +msg := models.SqsMessage{MessageBody: sendEntry.MessageBody} +if len(sendEntry.MessageAttributes) > 0 { +msg.MessageAttributes = sendEntry.MessageAttributes +msg.MD5OfMessageAttributes = utils.HashAttributes(sendEntry.MessageAttributes) +} +msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody) +msg.GroupID = sendEntry.MessageGroupId +msg.DeduplicationID = sendEntry.MessageDeduplicationId +msg.Uuid = uuid.NewString() +msg.SentTime = time.Now() + +models.SyncQueues.Lock() +fifoSeqNumber := "" +if models.SyncQueues.Queues[key].IsFIFO { +fifoSeqNumber = models.SyncQueues.Queues[key].NextSequenceNumber(sendEntry.MessageGroupId) +} +if !models.SyncQueues.Queues[key].IsDuplicate(sendEntry.MessageDeduplicationId) { +models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) +} else { +log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", sendEntry.MessageDeduplicationId, queueName) +} +models.SyncQueues.Queues[key].InitDuplicatation(sendEntry.MessageDeduplicationId) +models.SyncQueues.Unlock() + +sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{ +Id: sendEntry.Id, +MessageId: msg.Uuid, +MD5OfMessageBody: msg.MD5OfMessageBody, +MD5OfMessageAttributes: msg.MD5OfMessageAttributes, +SequenceNumber: fifoSeqNumber, +}) +log.Infof("%s: Queue: %s, Message: %s", time.Now().Format("2006-01-02 15:04:05"), queueName, msg.MessageBody) +} + +respStruct := models.SendMessageBatchResponse{ +Xmlns: models.BaseXmlns, +Result: models.SendMessageBatchResult{Entry: sentEntries}, +Metadata: models.BaseResponseMetadata, +} + +return http.StatusOK, respStruct +} diff --git a/app/gosqs/send_message_batch_test.go b/app/gosqs/send_message_batch_test.go new file mode 100644 index 0000000..45abe31 --- /dev/null +++ b/app/gosqs/send_message_batch_test.go @@ -0,0 +1,346 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + + "shared-sqs/app/conf" + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/test" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestSendMessageBatchV1_Success(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageBatchRequest{ + Entries: []models.SendMessageBatchRequestEntry{ + { + Id: "test-msg-with-non-attribute", + MessageBody: "test%20message%20body%201", + }, + { + Id: "test-msg-with-single-attirbute", + MessageBody: "test%20message%20body%202", + MessageAttributes: map[string]models.MessageAttribute{ + "my-attribute-name": { + DataType: "String", + StringValue: "my-attribute-string-value", + }, + }, + }, + { + Id: "test-msg-with-multi-attirbute", + MessageBody: "test%20message%20body%203", + MessageAttributes: map[string]models.MessageAttribute{ + "my-attribute-name-1": { + BinaryValue: "binary-value-1", + DataType: "Binary", + }, + "my-attribute-name-2": { + DataType: "String", + StringValue: "my-attribute-string-value-2", + }, + }, + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageBatchRequest) + *v = sendMessageRequest_success + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageBatchV1(r) + sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse) + + assert.Equal(t, http.StatusOK, status) + assert.True(t, ok) + + resultEntry := sendMessageBatchResponse.Result.Entry + assert.Equal(t, 3, len(resultEntry)) + assert.Contains(t, resultEntry[0].Id, "test-msg-with-non-attribute") + assert.Contains(t, resultEntry[1].Id, "test-msg-with-single-attirbute") + assert.Contains(t, resultEntry[2].Id, "test-msg-with-multi-attirbute") + assert.Empty(t, resultEntry[0].SequenceNumber) + assert.Empty(t, resultEntry[1].SequenceNumber) + assert.Empty(t, resultEntry[2].SequenceNumber) + +} + +func TestSendMessageBatchV1_Success_Fifo_Queue(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageBatchRequest{ + Entries: []models.SendMessageBatchRequestEntry{ + { + Id: "test_msg_001", + MessageBody: "test%20message%20body%201", + }, + { + Id: "test_msg_002", + MessageBody: "test%20message%20body%202", + }, + { + Id: "test_msg_003", + MessageBody: "test%20message%20body%203", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "fifo-queue-1"), + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageBatchRequest) + *v = sendMessageRequest_success + return true + } + + q := &models.Queue{ + Name: "fifo-queue-1", + MaximumMessageSize: 1024, + IsFIFO: true, + } + models.SyncQueues.Queues["fifo-queue-1"] = q + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageBatchV1(r) + sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse) + + assert.Equal(t, http.StatusOK, status) + assert.True(t, ok) + + resultEntry := sendMessageBatchResponse.Result.Entry + assert.Equal(t, 3, len(resultEntry)) + assert.Contains(t, resultEntry[0].Id, "test_msg_001") + assert.NotEmpty(t, resultEntry[0].SequenceNumber) + assert.Contains(t, resultEntry[1].Id, "test_msg_002") + assert.NotEmpty(t, resultEntry[1].SequenceNumber) + assert.Contains(t, resultEntry[2].Id, "test_msg_003") + assert.NotEmpty(t, resultEntry[2].SequenceNumber) +} + +func TestSendMessageBatchV1_Error_QueueNotFound(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_not_found_queue := models.SendMessageBatchRequest{ + Entries: []models.SendMessageBatchRequestEntry{ + { + Id: "test_msg_001", + MessageBody: "test%20message%20body%201", + }, + { + Id: "test_msg_002", + MessageBody: "test%20message%20body%202", + }, + { + Id: "test_msg_003", + MessageBody: "test%20message%20body%203", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue1"), + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageBatchRequest) + *v = sendMessageRequest_not_found_queue + return true + } + + expected := models.ErrorResult{ + Type: "Not Found", + Code: "AWS.SimpleQueueService.NonExistentQueue", + Message: "The specified queue does not exist for this wsdl version.", + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageBatchV1(r) + errorResult := response.GetResult().(models.ErrorResult) + + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, expected, errorResult) +} + +func TestSendMessageBatchV1_Error_NoEntry(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_no_entry := models.SendMessageBatchRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageBatchRequest) + *v = sendMessageRequest_no_entry + return true + } + + expected := models.ErrorResult{ + Type: "EmptyBatchRequest", + Code: "AWS.SimpleQueueService.EmptyBatchRequest", + Message: "The batch request doesn't contain any entries.", + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageBatchV1(r) + errorResult := response.GetResult().(models.ErrorResult) + + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, expected, errorResult) +} + +func TestSendMessageBatchV1_Error_IdNotDistinct(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_no_entry := models.SendMessageBatchRequest{ + Entries: []models.SendMessageBatchRequestEntry{ + { + Id: "test_msg_001", + MessageBody: "test%20message%20body%201", + }, + { + Id: "test_msg_001", + MessageBody: "test%20message%20body%202", + }, + { + Id: "test_msg_001", + MessageBody: "test%20message%20body%203", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageBatchRequest) + *v = sendMessageRequest_no_entry + return true + } + + expected := models.ErrorResult{ + Type: "BatchEntryIdsNotDistinct", + Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct", + Message: "Two or more batch entries in the request have the same Id.", + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageBatchV1(r) + errorResult := response.GetResult().(models.ErrorResult) + + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, expected, errorResult) +} + +func TestSendMessageBatchV1_Error_TooManyEntries(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageBatchRequest{ + Entries: []models.SendMessageBatchRequestEntry{ + { + Id: "test_msg_001", + MessageBody: "test%20message%20body%201", + }, + { + Id: "test_msg_002", + MessageBody: "test%20message%20body%202", + }, + { + Id: "test_msg_003", + MessageBody: "test%20message%20body%203", + }, + { + Id: "test_msg_004", + MessageBody: "test%20message%20body%204", + }, + { + Id: "test_msg_005", + MessageBody: "test%20message%20body%205", + }, + { + Id: "test_msg_006", + MessageBody: "test%20message%20body%206", + }, + { + Id: "test_msg_007", + MessageBody: "test%20message%20body%207", + }, + { + Id: "test_msg_008", + MessageBody: "test%20message%20body%208", + }, + { + Id: "test_msg_009", + MessageBody: "test%20message%20body%209", + }, + { + Id: "test_msg_010", + MessageBody: "test%20message%20body%210", + }, + { + Id: "test_msg_011", + MessageBody: "test%20message%20body%211", + }, + }, + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageBatchRequest) + *v = sendMessageRequest_success + return true + } + + expected := models.ErrorResult{ + Type: "TooManyEntriesInBatchRequest", + Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + Message: "Maximum number of entries per request are 10.", + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageBatchV1(r) + errorResult := response.GetResult().(models.ErrorResult) + + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, expected, errorResult) + +} + +func TestSendMessageBatchV1_Error_transformer(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := SendMessageBatchV1(r) + + assert.Equal(t, http.StatusBadRequest, code) + +} diff --git a/app/gosqs/send_message_test.go b/app/gosqs/send_message_test.go new file mode 100644 index 0000000..f0d70b2 --- /dev/null +++ b/app/gosqs/send_message_test.go @@ -0,0 +1,220 @@ +package gosqs + +import ( + "net/http" + "testing" + "time" + + "shared-sqs/app/test" + + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestSendMessageV1_Success(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageRequest{ + QueueUrl: "http://localhost:4200/new-queue-1", + MessageBody: "Test Message", + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageRequest) + *v = sendMessageRequest_success + return true + } + + q := &models.Queue{ + Name: "new-queue-1", + MaximumMessageSize: 1024, + } + models.SyncQueues.Queues["new-queue-1"] = q + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageV1(r) + + // Check the queue + assert.Equal(t, 1, len(q.Messages)) + msg := q.Messages[0] + assert.Equal(t, "Test Message", string(msg.MessageBody)) + + // Check the response + assert.Equal(t, http.StatusOK, status) + sendMessageResponse, ok := response.(models.SendMessageResponse) + assert.True(t, ok) + assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody) + // No FIFO Sequence + assert.Empty(t, sendMessageResponse.Result.SequenceNumber) +} + +func TestSendMessageV1_Success_FIFOQueue(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageRequest{ + QueueUrl: "http://localhost:4200/new-queue-1", + MessageBody: "Test Message", + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageRequest) + *v = sendMessageRequest_success + return true + } + + q := &models.Queue{ + Name: "new-queue-1", + MaximumMessageSize: 1024, + IsFIFO: true, + } + models.SyncQueues.Queues["new-queue-1"] = q + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageV1(r) + + // Check the queue + assert.Equal(t, 1, len(q.Messages)) + msg := q.Messages[0] + assert.Equal(t, "Test Message", string(msg.MessageBody)) + + // Check the response + assert.Equal(t, http.StatusOK, status) + sendMessageResponse, ok := response.(models.SendMessageResponse) + assert.True(t, ok) + assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody) + // Should have FIFO Sequence + assert.NotEmpty(t, sendMessageResponse.Result.SequenceNumber) +} + +func TestSendMessageV1_Success_Deduplication(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageRequest{ + QueueUrl: "http://localhost:4200/new-queue-1", + MessageBody: "Test Message", + MessageDeduplicationId: "1", + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageRequest) + *v = sendMessageRequest_success + return true + } + + q := &models.Queue{ + Name: "new-queue-1", + MaximumMessageSize: 1024, + IsFIFO: true, + EnableDuplicates: true, + Duplicates: make(map[string]time.Time), + } + models.SyncQueues.Queues["new-queue-1"] = q + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, _ := SendMessageV1(r) + + // Check the queue + assert.Equal(t, 1, len(q.Messages)) + // Check the response + assert.Equal(t, http.StatusOK, status) + + // Send the same message (have DeduplicationId) + status, _ = SendMessageV1(r) + // Response is "success" + assert.Equal(t, http.StatusOK, status) + // Only 1 message should be in the queue + assert.Equal(t, 1, len(q.Messages)) +} + +func TestSendMessageV1_request_transformer_error(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := SendMessageV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestSendMessageV1_MaximumMessageSize_MessageTooBig(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageRequest{ + QueueUrl: "http://localhost:4200/new-queue-1", + MessageBody: "Test Message", + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageRequest) + *v = sendMessageRequest_success + return true + } + + q := &models.Queue{ + Name: "new-queue-1", + MaximumMessageSize: 1, + } + models.SyncQueues.Queues["new-queue-1"] = q + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageV1(r) + + // Check the response + assert.Equal(t, http.StatusBadRequest, status) + errorResponse, ok := response.(models.ErrorResponse) + assert.True(t, ok) + assert.Equal(t, "MessageTooBig", errorResponse.Result.Type) +} + +func TestSendMessageV1_POST_QueueNonExistant(t *testing.T) { + models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + sendMessageRequest_success := models.SendMessageRequest{ + QueueUrl: "http://localhost:4200/new-queue-1", + MessageBody: "Test Message", + } + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SendMessageRequest) + *v = sendMessageRequest_success + return true + } + + // No test queue is added to app.SyncQueues + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + status, response := SendMessageV1(r) + + // Check the status code is what we expect. + assert.Equal(t, http.StatusBadRequest, status) + + // Check the response body is what we expect. + errorResponse, ok := response.(models.ErrorResponse) + assert.True(t, ok) + assert.Equal(t, "Not Found", errorResponse.Result.Type) +} diff --git a/app/gosqs/set_queue_attributes.go b/app/gosqs/set_queue_attributes.go new file mode 100644 index 0000000..fb6420f --- /dev/null +++ b/app/gosqs/set_queue_attributes.go @@ -0,0 +1,58 @@ +// Изменено: 2026-04-10 — добавлена Redis persistence +// SetQueueAttributesV1 — устанавливает атрибуты очереди тенанта. +// Ловушка #9: при RedrivePolicy парсим ARN DLQ и DLQ тоже должна принадлежать тому же тенанту. +package gosqs + +import ( + "net/http" + "strings" + + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/persistence" + "shared-sqs/app/utils" + + log "github.com/sirupsen/logrus" +) + +func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) { + requestBody := models.NewSetQueueAttributesRequest() + ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) + if !ok { + log.Error("Invalid Request - SetQueueAttributesV1") + return utils.CreateErrorResponseV1("InvalidParameterValue", true) + } + if requestBody.QueueUrl == "" { + log.Error("Missing QueueUrl - SetQueueAttributesV1") + return utils.CreateErrorResponseV1("InvalidParameterValue", true) + } + + t := getTenantFromContext(req) + if t == nil { + return utils.CreateErrorResponseV1("InvalidClientTokenId", true) + } + + uriSegments := strings.Split(requestBody.QueueUrl, "/") + queueName := uriSegments[len(uriSegments)-1] + key := tenantQueueKey(t.AccessKey, queueName) + + log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID) + models.SyncQueues.Lock() + defer models.SyncQueues.Unlock() + queue, ok := models.SyncQueues.Queues[key] + if !ok { + log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID) + return utils.CreateErrorResponseV1("QueueNotFound", true) + } + if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil { + return utils.CreateErrorResponseV1(err.Error(), true) + } + // Сохраняем атрибуты в Redis пока держим Lock (через defer) + persistence.SaveQueue(key, queue) + + respStruct := models.SetQueueAttributesResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + return http.StatusOK, respStruct +} diff --git a/app/gosqs/set_queue_attributes_test.go b/app/gosqs/set_queue_attributes_test.go new file mode 100644 index 0000000..19c730b --- /dev/null +++ b/app/gosqs/set_queue_attributes_test.go @@ -0,0 +1,169 @@ +package gosqs + +import ( + "fmt" + "net/http" + "testing" + + "shared-sqs/app/test" + + "shared-sqs/app/conf" + "shared-sqs/app/fixtures" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "shared-sqs/app/utils" + "github.com/stretchr/testify/assert" +) + +func TestSetQueueAttributesV1_success_multiple_attributes(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SetQueueAttributesRequest) + *v = fixtures.SetQueueAttributesRequest + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := SetQueueAttributesV1(r) + + expectedResponse := models.SetQueueAttributesResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) + + actualQueue := models.SyncQueues.Queues["unit-queue1"] + assert.Equal(t, 5, actualQueue.VisibilityTimeout) + assert.Equal(t, 4, actualQueue.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 1, actualQueue.DelaySeconds) + assert.Equal(t, 2, actualQueue.MaximumMessageSize) + assert.Equal(t, 3, actualQueue.MessageRetentionPeriod) +} + +func TestSetQueueAttributesV1_success_single_attribute(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SetQueueAttributesRequest) + *v = models.SetQueueAttributesRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + Attributes: models.QueueAttributes{ + VisibilityTimeout: 5, + }, + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, response := SetQueueAttributesV1(r) + + expectedResponse := models.SetQueueAttributesResponse{ + Xmlns: models.BaseXmlns, + Metadata: models.BaseResponseMetadata, + } + assert.Equal(t, http.StatusOK, code) + assert.Equal(t, expectedResponse, response) + + actualQueue := models.SyncQueues.Queues["unit-queue1"] + assert.Equal(t, 5, actualQueue.VisibilityTimeout) + assert.Equal(t, 0, actualQueue.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, 0, actualQueue.DelaySeconds) + assert.Equal(t, 0, actualQueue.MaximumMessageSize) + assert.Equal(t, 345600, actualQueue.MessageRetentionPeriod) +} + +func TestSetQueueAttributesV1_invalid_request_body(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + return false + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := SetQueueAttributesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestSetQueueAttributesV1_missing_queue_url(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SetQueueAttributesRequest) + *v = models.SetQueueAttributesRequest{ + Attributes: models.QueueAttributes{}, + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := SetQueueAttributesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestSetQueueAttributesV1_missing_expected_queue(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SetQueueAttributesRequest) + *v = models.SetQueueAttributesRequest{ + QueueUrl: "garbage", + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := SetQueueAttributesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} + +func TestSetQueueAttributesV1_invalid_redrive_queue(t *testing.T) { + conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests") + defer func() { + models.ResetApp() + utils.REQUEST_TRANSFORMER = utils.TransformRequest + }() + + utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + v := resultingStruct.(*models.SetQueueAttributesRequest) + *v = models.SetQueueAttributesRequest{ + QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"), + Attributes: models.QueueAttributes{ + RedrivePolicy: models.RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"), + }, + }, + } + return true + } + + _, r := test.GenerateRequestInfo("POST", "/", nil, true) + code, _ := SetQueueAttributesV1(r) + + assert.Equal(t, http.StatusBadRequest, code) +} diff --git a/app/gosqs/tenant_helpers.go b/app/gosqs/tenant_helpers.go new file mode 100644 index 0000000..f1f6337 --- /dev/null +++ b/app/gosqs/tenant_helpers.go @@ -0,0 +1,56 @@ +// Изменено: 2026-04-09 +// Helper-функции для tenant-scoped операций с очередями. +// Используются всеми SQS handlers для изоляции очередей между тенантами. +package gosqs + +import ( +"net/http" + +"shared-sqs/app/auth" +"shared-sqs/app/models" +"shared-sqs/app/tenant" +) + +// tenantQueueKey — внутренний ключ очереди в SyncQueues в формате "{accessKey}:{queueName}". +// Такой формат гарантирует изоляцию: тенант видит только очереди с префиксом своего accessKey. +func tenantQueueKey(tenantAccessKey, queueName string) string { +return tenantAccessKey + ":" + queueName +} + +// getTenantFromContext — извлекает тенанта из request context. +// Возвращает nil если тенант не найден (не должно быть — auth middleware должен это поймать раньше). +func getTenantFromContext(r *http.Request) *tenant.Tenant { +t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant) +return t +} + +// tenantQueueURL — формирует URL очереди для тенанта. +// Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive. +func tenantQueueURL(t *tenant.Tenant, queueName string) string { +host := models.CurrentEnvironment.Host +port := models.CurrentEnvironment.Port +region := models.CurrentEnvironment.Region +if region != "" { +return "http://" + region + "." + host + ":" + port + "/" + t.ID + "/" + queueName +} +return "http://" + host + ":" + port + "/" + t.ID + "/" + queueName +} + +// tenantQueueARN — формирует ARN очереди для тенанта. +func tenantQueueARN(t *tenant.Tenant, queueName string) string { +return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName +} + +// countTenantQueues — считает количество очередей тенанта в SyncQueues. +// Используется для проверки лимита MaxQueues. +// Вызывать под SyncQueues.RLock(). +func countTenantQueues(tenantAccessKey string) int { +prefix := tenantAccessKey + ":" +count := 0 +for key := range models.SyncQueues.Queues { +if len(key) > len(prefix) && key[:len(prefix)] == prefix { +count++ +} +} +return count +} diff --git a/app/interfaces/interfaces.go b/app/interfaces/interfaces.go new file mode 100644 index 0000000..ba4dbd1 --- /dev/null +++ b/app/interfaces/interfaces.go @@ -0,0 +1,28 @@ +package interfaces + +import ( + "net/url" + + "shared-sqs/app/models" +) + +type AbstractRequestBody interface { + SetAttributesFromForm(values url.Values) +} + +type AbstractResponseBody interface { + GetResult() interface{} + GetRequestId() string +} + +type AbstractErrorResponse interface { + Response() models.ErrorResult + StatusCode() int +} + +type AbstractPublishEntry interface { + GetMessage() string + GetMessageAttributes() map[string]models.MessageAttribute + GetMessageStructure() string + GetSubject() string +} diff --git a/app/models/configuration.go b/app/models/configuration.go new file mode 100644 index 0000000..4053973 --- /dev/null +++ b/app/models/configuration.go @@ -0,0 +1,37 @@ +package models + +/*** config ***/ +type EnvQueue struct { +Name string +ReceiveMessageWaitTimeSeconds int +RedrivePolicy string +MaximumMessageSize int +VisibilityTimeout int +MessageRetentionPeriod int +} + +type EnvQueueAttributes struct { +VisibilityTimeout int +ReceiveMessageWaitTimeSeconds int +MaximumMessageSize int +MessageRetentionPeriod int // seconds +} + +type Environment struct { +Host string +Port string +SqsPort string +Region string +AccountID string +LogToFile bool +LogFile string +EnableDuplicates bool +Queues []EnvQueue +QueueAttributeDefaults EnvQueueAttributes +RandomLatency RandomLatency +} + +type RandomLatency struct { +Min int +Max int +} diff --git a/app/models/constants.go b/app/models/constants.go new file mode 100644 index 0000000..1ed7cbf --- /dev/null +++ b/app/models/constants.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" +) + +var BaseXmlns = "http://queue.amazonaws.com/doc/2012-11-05/" +var BaseResponseMetadata = ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"} + +var DeduplicationPeriod = 5 * time.Minute + +var AvailableQueueAttributes = map[string]bool{ + "DelaySeconds": true, + "MaximumMessageSize": true, + "MessageRetentionPeriod": true, + "Policy": true, + "ReceiveMessageWaitTimeSeconds": true, + "VisibilityTimeout": true, + "RedrivePolicy": true, + "RedriveAllowPolicy": true, + "ApproximateNumberOfMessages": true, + "ApproximateNumberOfMessagesDelayed": true, + "ApproximateNumberOfMessagesNotVisible": true, + "CreatedTimestamp": true, + "LastModifiedTimestamp": true, + "QueueArn": true, +} + +const ( + ProtocolSQS Protocol = "sqs" + ProtocolHTTP Protocol = "http" + ProtocolHTTPS Protocol = "https" + ProtocolDefault Protocol = "default" +) + +const ( + MessageStructureJSON MessageStructure = "json" +) diff --git a/app/models/conversions.go b/app/models/conversions.go new file mode 100644 index 0000000..35c7530 --- /dev/null +++ b/app/models/conversions.go @@ -0,0 +1,36 @@ +package models + +import ( + "encoding/json" + "strconv" +) + +// StringToInt this is a custom type that will allow our request bodies to support either a string OR an int. +// It has its own UnmarshalJSON method to handle both types automatically and it can return an `int` +// from the `Int` method. +type StringToInt int + +func (s *StringToInt) UnmarshalJSON(data []byte) error { + var i int + err := json.Unmarshal(data, &i) + if err == nil { + *s = StringToInt(i) + return nil + } + + var str string + err = json.Unmarshal(data, &str) + if err != nil { + return err + } + tmp, err := strconv.Atoi(str) + if err != nil { + return err + } + *s = StringToInt(tmp) + return nil +} + +func (s *StringToInt) Int() int { + return int(*s) +} diff --git a/app/models/conversions_test.go b/app/models/conversions_test.go new file mode 100644 index 0000000..3814dbd --- /dev/null +++ b/app/models/conversions_test.go @@ -0,0 +1,75 @@ +package models + +import ( + "encoding/json" + "testing" + + "shared-sqs/app/test" + "github.com/stretchr/testify/assert" +) + +type StringToIntStruct struct { + Field1 StringToInt `json:"Field1"` + Field2 StringToInt `json:"Field2"` +} + +func TestStringToInt_unmarshalJSON_int(t *testing.T) { + body := struct { + Field1 int `json:"Field1"` + Field2 int `json:"Field2"` + }{ + Field1: 1, + Field2: 2, + } + _, r := test.GenerateRequestInfo("POST", "/", body, true) + + result := &StringToIntStruct{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(result) + + assert.Nil(t, err) + assert.Equal(t, StringToInt(1), result.Field1) + assert.Equal(t, StringToInt(2), result.Field2) +} + +func TestStringToInt_unmarshalJSON_string(t *testing.T) { + body := struct { + Field1 string `json:"Field1"` + Field2 string `json:"Field2"` + }{ + Field1: "1", + Field2: "2", + } + _, r := test.GenerateRequestInfo("POST", "/", body, true) + + result := &StringToIntStruct{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(result) + + assert.Nil(t, err) + assert.Equal(t, StringToInt(1), result.Field1) + assert.Equal(t, StringToInt(2), result.Field2) +} + +func TestStringToInt_unmarshalJSON_invalid_type_returns_error(t *testing.T) { + body := struct { + Field1 bool `json:"Field1"` + Field2 bool `json:"Field2"` + }{ + Field1: true, + Field2: false, + } + _, r := test.GenerateRequestInfo("POST", "/", body, true) + + result := &StringToIntStruct{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(result) + + assert.Error(t, err) +} + +func TestStringToInt_int_returns_int_type(t *testing.T) { + s := StringToInt(1) + + assert.Equal(t, int(1), s.Int()) +} diff --git a/app/models/errors.go b/app/models/errors.go new file mode 100644 index 0000000..a0792c3 --- /dev/null +++ b/app/models/errors.go @@ -0,0 +1,71 @@ +package models + +import "net/http" + +func init() { + SqsErrors = map[string]SqsErrorType{ + "QueueNotFound": {HttpError: http.StatusBadRequest, Type: "Not Found", Code: "AWS.SimpleQueueService.NonExistentQueue", Message: "The specified queue does not exist for this wsdl version."}, + "QueueExists": {HttpError: http.StatusBadRequest, Type: "Duplicate", Code: "AWS.SimpleQueueService.QueueExists", Message: "The specified queue already exists."}, + "MessageDoesNotExist": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "AWS.SimpleQueueService.QueueExists", Message: "The specified queue does not contain the message specified."}, + "GeneralError": {HttpError: http.StatusBadRequest, Type: "GeneralError", Code: "AWS.SimpleQueueService.GeneralError", Message: "General Error."}, + "TooManyEntriesInBatchRequest": {HttpError: http.StatusBadRequest, Type: "TooManyEntriesInBatchRequest", Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", Message: "Maximum number of entries per request are 10."}, + "BatchEntryIdsNotDistinct": {HttpError: http.StatusBadRequest, Type: "BatchEntryIdsNotDistinct", Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct", Message: "Two or more batch entries in the request have the same Id."}, + "EmptyBatchRequest": {HttpError: http.StatusBadRequest, Type: "EmptyBatchRequest", Code: "AWS.SimpleQueueService.EmptyBatchRequest", Message: "The batch request doesn't contain any entries."}, + "InvalidVisibilityTimeout": {HttpError: http.StatusBadRequest, Type: "ValidationError", Code: "AWS.SimpleQueueService.ValidationError", Message: "The visibility timeout is incorrect"}, + "MessageNotInFlight": {HttpError: http.StatusBadRequest, Type: "MessageNotInFlight", Code: "AWS.SimpleQueueService.MessageNotInFlight", Message: "The message referred to isn't in flight."}, + "MessageTooBig": {HttpError: http.StatusBadRequest, Type: "MessageTooBig", Code: "InvalidParameterValue", Message: "The message size exceeds the limit."}, + "InvalidParameterValue": {HttpError: http.StatusBadRequest, Type: "InvalidParameterValue", Code: "AWS.SimpleQueueService.InvalidParameterValue", Message: "An invalid or out-of-range value was supplied for the input parameter."}, + "InvalidAttributeValue": {HttpError: http.StatusBadRequest, Type: "InvalidAttributeValue", Code: "AWS.SimpleQueueService.InvalidAttributeValue", Message: "Invalid Value for the parameter RedrivePolicy."}, + // InvalidClientTokenId — невалидные credentials тенанта + "InvalidClientTokenId": {HttpError: http.StatusForbidden, Type: "InvalidClientTokenId", Code: "AWS.SimpleQueueService.InvalidClientTokenId", Message: "The security token included in the request is invalid."}, + // ValidationError — ошибка валидации параметров (например, VisibilityTimeout вне диапазона) + "ValidationError": {HttpError: http.StatusBadRequest, Type: "ValidationError", Code: "AWS.SimpleQueueService.ValidationError", Message: "The input fails to satisfy the constraints specified by an AWS service."}, + // LimitExceeded — превышен лимит очередей тенанта (max_queues) + "LimitExceeded": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."}, + } + SnsErrors = map[string]SnsErrorType{ + "InvalidParameterValue": {HttpError: http.StatusBadRequest, Type: "InvalidParameterValue", Code: "AWS.SimpleNotificationService.InvalidParameterValue", Message: "An invalid or out-of-range value was supplied for the input parameter."}, + "TopicNotFound": {HttpError: http.StatusBadRequest, Type: "Not Found", Code: "AWS.SimpleNotificationService.NonExistentTopic", Message: "The specified topic does not exist for this wsdl version."}, + "SubscriptionNotFound": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "AWS.SimpleNotificationService.NonExistentSubscription", Message: "The specified subscription does not exist for this wsdl version."}, + "TopicExists": {HttpError: http.StatusBadRequest, Type: "Duplicate", Code: "AWS.SimpleNotificationService.TopicAlreadyExists", Message: "The specified topic already exists."}, + "ValidationError": {HttpError: http.StatusBadRequest, Type: "InvalidParameter", Code: "AWS.SimpleNotificationService.ValidationError", Message: "The input fails to satisfy the constraints specified by an AWS service."}, + "BatchEntryIdsNotDistinct": {HttpError: http.StatusBadRequest, Type: "BatchEntryIdsNotDistinct", Code: "AWS.SimpleNotificationService.BatchEntryIdsNotDistinct", Message: "Two or more batch entries in the request have the same Id."}, + "EmptyBatchRequest": {HttpError: http.StatusBadRequest, Type: "EmptyBatchRequest", Code: "AWS.SimpleNotificationService.EmptyBatchRequest", Message: "The batch request doesn't contain any entries."}, + "TooManyEntriesInBatchRequest": {HttpError: http.StatusBadRequest, Type: "TooManyEntriesInBatchRequest", Code: "AWS.SimpleNotificationService.TooManyEntriesInBatchRequest", Message: "Maximum number of entries per request are 10."}, + "MalformedInput": {HttpError: http.StatusBadRequest, Type: "Sender", Code: "AWS.SimpleNotificationService.MalformedInput", Message: "Invalid Base64 encoding"}, + } +} + +type SqsErrorType struct { + HttpError int + Type string + Code string + Message string +} + +func (s SqsErrorType) StatusCode() int { + return s.HttpError +} + +func (s SqsErrorType) Response() ErrorResult { + return ErrorResult{Type: s.Type, Code: s.Code, Message: s.Message} +} + +var SqsErrors map[string]SqsErrorType + +type SnsErrorType struct { + HttpError int + Type string + Code string + Message string +} + +func (s SnsErrorType) StatusCode() int { + return s.HttpError +} + +func (s SnsErrorType) Response() ErrorResult { + return ErrorResult{Type: s.Type, Code: s.Code, Message: s.Message} +} + +var SnsErrors map[string]SnsErrorType diff --git a/app/models/globals.go b/app/models/globals.go new file mode 100644 index 0000000..8d0c050 --- /dev/null +++ b/app/models/globals.go @@ -0,0 +1,23 @@ +package models + +import ( + "sync" +) + +// CurrentEnvironment should get overwritten when the app starts up and loads the config. For the +// sake of generating "partial" apps piece-meal during test automation we'll slap these placeholder +// values in here so the resource URLs aren't wonky like `http://://new-queue`. +var CurrentEnvironment = Environment{ + Host: "host", + Port: "port", + Region: "region", + AccountID: "accountID", +} + +var LogMessages bool +var LogFile string + +var SyncQueues = struct { + sync.RWMutex + Queues map[string]*Queue +}{Queues: make(map[string]*Queue)} diff --git a/app/models/helpers.go b/app/models/helpers.go new file mode 100644 index 0000000..cf5c91d --- /dev/null +++ b/app/models/helpers.go @@ -0,0 +1,48 @@ +package models + +import ( + "errors" + "fmt" + "math/rand" + "time" +) + +// ---- Unit Tests ---- +func ResetApp() { + CurrentEnvironment = Environment{} + ResetResources() +} + +func ResetResources() { + SyncQueues.Lock() + SyncQueues.Queues = make(map[string]*Queue) + SyncQueues.Unlock() +} + +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +func generateRandomLatency() (time.Duration, error) { + min := CurrentEnvironment.RandomLatency.Min + max := CurrentEnvironment.RandomLatency.Max + if min == 0 && max == 0 { + return time.Duration(0), nil + } + var randomLatencyValue int + if max == min { + randomLatencyValue = max + } else { + randomLatencyValue = rand.Intn(max-min) + min + } + randomDuration, err := time.ParseDuration(fmt.Sprintf("%dms", randomLatencyValue)) + if err != nil { + return time.Duration(0), errors.New(fmt.Sprintf("Error parsing random latency value: %dms", randomLatencyValue)) + } + return randomDuration, nil +} diff --git a/app/models/models.go b/app/models/models.go new file mode 100644 index 0000000..637a4fd --- /dev/null +++ b/app/models/models.go @@ -0,0 +1,116 @@ +package models + +import ( + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +type MessageStructure string +type Protocol string + +type MessageAttribute struct { + BinaryListValues []string `json:"BinaryListValues,omitempty" xml:"BinaryListValues,omitempty"` // currently unsupported by AWS + BinaryValue string `json:"BinaryValue,omitempty" xml:"BinaryValue,omitempty"` + DataType string `json:"DataType,omitempty" xml:"DataType,omitempty"` + StringListValues []string `json:"StringListValues,omitempty" xml:"StringListValues,omitempty"` // currently unsupported by AWS + StringValue string `json:"StringValue,omitempty" xml:"StringValue,omitempty"` +} + + +type SqsMessage struct { + MessageBody string + Uuid string + MD5OfMessageAttributes string + MD5OfMessageBody string + ReceiptHandle string + ReceiptTime time.Time + VisibilityTimeout time.Time + NumberOfReceives int + Retry int + MessageAttributes map[string]MessageAttribute + GroupID string + DeduplicationID string + SentTime time.Time + DelaySecs int +} + +func (m *SqsMessage) IsReadyForReceipt() bool { + randomLatency, err := generateRandomLatency() + if err != nil { + log.Error(err) + return true + } + showAt := m.SentTime.Add(randomLatency).Add(time.Duration(m.DelaySecs) * time.Second) + return showAt.Before(time.Now()) +} + +type Queue struct { + Name string + URL string + Arn string + VisibilityTimeout int // seconds + ReceiveMessageWaitTimeSeconds int + DelaySeconds int + MaximumMessageSize int + MessageRetentionPeriod int // seconds // TODO - not used in the code yet + Messages []SqsMessage + DeadLetterQueue *Queue + MaxReceiveCount int + IsFIFO bool + FIFOMessages map[string]int + FIFOSequenceNumbers map[string]int + EnableDuplicates bool + Duplicates map[string]time.Time +} + +func (q *Queue) NextSequenceNumber(groupId string) string { + if _, ok := q.FIFOSequenceNumbers[groupId]; !ok { + q.FIFOSequenceNumbers = map[string]int{ + groupId: 0, + } + } + + q.FIFOSequenceNumbers[groupId]++ + return strconv.Itoa(q.FIFOSequenceNumbers[groupId]) +} + +func (q *Queue) IsLocked(groupId string) bool { + _, ok := q.FIFOMessages[groupId] + return ok +} + +func (q *Queue) LockGroup(groupId string) { + if _, ok := q.FIFOMessages[groupId]; !ok { + q.FIFOMessages = map[string]int{ + groupId: 0, + } + } +} + +func (q *Queue) UnlockGroup(groupId string) { + if _, ok := q.FIFOMessages[groupId]; ok { + delete(q.FIFOMessages, groupId) + } +} + +func (q *Queue) IsDuplicate(deduplicationId string) bool { + if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { + return false + } + + _, ok := q.Duplicates[deduplicationId] + + return ok +} + +func (q *Queue) InitDuplicatation(deduplicationId string) { + if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { + return + } + + if _, ok := q.Duplicates[deduplicationId]; !ok { + q.Duplicates[deduplicationId] = time.Now() + } +} diff --git a/app/models/models_test.go b/app/models/models_test.go new file mode 100644 index 0000000..4954b07 --- /dev/null +++ b/app/models/models_test.go @@ -0,0 +1,73 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestFilterPolicy_IsSatisfiedBy(t *testing.T) { + var tests = []struct { + filterPolicy *FilterPolicy + messageAttributes map[string]MessageAttribute + expected bool + }{ + { + &FilterPolicy{"foo": {"bar"}}, + map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "bar"}}, + true, + }, + { + &FilterPolicy{"foo": {"bar", "xyz"}}, + map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "xyz"}}, + true, + }, + { + &FilterPolicy{"foo": {"bar", "xyz"}, "abc": {"def"}}, + map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "xyz"}, + "abc": {DataType: "String", StringValue: "def"}}, + true, + }, + { + &FilterPolicy{"foo": {"bar"}}, + map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "baz"}}, + false, + }, + { + &FilterPolicy{"foo": {"bar"}}, + map[string]MessageAttribute{}, + false, + }, + { + &FilterPolicy{"foo": {"bar"}, "abc": {"def"}}, + map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "bar"}}, + false, + }, + { + &FilterPolicy{"foo": {"bar"}}, + map[string]MessageAttribute{"foo": {DataType: "Binary", BinaryValue: "bar"}}, + false, + }, + } + + for i, tt := range tests { + actual := tt.filterPolicy.IsSatisfiedBy(tt.messageAttributes) + if tt.filterPolicy.IsSatisfiedBy(tt.messageAttributes) != tt.expected { + t.Errorf("#%d FilterPolicy: expected %t, actual %t", i, tt.expected, actual) + } + } + +} + +func TestMessage_IsReadyForReceipt(t *testing.T) { + CurrentEnvironment.RandomLatency.Min = 100 + CurrentEnvironment.RandomLatency.Max = 100 + msg := SqsMessage{ + SentTime: time.Now(), + } + assert.False(t, msg.IsReadyForReceipt()) + duration, _ := time.ParseDuration("105ms") + time.Sleep(duration) + assert.True(t, msg.IsReadyForReceipt()) +} diff --git a/app/models/requests.go b/app/models/requests.go new file mode 100644 index 0000000..606bf21 --- /dev/null +++ b/app/models/requests.go @@ -0,0 +1,532 @@ +package models + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + + log "github.com/sirupsen/logrus" +) + +type CreateQueueRequest struct { + QueueName string `json:"QueueName" schema:"QueueName"` + Attributes QueueAttributes `json:"Attributes" schema:"Attribute"` + Tags map[string]string `json:"Tags" schema:"Tags"` + Version string `json:"Version" schema:"Version"` +} + +// TODO - is there an easier way to do this? Similar to the StringToInt type? +func (r *CreateQueueRequest) SetAttributesFromForm(values url.Values) { + for i := 1; true; i++ { + nameKey := fmt.Sprintf("Attribute.%d.Name", i) + attrName := values.Get(nameKey) + if attrName == "" { + break + } + + valueKey := fmt.Sprintf("Attribute.%d.Value", i) + attrValue := values.Get(valueKey) + if attrValue == "" { + continue + } + switch attrName { + case "DelaySeconds": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.DelaySeconds = StringToInt(tmp) + case "MaximumMessageSize": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.MaximumMessageSize = StringToInt(tmp) + case "MessageRetentionPeriod": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.MessageRetentionPeriod = StringToInt(tmp) + case "Policy": + var tmp map[string]interface{} + err := json.Unmarshal([]byte(attrValue), &tmp) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.Policy = tmp + case "ReceiveMessageWaitTimeSeconds": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.ReceiveMessageWaitTimeSeconds = StringToInt(tmp) + case "VisibilityTimeout": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.VisibilityTimeout = StringToInt(tmp) + case "RedrivePolicy": + tmp := RedrivePolicy{} + var decodedPolicy struct { + MaxReceiveCount interface{} `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` + } + err := json.Unmarshal([]byte(attrValue), &decodedPolicy) + if err != nil || decodedPolicy.DeadLetterTargetArn == "" { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + // Support both int and string types (historic processing), set a default of 10 if not provided. + // Go will default into float64 for interface{} types when parsing numbers + receiveCount, ok := decodedPolicy.MaxReceiveCount.(float64) + if !ok { + receiveCount = 10 + t, ok := decodedPolicy.MaxReceiveCount.(string) + if ok { + r, err := strconv.ParseFloat(t, 64) + if err == nil { + receiveCount = r + } else { + log.Debugf("Failed to parse form attribute (maxReceiveCount) - %s: %s", attrName, attrValue) + } + } else { + log.Debugf("Failed to parse form attribute (maxReceiveCount) - %s: %s", attrName, attrValue) + } + } + tmp.MaxReceiveCount = StringToInt(receiveCount) + tmp.DeadLetterTargetArn = decodedPolicy.DeadLetterTargetArn + r.Attributes.RedrivePolicy = tmp + case "RedriveAllowPolicy": + var tmp map[string]interface{} + err := json.Unmarshal([]byte(attrValue), &tmp) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.RedriveAllowPolicy = tmp + } + } + return +} + +func NewListQueuesRequest() *ListQueueRequest { + return &ListQueueRequest{} +} + +type ListQueueRequest struct { + MaxResults int `json:"MaxResults" schema:"MaxResults"` + NextToken string `json:"NextToken" schema:"NextToken"` + QueueNamePrefix string `json:"QueueNamePrefix" schema:"QueueNamePrefix"` +} + +func (r *ListQueueRequest) SetAttributesFromForm(values url.Values) { + maxResults, err := strconv.Atoi(values.Get("MaxResults")) + if err == nil { + r.MaxResults = maxResults + } + r.NextToken = values.Get("NextToken") + r.QueueNamePrefix = values.Get("QueueNamePrefix") +} + +func NewGetQueueAttributesRequest() *GetQueueAttributesRequest { + return &GetQueueAttributesRequest{} +} + +type GetQueueAttributesRequest struct { + QueueUrl string `json:"QueueUrl"` + AttributeNames []string `json:"AttributeNames"` +} + +func (r *GetQueueAttributesRequest) SetAttributesFromForm(values url.Values) { + r.QueueUrl = values.Get("QueueUrl") + for i := 1; true; i++ { + attrKey := fmt.Sprintf("AttributeName.%d", i) + attrValue := values.Get(attrKey) + if attrValue == "" { + break + } + r.AttributeNames = append(r.AttributeNames, attrValue) + } +} + +/*** Send Message Request */ +func NewSendMessageRequest() *SendMessageRequest { + return &SendMessageRequest{ + MessageAttributes: make(map[string]MessageAttribute), + MessageSystemAttributes: make(map[string]MessageAttribute), + } +} + +type SendMessageRequest struct { + DelaySeconds int `json:"DelaySeconds" schema:"DelaySeconds"` + // MessageAttributes is custom attributes that users can add on the message as they like. + // Please see: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html#SQS-SendMessage-request-MessageAttributes + MessageAttributes map[string]MessageAttribute `json:"MessageAttributes" schema:"MessageAttributes"` + MessageBody string `json:"MessageBody" schema:"MessageBody"` + MessageDeduplicationId string `json:"MessageDeduplicationId" schema:"MessageDeduplicationId"` + MessageGroupId string `json:"MessageGroupId" schema:"MessageGroupId"` + // MessageSystemAttributes is custom attributes for AWS services. + // Please see: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html#SQS-SendMessage-request-MessageSystemAttributes + // On AWS, the only supported attribute is "AWSTraceHeader" that is for AWS X-Ray. + // Goaws does not contains X-Ray emulation, so currently MessageSystemAttributes is unsupported. + // TODO: Replace with a struct with known attributes "AWSTraceHeader". + MessageSystemAttributes map[string]MessageAttribute `json:"MessageSystemAttributes" schema:"MessageSystemAttributes"` + QueueUrl string `json:"QueueUrl" schema:"QueueUrl"` +} + +func parseMessageAttributes(values url.Values, keyPrefix string) map[string]MessageAttribute { + result := map[string]MessageAttribute{} + + for i := 1; true; i++ { + nameKey := fmt.Sprintf("%s.%d.Name", keyPrefix, i) + name := values.Get(nameKey) + if name == "" { + break + } + + dataTypeKey := fmt.Sprintf("%s.%d.Value.DataType", keyPrefix, i) + dataType := values.Get(dataTypeKey) + if dataType == "" { + log.Warnf("DataType of message attribute %s is missing, MD5 checksum will most probably be wrong!\n", name) + continue + } + + stringValue := values.Get(fmt.Sprintf("%s.%d.Value.StringValue", keyPrefix, i)) + binaryValue := values.Get(fmt.Sprintf("%s.%d.Value.BinaryValue", keyPrefix, i)) + + result[name] = MessageAttribute{ + DataType: dataType, + StringValue: stringValue, + BinaryValue: binaryValue, + } + } + + if len(result) > 0 { + return result + } + return nil +} + +func (r *SendMessageRequest) SetAttributesFromForm(values url.Values) { + r.MessageAttributes = parseMessageAttributes(values, "MessageAttribute") +} + +func NewSendMessageBatchRequest() *SendMessageBatchRequest { + return &SendMessageBatchRequest{} +} + +type SendMessageBatchRequest struct { + Entries []SendMessageBatchRequestEntry + QueueUrl string +} + +func (r *SendMessageBatchRequest) SetAttributesFromForm(values url.Values) { + // Парсим записи по AWS Query Protocol: SendMessageBatchRequestEntry.N.Id (1-based) + // Gorilla/schema с дефолтными тегами ищет Entries.0.Id, что не соответствует AWS SQS API. + for i := 1; ; i++ { + id := values.Get(fmt.Sprintf("SendMessageBatchRequestEntry.%d.Id", i)) + if id == "" { + break + } + entry := SendMessageBatchRequestEntry{ + Id: id, + MessageBody: values.Get(fmt.Sprintf("SendMessageBatchRequestEntry.%d.MessageBody", i)), + MessageDeduplicationId: values.Get(fmt.Sprintf("SendMessageBatchRequestEntry.%d.MessageDeduplicationId", i)), + MessageGroupId: values.Get(fmt.Sprintf("SendMessageBatchRequestEntry.%d.MessageGroupId", i)), + } + ds := values.Get(fmt.Sprintf("SendMessageBatchRequestEntry.%d.DelaySeconds", i)) + if ds != "" { + entry.DelaySeconds, _ = strconv.Atoi(ds) + } + entry.MessageAttributes = parseMessageAttributes(values, fmt.Sprintf("SendMessageBatchRequestEntry.%d.MessageAttribute", i)) + r.Entries = append(r.Entries, entry) + } +} + +type SendMessageBatchRequestEntry struct { + Id string `json:"Id" schema:"Id"` + MessageBody string `json:"MessageBody" schema:"MessageBody"` + DelaySeconds int `json:"DelaySeconds" schema:"DelaySeconds"` // NOTE: not implemented + MessageAttributes map[string]MessageAttribute `json:"MessageAttributes" schema:"MessageAttributes"` + MessageDeduplicationId string `json:"MessageDeduplicationId" schema:"MessageDeduplicationId"` + MessageGroupId string `json:"MessageGroupId" schema:"MessageGroupId"` + MessageSystemAttributes map[string]MessageAttribute `json:"MessageSystemAttributes" schema:"MessageSystemAttributes"` // NOTE: not implemented +} + +// Get Queue Url Request +func NewGetQueueUrlRequest() *GetQueueUrlRequest { + return &GetQueueUrlRequest{} +} + +type GetQueueUrlRequest struct { + QueueName string `json:"QueueName"` + QueueOwnerAWSAccountId string `json:"QueueOwnerAWSAccountId"` // NOTE: not implemented +} + +func (r *GetQueueUrlRequest) SetAttributesFromForm(values url.Values) {} + +func NewSetQueueAttributesRequest() *SetQueueAttributesRequest { + return &SetQueueAttributesRequest{} +} + +type SetQueueAttributesRequest struct { + QueueUrl string `json:"QueueUrl"` + Attributes QueueAttributes `json:"Attributes"` +} + +func (r *SetQueueAttributesRequest) SetAttributesFromForm(values url.Values) { + r.QueueUrl = values.Get("QueueUrl") + // TODO - could we share with CreateQueueRequest? + for i := 1; true; i++ { + nameKey := fmt.Sprintf("Attribute.%d.Name", i) + attrName := values.Get(nameKey) + if attrName == "" { + break + } + + valueKey := fmt.Sprintf("Attribute.%d.Value", i) + attrValue := values.Get(valueKey) + if attrValue == "" { + continue + } + switch attrName { + case "DelaySeconds": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.DelaySeconds = StringToInt(tmp) + case "MaximumMessageSize": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.MaximumMessageSize = StringToInt(tmp) + case "MessageRetentionPeriod": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.MessageRetentionPeriod = StringToInt(tmp) + case "Policy": + var tmp map[string]interface{} + err := json.Unmarshal([]byte(attrValue), &tmp) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.Policy = tmp + case "ReceiveMessageWaitTimeSeconds": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.ReceiveMessageWaitTimeSeconds = StringToInt(tmp) + case "VisibilityTimeout": + tmp, err := strconv.Atoi(attrValue) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.VisibilityTimeout = StringToInt(tmp) + case "RedrivePolicy": + tmp := RedrivePolicy{} + var decodedPolicy struct { + MaxReceiveCount interface{} `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` + } + err := json.Unmarshal([]byte(attrValue), &decodedPolicy) + if err != nil || decodedPolicy.DeadLetterTargetArn == "" { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + // Support both int and string types (historic processing), set a default of 10 if not provided. + // Go will default into float64 for interface{} types when parsing numbers + receiveCount, ok := decodedPolicy.MaxReceiveCount.(float64) + if !ok { + receiveCount = 10 + t, ok := decodedPolicy.MaxReceiveCount.(string) + if ok { + r, err := strconv.ParseFloat(t, 64) + if err == nil { + receiveCount = r + } else { + log.Debugf("Failed to parse form attribute (maxReceiveCount) - %s: %s", attrName, attrValue) + } + } else { + log.Debugf("Failed to parse form attribute (maxReceiveCount) - %s: %s", attrName, attrValue) + } + } + tmp.MaxReceiveCount = StringToInt(receiveCount) + tmp.DeadLetterTargetArn = decodedPolicy.DeadLetterTargetArn + r.Attributes.RedrivePolicy = tmp + case "RedriveAllowPolicy": + var tmp map[string]interface{} + err := json.Unmarshal([]byte(attrValue), &tmp) + if err != nil { + log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue) + continue + } + r.Attributes.RedriveAllowPolicy = tmp + } + } + return +} + +// TODO - there are FIFO attributes and things too +// QueueAttributes - SQS QueueAttributes Available in create/set attributes requests. +// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_CreateQueue.html#SQS-CreateQueue-request-attributes +type QueueAttributes struct { + DelaySeconds StringToInt `json:"DelaySeconds"` + MaximumMessageSize StringToInt `json:"MaximumMessageSize"` + MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented + Policy map[string]interface{} `json:"Policy"` // NOTE: not implemented + ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"` + VisibilityTimeout StringToInt `json:"VisibilityTimeout"` + // Dead Letter Queues Only + RedrivePolicy RedrivePolicy `json:"RedrivePolicy"` + RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented +} + +type RedrivePolicy struct { + MaxReceiveCount StringToInt `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` +} + +// UnmarshalJSON this will convert a JSON string of a Redrive Policy sub-doc (escaped characters and all) or +// a regular json document into the appropriate resulting struct. +func (r *RedrivePolicy) UnmarshalJSON(data []byte) error { + type basicRequest RedrivePolicy + + err := json.Unmarshal(data, (*basicRequest)(r)) + if err == nil { + return nil + } + + tmp, _ := strconv.Unquote(string(data)) + err = json.Unmarshal([]byte(tmp), (*basicRequest)(r)) + if err != nil { + return err + } + return nil +} + +func NewReceiveMessageRequest() *ReceiveMessageRequest { + return &ReceiveMessageRequest{} +} + +type ReceiveMessageRequest struct { + QueueUrl string `json:"QueueUrl" schema:"QueueUrl"` + AttributeNames []string `json:"AttributeNames" schema:"AttributeNames"` + MessageSystemAttributeNames []string `json:"MessageSystemAttributeNames" schema:"MessageSystemAttributeNames"` + MessageAttributeNames []string `json:"MessageAttributeNames" schema:"MessageAttributeNames"` + MaxNumberOfMessages int `json:"MaxNumberOfMessages" schema:"MaxNumberOfMessages"` + VisibilityTimeout int `json:"VisibilityTimeout" schema:"VisibilityTimeout"` + WaitTimeSeconds int `json:"WaitTimeSeconds" schema:"WaitTimeSeconds"` + ReceiveRequestAttemptId string `json:"ReceiveRequestAttemptId" schema:"ReceiveRequestAttemptId"` +} + +func (r *ReceiveMessageRequest) SetAttributesFromForm(values url.Values) {} + +func NewCreateQueueRequest() *CreateQueueRequest { + return &CreateQueueRequest{ + Attributes: QueueAttributes{ + DelaySeconds: 0, + MaximumMessageSize: StringToInt(CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize), + MessageRetentionPeriod: StringToInt(CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod), + ReceiveMessageWaitTimeSeconds: StringToInt(CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds), + VisibilityTimeout: StringToInt(CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout), + }, + } +} + +func NewChangeMessageVisibilityRequest() *ChangeMessageVisibilityRequest { + return &ChangeMessageVisibilityRequest{} +} + +type ChangeMessageVisibilityRequest struct { + QueueUrl string `json:"QueueUrl" schema:"QueueUrl"` + ReceiptHandle string `json:"ReceiptHandle" schema:"ReceiptHandle"` + VisibilityTimeout int `json:"VisibilityTimeout" schema:"VisibilityTimeout"` +} + +func (r *ChangeMessageVisibilityRequest) SetAttributesFromForm(values url.Values) {} + +func NewDeleteMessageRequest() *DeleteMessageRequest { + return &DeleteMessageRequest{} +} + +type DeleteMessageRequest struct { + QueueUrl string `json:"QueueUrl" schema:"QueueUrl"` + ReceiptHandle string `json:"ReceiptHandle" schema:"ReceiptHandle"` +} + +func (r *DeleteMessageRequest) SetAttributesFromForm(values url.Values) {} + +func NewPurgeQueueRequest() *PurgeQueueRequest { + return &PurgeQueueRequest{} +} + +type PurgeQueueRequest struct { + QueueUrl string `json:"QueueUrl" schema:"QueueUrl"` +} + +func (r *PurgeQueueRequest) SetAttributesFromForm(values url.Values) {} + +func NewDeleteQueueRequest() *DeleteQueueRequest { + return &DeleteQueueRequest{} +} + +type DeleteQueueRequest struct { + QueueUrl string `json:"QueueUrl" schema:"QueueUrl"` +} + +func (r *DeleteQueueRequest) SetAttributesFromForm(values url.Values) {} + +type DeleteMessageBatchRequestEntry struct { + Id string `json:"Id" schema:"Id"` + ReceiptHandle string `json:"ReceiptHandle" schema:"ReceiptHandle"` +} + +type DeleteMessageBatchRequest struct { + Entries []DeleteMessageBatchRequestEntry `json:"Entries"` + QueueUrl string `json:"QueueUrl" schema:"QueueUrl"` +} + +func NewDeleteMessageBatchRequest() *DeleteMessageBatchRequest { + return &DeleteMessageBatchRequest{} +} + +func (r *DeleteMessageBatchRequest) SetAttributesFromForm(values url.Values) { + entries := []DeleteMessageBatchRequestEntry{} + for i := 1; true; i++ { + msgIdKey := fmt.Sprintf("DeleteMessageBatchRequestEntry.%d.Id", i) + receiptHandleKey := fmt.Sprintf("DeleteMessageBatchRequestEntry.%d.ReceiptHandle", i) + + msgId := values.Get(msgIdKey) + receiptHandle := values.Get(receiptHandleKey) + if msgId == "" || receiptHandle == "" { + break + } + entries = append(entries, DeleteMessageBatchRequestEntry{ + Id: msgId, + ReceiptHandle: receiptHandle, + }) + } + if len(entries) > 0 { + r.Entries = entries + } +} diff --git a/app/models/requests_test.go b/app/models/requests_test.go new file mode 100644 index 0000000..9da5849 --- /dev/null +++ b/app/models/requests_test.go @@ -0,0 +1,739 @@ +package models + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/url" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewCreateQueueRequest(t *testing.T) { + CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize = 262144 + CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod = 345600 + CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds = 10 + CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30 + defer func() { + ResetApp() + }() + + expectedCreateQueueRequest := &CreateQueueRequest{ + Attributes: QueueAttributes{ + DelaySeconds: 0, + MaximumMessageSize: 262144, + MessageRetentionPeriod: 345600, + ReceiveMessageWaitTimeSeconds: 10, + VisibilityTimeout: 30, + }, + } + + result := NewCreateQueueRequest() + + assert.Equal(t, expectedCreateQueueRequest, result) +} + +func TestCreateQueueRequest_SetAttributesFromForm_success(t *testing.T) { + expectedRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "new-queue") + form.Add("Version", "2012-11-05") + form.Add("Attribute.1.Name", "DelaySeconds") + form.Add("Attribute.1.Value", "1") + form.Add("Attribute.2.Name", "MaximumMessageSize") + form.Add("Attribute.2.Value", "2") + form.Add("Attribute.3.Name", "MessageRetentionPeriod") + form.Add("Attribute.3.Value", "3") + form.Add("Attribute.4.Name", "Policy") + form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\"}") + form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds") + form.Add("Attribute.5.Value", "4") + form.Add("Attribute.6.Name", "VisibilityTimeout") + form.Add("Attribute.6.Value", "5") + form.Add("Attribute.7.Name", "RedrivePolicy") + form.Add("Attribute.7.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + form.Add("Attribute.8.Name", "RedriveAllowPolicy") + form.Add("Attribute.8.Value", "{\"i-am\":\"the-redrive-allow-policy\"}") + + cqr := &CreateQueueRequest{ + Attributes: QueueAttributes{ + DelaySeconds: 1, + MaximumMessageSize: 262144, + MessageRetentionPeriod: 345600, + ReceiveMessageWaitTimeSeconds: 10, + VisibilityTimeout: 30, + }, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds) + assert.Equal(t, StringToInt(2), cqr.Attributes.MaximumMessageSize) + assert.Equal(t, StringToInt(3), cqr.Attributes.MessageRetentionPeriod) + assert.Equal(t, map[string]interface{}{"i-am": "the-policy"}, cqr.Attributes.Policy) + assert.Equal(t, StringToInt(4), cqr.Attributes.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, StringToInt(5), cqr.Attributes.VisibilityTimeout) + assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy) + assert.Equal(t, map[string]interface{}{"i-am": "the-redrive-allow-policy"}, cqr.Attributes.RedriveAllowPolicy) +} + +func TestCreateQueueRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_int(t *testing.T) { + expectedRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Attribute.1.Name", "RedrivePolicy") + form.Add("Attribute.1.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + + cqr := &CreateQueueRequest{ + Attributes: QueueAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy) +} + +func TestCreateQueueRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_string(t *testing.T) { + expectedRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Attribute.1.Name", "RedrivePolicy") + form.Add("Attribute.1.Value", "{\"maxReceiveCount\": \"100\", \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + + cqr := &CreateQueueRequest{ + Attributes: QueueAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy) +} + +func TestCreateQueueRequest_SetAttributesFromForm_success_default_unparsable_redrive_recieve_count(t *testing.T) { + defaultRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 10, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Attribute.1.Name", "RedrivePolicy") + form.Add("Attribute.1.Value", "{\"maxReceiveCount\": null, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + + cqr := &CreateQueueRequest{ + Attributes: QueueAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, defaultRedrivePolicy, cqr.Attributes.RedrivePolicy) +} + +func TestCreateQueueRequest_SetAttributesFromForm_success_skips_invalid_values(t *testing.T) { + form := url.Values{} + form.Add("Attribute.1.Name", "DelaySeconds") + form.Add("Attribute.1.Value", "garbage") + form.Add("Attribute.2.Name", "MaximumMessageSize") + form.Add("Attribute.2.Value", "garbage") + form.Add("Attribute.3.Name", "MessageRetentionPeriod") + form.Add("Attribute.3.Value", "garbage") + form.Add("Attribute.4.Name", "Policy") + form.Add("Attribute.4.Value", "garbage") + form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds") + form.Add("Attribute.5.Value", "garbage") + form.Add("Attribute.6.Name", "VisibilityTimeout") + form.Add("Attribute.6.Value", "garbage") + form.Add("Attribute.7.Name", "RedrivePolicy") + form.Add("Attribute.7.Value", "garbage") + form.Add("Attribute.8.Name", "RedriveAllowPolicy") + form.Add("Attribute.8.Value", "garbage") + + cqr := &CreateQueueRequest{ + Attributes: QueueAttributes{ + DelaySeconds: 1, + MaximumMessageSize: 262144, + MessageRetentionPeriod: 345600, + ReceiveMessageWaitTimeSeconds: 10, + VisibilityTimeout: 30, + }, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds) + assert.Equal(t, StringToInt(262144), cqr.Attributes.MaximumMessageSize) + assert.Equal(t, StringToInt(345600), cqr.Attributes.MessageRetentionPeriod) + assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.Policy) + assert.Equal(t, StringToInt(10), cqr.Attributes.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, StringToInt(30), cqr.Attributes.VisibilityTimeout) + assert.Equal(t, RedrivePolicy{}, cqr.Attributes.RedrivePolicy) + assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.RedriveAllowPolicy) +} + +func TestRedrivePolicy_UnmarshalJSON_handles_nested_json(t *testing.T) { + request := struct { + MaxReceiveCount int `json:"maxReceiveCount"` + DeadLetterTargetArn string `json:"deadLetterTargetArn"` + }{ + MaxReceiveCount: 100, + DeadLetterTargetArn: "arn:redrive-queue", + } + b, _ := json.Marshal(request) + var r = RedrivePolicy{} + err := r.UnmarshalJSON(b) + + assert.Nil(t, err) + assert.Equal(t, StringToInt(100), r.MaxReceiveCount) + assert.Equal(t, fmt.Sprintf("%s:%s", "arn", "redrive-queue"), r.DeadLetterTargetArn) +} + +func TestRedrivePolicy_UnmarshalJSON_handles_escaped_string(t *testing.T) { + request := `{"maxReceiveCount":"100","deadLetterTargetArn":"arn:redrive-queue"}` + b, _ := json.Marshal(request) + var r = RedrivePolicy{} + err := r.UnmarshalJSON(b) + + assert.Nil(t, err) + assert.Equal(t, StringToInt(100), r.MaxReceiveCount) + assert.Equal(t, fmt.Sprintf("%s:%s", "arn", "redrive-queue"), r.DeadLetterTargetArn) +} + +func TestRedrivePolicy_UnmarshalJSON_invalid_json_request_returns_error(t *testing.T) { + request := fmt.Sprintf(`{\"maxReceiveCount\":\"100\",\"deadLetterTargetArn\":\"arn:redrive-queue\"}`) + var r = RedrivePolicy{} + err := r.UnmarshalJSON([]byte(request)) + + assert.Error(t, err) + assert.Equal(t, StringToInt(0), r.MaxReceiveCount) + assert.Equal(t, "", r.DeadLetterTargetArn) +} + +func TestRedrivePolicy_UnmarshalJSON_invalid_type_returns_error(t *testing.T) { + request := `{"maxReceiveCount":true,"deadLetterTargetArn":"arn:redrive-queue"}` + b, _ := json.Marshal(request) + var r = RedrivePolicy{} + err := r.UnmarshalJSON(b) + + assert.Error(t, err) + assert.Equal(t, StringToInt(0), r.MaxReceiveCount) + assert.Equal(t, "", r.DeadLetterTargetArn) +} + +func TestNewListQueuesRequest_SetAttributesFromForm(t *testing.T) { + form := url.Values{} + form.Add("MaxResults", "1") + form.Add("NextToken", "next-token") + form.Add("QueueNamePrefix", "queue-name-prefix") + + lqr := &ListQueueRequest{} + lqr.SetAttributesFromForm(form) + + assert.Equal(t, 1, lqr.MaxResults) + assert.Equal(t, "next-token", lqr.NextToken) + assert.Equal(t, "queue-name-prefix", lqr.QueueNamePrefix) +} + +func TestListQueuesRequest_SetAttributesFromForm_invalid_max_results(t *testing.T) { + form := url.Values{} + form.Add("MaxResults", "1.0") + form.Add("NextToken", "next-token") + form.Add("QueueNamePrefix", "queue-name-prefix") + + lqr := &ListQueueRequest{} + lqr.SetAttributesFromForm(form) + + assert.Equal(t, 0, lqr.MaxResults) + assert.Equal(t, "next-token", lqr.NextToken) + assert.Equal(t, "queue-name-prefix", lqr.QueueNamePrefix) +} + +func TestGetQueueAttributesRequest_SetAttributesFromForm(t *testing.T) { + form := url.Values{} + form.Add("QueueUrl", "queue-url") + form.Add("AttributeName.1", "attribute-1") + form.Add("AttributeName.2", "attribute-2") + + lqr := &GetQueueAttributesRequest{} + lqr.SetAttributesFromForm(form) + + assert.Equal(t, "queue-url", lqr.QueueUrl) + assert.Equal(t, 2, len(lqr.AttributeNames)) + assert.Contains(t, lqr.AttributeNames, "attribute-1") + assert.Contains(t, lqr.AttributeNames, "attribute-2") +} + +func TestGetQueueAttributesRequest_SetAttributesFromForm_skips_invalid_key_sequence(t *testing.T) { + form := url.Values{} + form.Add("QueueUrl", "queue-url") + form.Add("AttributeName.1", "attribute-1") + form.Add("AttributeName.3", "attribute-3") + + lqr := &GetQueueAttributesRequest{} + lqr.SetAttributesFromForm(form) + + assert.Equal(t, "queue-url", lqr.QueueUrl) + assert.Equal(t, 1, len(lqr.AttributeNames)) + assert.Contains(t, lqr.AttributeNames, "attribute-1") +} + +func TestSendMessageRequest_SetAttributesFromForm_success(t *testing.T) { + form := url.Values{} + form.Add("MessageAttribute.1.Name", "Attr1") + form.Add("MessageAttribute.1.Value.DataType", "String") + form.Add("MessageAttribute.1.Value.StringValue", "Value1") + form.Add("MessageAttribute.2.Name", "Attr2") + form.Add("MessageAttribute.2.Value.DataType", "Binary") + form.Add("MessageAttribute.2.Value.BinaryValue", "VmFsdWUy") + form.Add("MessageAttribute.3.Name", "") + form.Add("MessageAttribute.3.Value.DataType", "String") + form.Add("MessageAttribute.3.Value.StringValue", "Value") + form.Add("MessageAttribute.4.Name", "Attr4") + form.Add("MessageAttribute.4.Value.DataType", "") + form.Add("MessageAttribute.4.Value.StringValue", "Value4") + + r := &SendMessageRequest{ + MessageAttributes: make(map[string]MessageAttribute), + MessageSystemAttributes: make(map[string]MessageAttribute), + } + r.SetAttributesFromForm(form) + + assert.Equal(t, 2, len(r.MessageAttributes)) + + assert.NotNil(t, r.MessageAttributes["Attr1"]) + attr1 := r.MessageAttributes["Attr1"] + assert.Equal(t, "String", attr1.DataType) + assert.Equal(t, "Value1", attr1.StringValue) + assert.Empty(t, attr1.BinaryValue) + + assert.NotNil(t, r.MessageAttributes["Attr2"]) + attr2 := r.MessageAttributes["Attr2"] + assert.Equal(t, "Binary", attr2.DataType) + assert.Empty(t, attr2.StringValue) + assert.Equal(t, "VmFsdWUy", attr2.BinaryValue) +} + +func TestSetQueueAttributesRequest_SetAttributesFromForm_success(t *testing.T) { + expectedRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "new-queue") + form.Add("Version", "2012-11-05") + form.Add("Attribute.1.Name", "DelaySeconds") + form.Add("Attribute.1.Value", "1") + form.Add("Attribute.2.Name", "MaximumMessageSize") + form.Add("Attribute.2.Value", "2") + form.Add("Attribute.3.Name", "MessageRetentionPeriod") + form.Add("Attribute.3.Value", "3") + form.Add("Attribute.4.Name", "Policy") + form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\"}") + form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds") + form.Add("Attribute.5.Value", "4") + form.Add("Attribute.6.Name", "VisibilityTimeout") + form.Add("Attribute.6.Value", "5") + form.Add("Attribute.7.Name", "RedrivePolicy") + form.Add("Attribute.7.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + form.Add("Attribute.8.Name", "RedriveAllowPolicy") + form.Add("Attribute.8.Value", "{\"i-am\":\"the-redrive-allow-policy\"}") + + cqr := &SetQueueAttributesRequest{ + Attributes: QueueAttributes{ + DelaySeconds: 1, + MaximumMessageSize: 262144, + MessageRetentionPeriod: 345600, + ReceiveMessageWaitTimeSeconds: 10, + VisibilityTimeout: 30, + }, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds) + assert.Equal(t, StringToInt(2), cqr.Attributes.MaximumMessageSize) + assert.Equal(t, StringToInt(3), cqr.Attributes.MessageRetentionPeriod) + assert.Equal(t, map[string]interface{}{"i-am": "the-policy"}, cqr.Attributes.Policy) + assert.Equal(t, StringToInt(4), cqr.Attributes.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, StringToInt(5), cqr.Attributes.VisibilityTimeout) + assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy) + assert.Equal(t, map[string]interface{}{"i-am": "the-redrive-allow-policy"}, cqr.Attributes.RedriveAllowPolicy) +} + +func TestSetQueueAttributesRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_int(t *testing.T) { + expectedRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Attribute.1.Name", "RedrivePolicy") + form.Add("Attribute.1.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + + cqr := &SetQueueAttributesRequest{ + Attributes: QueueAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy) +} + +func TestSetQueueAttributesRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_string(t *testing.T) { + expectedRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 100, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Attribute.1.Name", "RedrivePolicy") + form.Add("Attribute.1.Value", "{\"maxReceiveCount\": \"100\", \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + + cqr := &SetQueueAttributesRequest{ + Attributes: QueueAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy) +} + +func TestSetQueueAttributesRequest_SetAttributesFromForm_success_default_unparsable_redrive_recieve_count(t *testing.T) { + defaultRedrivePolicy := RedrivePolicy{ + MaxReceiveCount: 10, + DeadLetterTargetArn: "dead-letter-queue-arn", + } + + form := url.Values{} + form.Add("Attribute.1.Name", "RedrivePolicy") + form.Add("Attribute.1.Value", "{\"maxReceiveCount\": null, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}") + + cqr := &SetQueueAttributesRequest{ + Attributes: QueueAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, defaultRedrivePolicy, cqr.Attributes.RedrivePolicy) +} + +func TestSetQueueAttributesRequest_SetAttributesFromForm_success_skips_invalid_values(t *testing.T) { + form := url.Values{} + form.Add("Attribute.1.Name", "DelaySeconds") + form.Add("Attribute.1.Value", "garbage") + form.Add("Attribute.2.Name", "MaximumMessageSize") + form.Add("Attribute.2.Value", "garbage") + form.Add("Attribute.3.Name", "MessageRetentionPeriod") + form.Add("Attribute.3.Value", "garbage") + form.Add("Attribute.4.Name", "Policy") + form.Add("Attribute.4.Value", "garbage") + form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds") + form.Add("Attribute.5.Value", "garbage") + form.Add("Attribute.6.Name", "VisibilityTimeout") + form.Add("Attribute.6.Value", "garbage") + form.Add("Attribute.7.Name", "RedrivePolicy") + form.Add("Attribute.7.Value", "garbage") + form.Add("Attribute.8.Name", "RedriveAllowPolicy") + form.Add("Attribute.8.Value", "garbage") + + cqr := &SetQueueAttributesRequest{ + Attributes: QueueAttributes{ + DelaySeconds: 1, + MaximumMessageSize: 262144, + MessageRetentionPeriod: 345600, + ReceiveMessageWaitTimeSeconds: 10, + VisibilityTimeout: 30, + }, + } + cqr.SetAttributesFromForm(form) + + assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds) + assert.Equal(t, StringToInt(262144), cqr.Attributes.MaximumMessageSize) + assert.Equal(t, StringToInt(345600), cqr.Attributes.MessageRetentionPeriod) + assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.Policy) + assert.Equal(t, StringToInt(10), cqr.Attributes.ReceiveMessageWaitTimeSeconds) + assert.Equal(t, StringToInt(30), cqr.Attributes.VisibilityTimeout) + assert.Equal(t, RedrivePolicy{}, cqr.Attributes.RedrivePolicy) + assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.RedriveAllowPolicy) +} + +func TestNewCreateTopicRequest(t *testing.T) { + defer func() { + ResetApp() + }() + + result := NewCreateTopicRequest() + + assert.Equal(t, false, result.Attributes.FifoTopic) + assert.Equal(t, StringToInt(1), result.Attributes.SignatureVersion) + assert.Equal(t, "Active", result.Attributes.TracingConfig) + assert.Equal(t, false, result.Attributes.ContentBasedDeduplication) +} + +func TestCreateTopicRequest_SetAttributesFromForm_success(t *testing.T) { + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "new-queue") + form.Add("Version", "2012-11-05") + form.Add("Attribute.1.Name", "DeliveryPolicy") + form.Add("Attribute.1.Value", "{\"i-am\":\"the-policy\", \"name\":\"delivery-policy\"}") + form.Add("Attribute.2.Name", "DisplayName") + form.Add("Attribute.2.Value", "Foo") + form.Add("Attribute.3.Name", "FifoTopic") + form.Add("Attribute.3.Value", "true") + form.Add("Attribute.4.Name", "Policy") + form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\", \"name\":\"policy\"}") + form.Add("Attribute.5.Name", "SignatureVersion") + form.Add("Attribute.5.Value", "99") + form.Add("Attribute.6.Name", "TracingConfig") + form.Add("Attribute.6.Value", "PassThrough") + form.Add("Attribute.7.Name", "KmsMasterKeyId") + form.Add("Attribute.7.Value", "1234abcd-12ab-34cd-56ef-1234567890ab") + form.Add("Attribute.8.Name", "ArchivePolicy") + form.Add("Attribute.8.Value", "{\"i-am\":\"the-policy\", \"name\":\"archive-policy\"}") + form.Add("Attribute.9.Name", "BeginningArchiveTime") + form.Add("Attribute.9.Value", "2024-07-01T23:59:59+09:00") + form.Add("Attribute.10.Name", "ContentBasedDeduplication") + form.Add("Attribute.10.Value", "true") + + ctr := &CreateTopicRequest{} + ctr.SetAttributesFromForm(form) + + assert.Equal(t, 2, len(ctr.Attributes.DeliveryPolicy)) + assert.Equal(t, "the-policy", ctr.Attributes.DeliveryPolicy["i-am"]) + assert.Equal(t, "delivery-policy", ctr.Attributes.DeliveryPolicy["name"]) + assert.Equal(t, "Foo", ctr.Attributes.DisplayName) + assert.Equal(t, true, ctr.Attributes.FifoTopic) + assert.Equal(t, 2, len(ctr.Attributes.Policy)) + assert.Equal(t, "the-policy", ctr.Attributes.Policy["i-am"]) + assert.Equal(t, "policy", ctr.Attributes.Policy["name"]) + assert.Equal(t, StringToInt(99), ctr.Attributes.SignatureVersion) + assert.Equal(t, "PassThrough", ctr.Attributes.TracingConfig) + assert.Equal(t, "1234abcd-12ab-34cd-56ef-1234567890ab", ctr.Attributes.KmsMasterKeyId) + assert.Equal(t, 2, len(ctr.Attributes.ArchivePolicy)) + assert.Equal(t, "the-policy", ctr.Attributes.ArchivePolicy["i-am"]) + assert.Equal(t, "archive-policy", ctr.Attributes.ArchivePolicy["name"]) + assert.Equal(t, "2024-07-01T23:59:59+09:00", ctr.Attributes.BeginningArchiveTime) + assert.Equal(t, true, ctr.Attributes.ContentBasedDeduplication) +} + +func TestSubscribeRequest_SetAttributesFromForm_success(t *testing.T) { + form := url.Values{} + form.Add("Attributes.entry.1.key", "RawMessageDelivery") + form.Add("Attributes.entry.1.value", "true") + form.Add("Attributes.entry.2.key", "FilterPolicy") + form.Add("Attributes.entry.2.value", "{\"filter\": [\"policy\"]}") + + cqr := &SubscribeRequest{ + Attributes: SubscriptionAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.True(t, cqr.Attributes.RawMessageDelivery) + assert.Equal(t, FilterPolicy{"filter": []string{"policy"}}, cqr.Attributes.FilterPolicy) +} + +func TestSubscribeRequest_SetAttributesFromForm_skips_invalid_values(t *testing.T) { + form := url.Values{} + form.Add("Attributes.entry.1.key", "RawMessageDelivery") + form.Add("Attributes.entry.1.value", "garbage") + form.Add("Attributes.entry.2.key", "FilterPolicy") + form.Add("Attributes.entry.2.value", "also-garbage") + + cqr := &SubscribeRequest{ + Attributes: SubscriptionAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.False(t, cqr.Attributes.RawMessageDelivery) + assert.Equal(t, FilterPolicy(nil), cqr.Attributes.FilterPolicy) +} + +func TestSubscribeRequest_SetAttributesFromForm_stops_if_attributes_not_numbered_sequentially(t *testing.T) { + form := url.Values{} + form.Add("Attributes.entry.2.key", "RawMessageDelivery") + form.Add("Attributes.entry.2.value", "garbage") + form.Add("Attributes.entry.3.key", "FilterPolicy") + form.Add("Attributes.entry.3.value", "also-garbage") + + cqr := &SubscribeRequest{ + Attributes: SubscriptionAttributes{}, + } + cqr.SetAttributesFromForm(form) + + assert.False(t, cqr.Attributes.RawMessageDelivery) + assert.Equal(t, FilterPolicy(nil), cqr.Attributes.FilterPolicy) +} + +func Test_DeleteMessageBatchRequest_SetAttributesFromForm_success(t *testing.T) { + form := url.Values{} + form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1") + form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1") + form.Add("DeleteMessageBatchRequestEntry.2.Id", "message-id-2") + form.Add("DeleteMessageBatchRequestEntry.2.ReceiptHandle", "receipt-handle-2") + form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3") + form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3") + + dmbr := &DeleteMessageBatchRequest{} + dmbr.SetAttributesFromForm(form) + + assert.Len(t, dmbr.Entries, 3) + assert.Equal(t, "message-id-1", dmbr.Entries[0].Id) + assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle) + assert.Equal(t, "message-id-2", dmbr.Entries[1].Id) + assert.Equal(t, "receipt-handle-2", dmbr.Entries[1].ReceiptHandle) + assert.Equal(t, "message-id-3", dmbr.Entries[2].Id) + assert.Equal(t, "receipt-handle-3", dmbr.Entries[2].ReceiptHandle) +} + +func Test_DeleteMessageBatchRequest_SetAttributesFromForm_stops_at_non_sequential_keys(t *testing.T) { + form := url.Values{} + form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1") + form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1") + form.Add("DeleteMessageBatchRequestEntry.4.Id", "message-id-2") + form.Add("DeleteMessageBatchRequestEntry.4.ReceiptHandle", "receipt-handle-2") + form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3") + form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3") + + dmbr := &DeleteMessageBatchRequest{} + dmbr.SetAttributesFromForm(form) + + assert.Len(t, dmbr.Entries, 1) + assert.Equal(t, "message-id-1", dmbr.Entries[0].Id) + assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle) +} + +func Test_DeleteMessageBatchRequest_SetAttributesFromForm_stops_at_invalid_keys(t *testing.T) { + form := url.Values{} + form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1") + form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1") + form.Add("INVALID_DeleteMessageBatchRequestEntry.2.Id", "message-id-2") + form.Add("DeleteMessageBatchRequestEntry.2.ReceiptHandle", "receipt-handle-2") + form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3") + form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3") + + dmbr := &DeleteMessageBatchRequest{} + dmbr.SetAttributesFromForm(form) + + assert.Len(t, dmbr.Entries, 1) + assert.Equal(t, "message-id-1", dmbr.Entries[0].Id) + assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle) +} + +func TestPublishRequest_SetAttributesFromForm_success_concurrent(t *testing.T) { + form := url.Values{} + form.Add("MessageAttributes.entry.1.Name", "test1") + form.Add("MessageAttributes.entry.1.Value.DataType", "String") + form.Add("MessageAttributes.entry.1.Value.StringValue", "sample-string") + form.Add("MessageAttributes.entry.2.Name", "test2") + form.Add("MessageAttributes.entry.2.Value.DataType", "Binary") + form.Add("MessageAttributes.entry.2.Value.BinaryValue", "YmluYXJ5LXZhbHVl") + + // if the code is not thread-safe, repeated runs increase the chance of detecting a race. + for r := 0; r < 10; r++ { + var wg sync.WaitGroup + goroutineCount := 40 + // launch goroutines in parallel to simulate concurrent access. + for g := 0; g < goroutineCount; g++ { + wg.Add(1) + go func() { + defer wg.Done() + // introduce a random delay to encourage goroutine interleaving + time.Sleep(time.Duration(rand.Intn(5)) * time.Millisecond) + cqr := &PublishRequest{ + MessageAttributes: make(map[string]MessageAttribute), + } + + cqr.SetAttributesFromForm(form) + + // validate the expected DataType values + assert.Equal(t, "String", cqr.MessageAttributes["test1"].DataType) + assert.Equal(t, "Binary", cqr.MessageAttributes["test2"].DataType) + }() + } + wg.Wait() + } +} + +func TestParseMessageAttributes(t *testing.T) { + for _, tc := range []struct { + description string + values url.Values + keyPrefix string + want map[string]MessageAttribute + }{ + { + description: "empty", + values: url.Values{}, + keyPrefix: "foo", + want: nil, + }, + { + description: "simple", + values: url.Values{ + "MessageAttribute.1.Name": []string{"Attr1"}, + "MessageAttribute.1.Value.DataType": []string{"String"}, + "MessageAttribute.1.Value.StringValue": []string{"Value1"}, + "MessageAttribute.2.Name": []string{"Attr2"}, + "MessageAttribute.2.Value.DataType": []string{"Binary"}, + "MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"}, + }, + keyPrefix: "MessageAttribute", + want: map[string]MessageAttribute{ + "Attr1": { + DataType: "String", + StringValue: "Value1", + BinaryValue: "", + }, + "Attr2": { + DataType: "Binary", + BinaryValue: "VmFsdWUy", + }, + }, + }, + { + description: "attributes after empty name ignored", + values: url.Values{ + "MessageAttribute.1.Name": []string{""}, + "MessageAttribute.1.Value.DataType": []string{"String"}, + "MessageAttribute.1.Value.StringValue": []string{"Value4"}, + "MessageAttribute.2.Name": []string{"Attr2"}, + "MessageAttribute.2.Value.DataType": []string{"Binary"}, + "MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"}, + }, + keyPrefix: "MessageAttribute", + want: nil, + }, + { + description: "attributes after missing number ignored", + values: url.Values{ + // Note starting from 2 + "MessageAttribute.2.Name": []string{"Attr2"}, + "MessageAttribute.2.Value.DataType": []string{"Binary"}, + "MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"}, + }, + keyPrefix: "MessageAttribute", + want: nil, + }, + { + description: "empty DataType ignored", + values: url.Values{ + "MessageAttribute.1.Name": []string{"Attr4"}, + "MessageAttribute.1.Value.DataType": []string{""}, + "MessageAttribute.1.Value.StringValue": []string{"Value4"}, + }, + keyPrefix: "MessageAttribute", + want: nil, + }, + } { + t.Run(tc.description, func(t *testing.T) { + got := parseMessageAttributes(tc.values, tc.keyPrefix) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/app/models/responses.go b/app/models/responses.go new file mode 100644 index 0000000..a69e652 --- /dev/null +++ b/app/models/responses.go @@ -0,0 +1,340 @@ +package models + +import ( + "encoding/xml" +) + +type ResponseMetadata struct { + RequestId string `xml:"RequestId"` +} + +// NOTE: Every response in here MUST implement the `AbstractResponseBody` interface in order to be used +// in `encodeResponse` + +/*** Error Responses ***/ +type ErrorResult struct { + Type string `json:"Type,omitempty" xml:"Type,omitempty"` + Code string `json:"Code,omitempty" xml:"Code,omitempty"` + Message string `json:"Message,omitempty" xml:"Message,omitempty"` +} + +type ErrorResponse struct { + Result ErrorResult `json:"Error" xml:"Error"` + RequestId string `json:"RequestId" xml:"RequestId"` +} + +func (r ErrorResponse) GetResult() interface{} { + return r.Result +} + +func (r ErrorResponse) GetRequestId() string { + return r.RequestId +} + +/*** Receive Message Response */ +type ReceiveMessageResult struct { + Messages []*ResultMessage `json:"Messages" xml:"Message,omitempty"` +} + +type ReceiveMessageResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result ReceiveMessageResult `json:"ReceiveMessageResult" xml:"ReceiveMessageResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r ReceiveMessageResponse) GetResult() interface{} { + return r.Result +} + +func (r ReceiveMessageResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +type ResultMessage struct { + MessageId string `json:"MessageId,omitempty" xml:"MessageId,omitempty"` + ReceiptHandle string `json:"ReceiptHandle,omitempty" xml:"ReceiptHandle,omitempty"` + MD5OfBody string `json:"MD5OfBody,omitempty" xml:"MD5OfBody,omitempty"` + Body string `json:"Body,omitempty" xml:"Body,omitempty"` + MD5OfMessageAttributes string `json:"MD5OfMessageAttributes,omitempty" xml:"MD5OfMessageAttributes,omitempty"` + MessageAttributes map[string]MessageAttribute `json:"MessageAttributes,omitempty" xml:"MessageAttribute,omitempty,attr"` + Attributes map[string]string `json:"Attributes,omitempty" xml:"Attribute,omitempty,attr"` +} + +// MarshalXML is a custom marshaler for the ResultMessage struct. We need it because we need to convert the +// maps into something that can be shown as XML. If we ever get rid of the XML response parsing this can go, +// and that would be glorious. +func (r *ResultMessage) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + type Attributes struct { + Name string `xml:"Name,omitempty"` + Value string `xml:"Value,omitempty"` + } + var attrs []Attributes + for key, value := range r.Attributes { + attribute := Attributes{ + Name: key, + Value: value, + } + attrs = append(attrs, attribute) + } + + type MessageAttributes struct { + Name string `xml:"Name,omitempty"` + Value MessageAttribute `xml:"Value,omitempty"` + } + var messageAttrs []MessageAttributes + for key, value := range r.MessageAttributes { + attribute := MessageAttributes{ + Name: key, + Value: value, + } + messageAttrs = append(messageAttrs, attribute) + } + e.EncodeToken(start) + + // Encode the fields + e.EncodeElement(r.MessageId, xml.StartElement{Name: xml.Name{Local: "MessageId"}}) + e.EncodeElement(r.ReceiptHandle, xml.StartElement{Name: xml.Name{Local: "ReceiptHandle"}}) + e.EncodeElement(r.MD5OfBody, xml.StartElement{Name: xml.Name{Local: "MD5OfBody"}}) + e.EncodeElement(r.Body, xml.StartElement{Name: xml.Name{Local: "Body"}}) + e.EncodeElement(attrs, xml.StartElement{Name: xml.Name{Local: "Attribute"}}) + e.EncodeElement(messageAttrs, xml.StartElement{Name: xml.Name{Local: "MessageAttribute"}}) + e.EncodeToken(xml.EndElement{Name: start.Name}) + return nil +} + +type ChangeMessageVisibilityResult struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r ChangeMessageVisibilityResult) GetResult() interface{} { + return nil +} + +func (r ChangeMessageVisibilityResult) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Create Queue Response */ +type CreateQueueResult struct { + QueueUrl string `json:"QueueUrl" xml:"QueueUrl"` +} + +type CreateQueueResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result CreateQueueResult `json:"CreateQueueResult" xml:"CreateQueueResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r CreateQueueResponse) GetResult() interface{} { + return r.Result +} + +func (r CreateQueueResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** List Queues Response */ +type ListQueuesResult struct { + // NOTE: the old XML sdks depend on QueueUrl, and the new JSON ones need QueueUrls + QueueUrls []string `json:"QueueUrls" xml:"QueueUrl"` +} + +type ListQueuesResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result ListQueuesResult `json:"ListQueuesResult" xml:"ListQueuesResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r ListQueuesResponse) GetResult() interface{} { + return r.Result +} + +func (r ListQueuesResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Get Queue QueueAttributes ***/ +type Attribute struct { + Name string `json:"Name,omitempty" xml:"Name,omitempty"` + Value string `json:"Value,omitempty" xml:"Value,omitempty"` +} + +type GetQueueAttributesResult struct { + /* VisibilityTimeout, DelaySeconds, ReceiveMessageWaitTimeSeconds, ApproximateNumberOfMessages + ApproximateNumberOfMessagesNotVisible, CreatedTimestamp, LastModifiedTimestamp, QueueArn */ + Attrs []Attribute `json:"Attributes,omitempty" xml:"Attribute,omitempty"` +} + +type GetQueueAttributesResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result GetQueueAttributesResult `json:"GetQueueAttributesResult" xml:"GetQueueAttributesResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r GetQueueAttributesResponse) GetResult() interface{} { + result := map[string]string{} + for _, attr := range r.Result.Attrs { + result[attr.Name] = attr.Value + } + return map[string]map[string]string{"Attributes": result} +} + +func (r GetQueueAttributesResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Send Message Response */ +type SendMessageResult struct { + MD5OfMessageAttributes string `json:"MD5OfMessageAttributes,omitempty" xml:"MD5OfMessageAttributes,omitempty"` + MD5OfMessageBody string `json:"MD5OfMessageBody" xml:"MD5OfMessageBody"` + MessageId string `json:"MessageId" xml:"MessageId"` + SequenceNumber string `json:"SequenceNumber,omitempty" xml:"SequenceNumber,omitempty"` +} + +type SendMessageResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result SendMessageResult `json:"SendMessageResult" xml:"SendMessageResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r SendMessageResponse) GetResult() interface{} { + return r.Result +} + +func (r SendMessageResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Delete Message Response */ +type DeleteMessageResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r DeleteMessageResponse) GetResult() interface{} { + return nil +} + +func (r DeleteMessageResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Get Queue Url Response */ +type GetQueueUrlResult struct { + QueueUrl string `json:"QueueUrl,omitempty" xml:"QueueUrl,omitempty"` +} + +type GetQueueUrlResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result GetQueueUrlResult `json:"GetQueueUrlResult" xml:"GetQueueUrlResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r GetQueueUrlResponse) GetResult() interface{} { + return r.Result +} + +func (r GetQueueUrlResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +type SendMessageBatchResultEntry struct { + Id string `json:"Id" xml:"Id"` + MessageId string `json:"MessageId" xml:"MessageId"` + MD5OfMessageBody string `json:"MD5OfMessageBody,omitempty" xml:"MD5OfMessageBody,omitempty"` + MD5OfMessageAttributes string `json:"MD5OfMessageAttributes,omitempty" xml:"MD5OfMessageAttributes,omitempty"` + SequenceNumber string `json:"SequenceNumber" xml:"SequenceNumber"` +} + +/*** Send Message Batch Response */ +type SendMessageBatchResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result SendMessageBatchResult `json:"SendMessageBatchResult" xml:"SendMessageBatchResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +type SendMessageBatchResult struct { + Entry []SendMessageBatchResultEntry `json:"Successful" xml:"SendMessageBatchResultEntry"` + Error []BatchResultErrorEntry `json:"Failed,omitempty" xml:"BatchResultErrorEntry,omitempty"` +} + +func (r SendMessageBatchResponse) GetResult() interface{} { + return r.Result +} + +func (r SendMessageBatchResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +type BatchResultErrorEntry struct { + Code string `json:"Code" xml:"Code"` + Id string `json:"Id" xml:"Id"` + Message string `json:"Message,omitempty" xml:"Message,omitempty"` + SenderFault bool `json:"SenderFault" xml:"SenderFault"` +} + +type SetQueueAttributesResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r SetQueueAttributesResponse) GetResult() interface{} { + return nil +} + +func (r SetQueueAttributesResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Purge Queue Response */ +type PurgeQueueResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r PurgeQueueResponse) GetResult() interface{} { + return nil +} + +func (r PurgeQueueResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Delete Queue Response */ +type DeleteQueueResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r DeleteQueueResponse) GetResult() interface{} { + return nil +} + +func (r DeleteQueueResponse) GetRequestId() string { + return r.Metadata.RequestId +} + +/*** Delete Message Batch Response ***/ +type DeleteMessageBatchResultEntry struct { + Id string `json:"Id" xml:"Id"` +} + +type DeleteMessageBatchResult struct { + Successful []DeleteMessageBatchResultEntry `json:"Successful" xml:"DeleteMessageBatchResultEntry"` + Failed []BatchResultErrorEntry `json:"Failed,omitempty" xml:"BatchResultErrorEntry,omitempty"` +} + +type DeleteMessageBatchResponse struct { + Xmlns string `json:"Xmlns" xml:"xmlns,attr"` + Result DeleteMessageBatchResult `json:"DeleteMessageBatchResult" xml:"DeleteMessageBatchResult"` + Metadata ResponseMetadata `json:"ResponseMetadata" xml:"ResponseMetadata"` +} + +func (r DeleteMessageBatchResponse) GetResult() interface{} { + return r.Result +} + +func (r DeleteMessageBatchResponse) GetRequestId() string { + return r.Metadata.RequestId +} diff --git a/app/models/responses_test.go b/app/models/responses_test.go new file mode 100644 index 0000000..92b7541 --- /dev/null +++ b/app/models/responses_test.go @@ -0,0 +1,110 @@ +package models + +import ( + "encoding/xml" + "testing" + + "github.com/stretchr/testify/assert" +) + +// NOTE: For now, we're only going to test those methods that do something other than just return a field + +func TestGetQueueAttributesResponse_GetResult(t *testing.T) { + gqa := GetQueueAttributesResponse{ + Result: GetQueueAttributesResult{Attrs: []Attribute{ + {Name: "attribute-name1", Value: "attribute-value1"}, + {Name: "attribute-name2", Value: "attribute-value2"}, + }}, + } + + expectedAttributes := map[string]map[string]string{ + "Attributes": { + "attribute-name1": "attribute-value1", + "attribute-name2": "attribute-value2", + }, + } + result := gqa.GetResult() + + assert.Equal(t, expectedAttributes, result) +} + +func Test_ResultMessage_MarshalXML_success_with_attributes(t *testing.T) { + input := &ResultMessage{ + MessageId: "message-id", + ReceiptHandle: "receipt-handle", + MD5OfBody: "body-md5", + Body: "message-body", + MD5OfMessageAttributes: "message-attrs-md5", + MessageAttributes: map[string]MessageAttribute{ + "attr1": { + DataType: "String", + StringValue: "string-value", + }, + "attr2": { + DataType: "Binary", + BinaryValue: "binary-value", + }, + "attr3": { + DataType: "Number", + StringValue: "number-value", + }, + }, + Attributes: map[string]string{ + "ApproximateFirstReceiveTimestamp": "1", + "SenderId": "2", + "ApproximateReceiveCount": "3", + "SentTimestamp": "4", + }, + } + result, err := xml.Marshal(input) + + assert.Nil(t, err) + + resultString := string(result) + + // We have to assert piecemeal like this, the maps go into their lists unordered, which will randomly break this. + entry := "message-idreceipt-handlebody-md5message-body" + assert.Contains(t, resultString, entry) + + entry = "ApproximateFirstReceiveTimestamp1" + assert.Contains(t, resultString, entry) + + entry = "SenderId2" + assert.Contains(t, resultString, entry) + + entry = "ApproximateReceiveCount3" + assert.Contains(t, resultString, entry) + + entry = "SentTimestamp4" + assert.Contains(t, resultString, entry) + + entry = "attr1Stringstring-value" + assert.Contains(t, resultString, entry) + + entry = "attr2binary-valueBinary" + assert.Contains(t, resultString, entry) + + entry = "attr3Numbernumber-value" + assert.Contains(t, resultString, entry) + + entry = "" + assert.Contains(t, resultString, entry) +} + +func Test_ResultMessage_MarshalXML_success_no_attributes(t *testing.T) { + input := &ResultMessage{ + MessageId: "message-id", + ReceiptHandle: "receipt-handle", + MD5OfBody: "body-md5", + Body: "message-body", + MD5OfMessageAttributes: "message-attrs-md5", + } + expectedOutput := "message-idreceipt-handlebody-md5message-body" + + result, err := xml.Marshal(input) + + assert.Nil(t, err) + + resultString := string(result) + assert.Equal(t, resultString, expectedOutput) +} diff --git a/app/persistence/redis.go b/app/persistence/redis.go new file mode 100644 index 0000000..e7934ca --- /dev/null +++ b/app/persistence/redis.go @@ -0,0 +1,192 @@ +// app/persistence/redis.go +// Redis persistence layer для shared-sqs — write-through cache. +// Стратегия: память — источник правды для чтения (быстро), +// Redis — источник правды для восстановления после рестарта. +// Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции. +// Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот. +// Created: 2026-04-10 + +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + log "github.com/sirupsen/logrus" + + "shared-sqs/app/models" +) + +// Client — глобальный Redis клиент. +// nil означает режим "только память" — все функции тихо no-op. +var Client *redis.Client + +const ( + // redisHashTenants — HASH: tenantID → JSON тенанта + redisHashTenants = "ssq:tenants" + // redisHashQueues — HASH: queueKey → JSON очереди (включая сообщения) + redisHashQueues = "ssq:queues" +) + +// Connect — подключается к Redis и проверяет ping. +// Если addr пустой — не подключается, остаёмся в memory-only режиме. +func Connect(addr, username, password string) error { + if addr == "" { + log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме") + return nil + } + rdb := redis.NewClient(&redis.Options{ + Addr: addr, + Username: username, + Password: password, + DB: 0, + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := rdb.Ping(ctx).Err(); err != nil { + return fmt.Errorf("redis ping %s: %w", addr, err) + } + Client = rdb + log.Infof("persistence: подключились к Redis %s", addr) + return nil +} + +// asyncWrite — запускает fn в горутине, перехватывает panic и логирует. +// Используется для записей в Redis чтобы не задерживать SQS-ответы. +func asyncWrite(fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + log.Errorf("persistence: panic в asyncWrite: %v", r) + } + }() + fn() + }() +} + +// SaveQueue — сохраняет очередь (с сообщениями) в Redis асинхронно. +// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда json.Marshal +// создаёт консистентный снапшот. Горутина только делает сетевой вызов. +func SaveQueue(key string, queue *models.Queue) { + if Client == nil { + return + } + // Сериализуем синхронно под мьютексом вызывающего → консистентный снапшот + data, err := json.Marshal(queue) + if err != nil { + log.Errorf("persistence: marshal queue %q: %v", key, err) + return + } + asyncWrite(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil { + log.Errorf("persistence: HSet queue %q: %v", key, err) + } + }) +} + +// DeleteQueue — удаляет очередь из Redis асинхронно. +func DeleteQueue(key string) { + if Client == nil { + return + } + asyncWrite(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil { + log.Errorf("persistence: HDel queue %q: %v", key, err) + } + }) +} + +// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса. +// Инициализирует nil-maps чтобы избежать panic при deduplication/FIFO операциях. +func LoadAllQueues() (map[string]*models.Queue, error) { + if Client == nil { + return nil, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + raw, err := Client.HGetAll(ctx, redisHashQueues).Result() + if err != nil { + return nil, fmt.Errorf("redis HGetAll queues: %w", err) + } + queues := make(map[string]*models.Queue, len(raw)) + for k, v := range raw { + var q models.Queue + if err := json.Unmarshal([]byte(v), &q); err != nil { + log.Errorf("persistence: unmarshal queue %q: %v", k, err) + continue + } + // Инициализируем nil-maps — их json.Unmarshal не создаёт если поле было nil + if q.Duplicates == nil { + q.Duplicates = make(map[string]time.Time) + } + if q.FIFOMessages == nil { + q.FIFOMessages = make(map[string]int) + } + if q.FIFOSequenceNumbers == nil { + q.FIFOSequenceNumbers = make(map[string]int) + } + queues[k] = &q + } + log.Infof("persistence: загружено %d очередей из Redis", len(queues)) + return queues, nil +} + +// SaveTenantRaw — сохраняет тенанта (сырой JSON) в Redis асинхронно. +// Принимает []byte чтобы избежать циклического импорта с пакетом tenant. +// Сериализацию делает вызывающий (tenant_store.go). +func SaveTenantRaw(id string, jsonData []byte) { + if Client == nil { + return + } + // Копируем bytes — вызывающий может переиспользовать буфер + dataCopy := make([]byte, len(jsonData)) + copy(dataCopy, jsonData) + asyncWrite(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := Client.HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil { + log.Errorf("persistence: HSet tenant %q: %v", id, err) + } + }) +} + +// DeleteTenant — удаляет тенанта из Redis асинхронно. +func DeleteTenant(id string) { + if Client == nil { + return + } + asyncWrite(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := Client.HDel(ctx, redisHashTenants, id).Err(); err != nil { + log.Errorf("persistence: HDel tenant %q: %v", id, err) + } + }) +} + +// LoadAllTenantsRaw — загружает всех тенантов из Redis при старте. +// Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go. +func LoadAllTenantsRaw() (map[string][]byte, error) { + if Client == nil { + return nil, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + raw, err := Client.HGetAll(ctx, redisHashTenants).Result() + if err != nil { + return nil, fmt.Errorf("redis HGetAll tenants: %w", err) + } + result := make(map[string][]byte, len(raw)) + for id, v := range raw { + result[id] = []byte(v) + } + log.Infof("persistence: загружено %d тенантов из Redis", len(result)) + return result, nil +} diff --git a/app/router/router.go b/app/router/router.go new file mode 100644 index 0000000..7c9b2b5 --- /dev/null +++ b/app/router/router.go @@ -0,0 +1,146 @@ +// app/router/router.go +// HTTP router для shared-sqs +// Updated: 2026-04-09 — добавлены TenantStore, admin API, auth middleware +package router + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + + "shared-sqs/app/admin" + "shared-sqs/app/auth" + sqs "shared-sqs/app/gosqs" + "shared-sqs/app/interfaces" + "shared-sqs/app/tenant" + "shared-sqs/app/ui" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" +) + +// New — создаёт HTTP router с tenant auth и admin API +func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler { + r := mux.NewRouter() + + // /health — публичный, без auth + r.HandleFunc("/health", health).Methods("GET") + + // Admin API — Bearer token auth, регистрируется через AdminHandler + adminHandler := admin.NewHandler(tenantStore, adminToken) + adminHandler.RegisterRoutes(r) + + // UI public API — без auth, для встроенной console + adminHandler.RegisterPublicRoutes(r) + + // UI console — встроенный SPA, публичный доступ + r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler())) + + // SQS API — tenant auth middleware оборачивает каждый handler отдельно. + // r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0 + // при пустом prefix — ответы теряются. Поэтому используем явную обёртку. + sqsAuth := auth.AuthMiddleware(tenantStore) + r.Handle("/", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST") + r.Handle("/{account}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST") + r.Handle("/queue/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST") + r.Handle("/{account}/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST") + + return r +} + +func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) { + protocol := resolveProtocol(req) + switch protocol { + case AwsJsonProtocol: + w.Header().Set("x-amzn-RequestId", body.GetRequestId()) + w.Header().Set("Content-Type", "application/x-amz-json-1.0") + w.WriteHeader(statusCode) + if body.GetResult() == nil { + return + } + err := json.NewEncoder(w).Encode(body.GetResult()) + if err != nil { + log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body) + http.Error(w, "General Error", http.StatusInternalServerError) + } + case AwsQueryProtocol: + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(statusCode) + result, err := xml.Marshal(body) + if err != nil { + log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body) + http.Error(w, "General Error", http.StatusInternalServerError) + } + _, _ = w.Write(result) + } +} + +// routingTableV1 — только SQS actions (SNS удалён) +var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": sqs.CreateQueueV1, + "ListQueues": sqs.ListQueuesV1, + "GetQueueAttributes": sqs.GetQueueAttributesV1, + "SetQueueAttributes": sqs.SetQueueAttributesV1, + "SendMessage": sqs.SendMessageV1, + "ReceiveMessage": sqs.ReceiveMessageV1, + "ChangeMessageVisibility": sqs.ChangeMessageVisibilityV1, + "DeleteMessage": sqs.DeleteMessageV1, + "GetQueueUrl": sqs.GetQueueUrlV1, + "PurgeQueue": sqs.PurgeQueueV1, + "DeleteQueue": sqs.DeleteQueueV1, + "SendMessageBatch": sqs.SendMessageBatchV1, + "DeleteMessageBatch": sqs.DeleteMessageBatchV1, +} + +func health(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(200) + fmt.Fprint(w, "OK") +} + +func actionHandler(w http.ResponseWriter, req *http.Request) { + action := extractAction(req) + log.WithFields(log.Fields{ + "action": action, + "url": req.URL, + }).Debug("Handling URL request") + jsonFn, ok := routingTableV1[action] + if ok { + statusCode, responseBody := jsonFn(req) + encodeResponse(w, req, statusCode, responseBody) + return + } + log.Warnf("Bad Request - Action: %s", action) + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, "Bad Request") +} + +type AwsProtocol int + +const ( + AwsJsonProtocol AwsProtocol = iota + AwsQueryProtocol AwsProtocol = iota +) + +// extractAction — извлекает Action из запроса (Query Protocol или JSON Protocol) +func extractAction(req *http.Request) string { + protocol := resolveProtocol(req) + switch protocol { + case AwsJsonProtocol: + action := req.Header.Get("X-Amz-Target") + return strings.Split(action, ".")[1] + case AwsQueryProtocol: + return req.FormValue("Action") + } + return "" +} + +// resolveProtocol — определяет протокол по Content-Type +func resolveProtocol(req *http.Request) AwsProtocol { + if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" { + return AwsJsonProtocol + } + return AwsQueryProtocol +} diff --git a/app/router/router_test.go b/app/router/router_test.go new file mode 100644 index 0000000..dd06773 --- /dev/null +++ b/app/router/router_test.go @@ -0,0 +1,251 @@ +package router + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + af "shared-sqs/app/fixtures" + + "shared-sqs/app/mocks" + + "shared-sqs/app/interfaces" + + sqs "shared-sqs/app/gosqs" + + "github.com/stretchr/testify/assert" + + "shared-sqs/app/test" +) + +func TestIndexServerhandler_POST_BadRequest(t *testing.T) { + // Create a request to pass to our handler. We don't have any query parameters for now, so we'll + // pass 'nil' as the third parameter. + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Add("Action", "BadRequest") + req.PostForm = form + + // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. + rr := httptest.NewRecorder() + + // Our handlers satisfy http.Handler, so we can call their ServeHTTP method + // directly and pass in our Request and ResponseRecorder. + New().ServeHTTP(rr, req) + + // Check the status code is what we expect. + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } +} + +func TestIndexServerhandler_POST_GoodRequest(t *testing.T) { + // Create a request to pass to our handler. We don't have any query parameters for now, so we'll + // pass 'nil' as the third parameter. + req, err := http.NewRequest("POST", "/", nil) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Add("Action", "ListTopics") + req.PostForm = form + + // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. + rr := httptest.NewRecorder() + + // Our handlers satisfy http.Handler, so we can call their ServeHTTP method + // directly and pass in our Request and ResponseRecorder. + New().ServeHTTP(rr, req) + + // Check the status code is what we expect. + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } +} + +func TestIndexServerhandler_POST_GoodRequest_With_URL(t *testing.T) { + req, err := http.NewRequest("POST", "/100010001000/local-queue1", nil) + if err != nil { + t.Fatal(err) + } + + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "local-queue1") + req.PostForm = form + rr := httptest.NewRecorder() + New().ServeHTTP(rr, req) + + form = url.Values{} + form.Add("Action", "GetQueueAttributes") + form.Add("QueueUrl", fmt.Sprintf("%s/local-queue1", af.BASE_URL)) + req.PostForm = form + + // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. + rr = httptest.NewRecorder() + + // Our handlers satisfy http.Handler, so we can call their ServeHTTP method + // directly and pass in our Request and ResponseRecorder. + New().ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestIndexServerhandler_POST_GoodRequest_With_URL_And_Aws_Json_Protocol(t *testing.T) { + json, _ := json.Marshal(map[string]string{ + "QueueName": "local-queue1", + }) + req, err := http.NewRequest("POST", "/100010001000/local-queue1", bytes.NewBuffer(json)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Amz-Target", "AmazonSQS.CreateQueue") + req.Header.Set("Content-Type", "application/x-amz-json-1.0") + + rr := httptest.NewRecorder() + + New().ServeHTTP(rr, req) + + // Check the status code is what we expect. + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } +} + +func TestIndexServerhandler_GET_GoodRequest_Pem_cert(t *testing.T) { + + req, err := http.NewRequest("GET", "/SimpleNotificationService/100010001000.pem", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + New().ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } +} + +func TestEncodeResponse_success_xml(t *testing.T) { + w, r := test.GenerateRequestInfo("POST", "/url", nil, false) + + encodeResponse(w, r, http.StatusOK, mocks.BaseResponse{Message: "test"}) + + assert.Equal(t, http.StatusOK, w.Code) + + tmp := mocks.BaseResponse{} + xml.Unmarshal(w.Body.Bytes(), &tmp) + assert.Equal(t, mocks.BaseResponse{Message: "test"}, tmp) +} + +func TestEncodeResponse_success_skips_nil_body_xml(t *testing.T) { + w, r := test.GenerateRequestInfo("POST", "/url", nil, false) + + encodeResponse(w, r, http.StatusOK, nil) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, &bytes.Buffer{}, w.Body) +} + +func TestEncodeResponse_success_json(t *testing.T) { + w, r := test.GenerateRequestInfo("POST", "/url", nil, true) + + encodeResponse(w, r, http.StatusOK, mocks.BaseResponse{Message: "test"}) + + assert.Equal(t, http.StatusOK, w.Code) + + tmp := mocks.BaseResponse{} + json.Unmarshal(w.Body.Bytes(), &tmp) + assert.Equal(t, mocks.BaseResponse{Message: "test"}, tmp) +} + +func TestEncodeResponse_success_skips_malformed_body_json(t *testing.T) { + mock := mocks.BaseResponse{ + Message: "test", + } + mock.MockGetResult = func() interface{} { + return make(chan int) + } + w, r := test.GenerateRequestInfo("POST", "/url", nil, true) + + encodeResponse(w, r, http.StatusOK, mock) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "General Error", strings.TrimSpace(string(w.Body.Bytes()))) +} + +func TestActionHandler_v1_json(t *testing.T) { + defer func() { + routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": sqs.CreateQueueV1, + } + }() + + mockCalled := false + mockFunction := func(req *http.Request) (int, interfaces.AbstractResponseBody) { + mockCalled = true + return http.StatusOK, mocks.BaseResponse{Message: "response-body"} + } + routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": mockFunction, + } + + w, r := test.GenerateRequestInfo("POST", "/url", nil, true) + r.Header.Set("X-Amz-Target", "QueueService.CreateQueue") + + actionHandler(w, r) + + assert.True(t, mockCalled) + assert.Equal(t, http.StatusOK, w.Code) + + tmp := mocks.BaseResponse{} + json.Unmarshal(w.Body.Bytes(), &tmp) + assert.Equal(t, mocks.BaseResponse{Message: "response-body"}, tmp) +} + +func TestActionHandler_v1_xml(t *testing.T) { + defer func() { + routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": sqs.CreateQueueV1, + } + }() + + mockCalled := false + mockFunction := func(req *http.Request) (int, interfaces.AbstractResponseBody) { + mockCalled = true + return http.StatusOK, mocks.BaseResponse{Message: "response-body"} + } + routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": mockFunction, + } + + w, r := test.GenerateRequestInfo("POST", "/url", nil, false) + form := url.Values{} + form.Add("Action", "CreateQueue") + r.PostForm = form + + actionHandler(w, r) + + assert.True(t, mockCalled) + assert.Equal(t, http.StatusOK, w.Code) + + tmp := mocks.BaseResponse{} + xml.Unmarshal(w.Body.Bytes(), &tmp) + assert.Equal(t, mocks.BaseResponse{Message: "response-body"}, tmp) +} diff --git a/app/tenant/tenant_store.go b/app/tenant/tenant_store.go new file mode 100644 index 0000000..a6e453d --- /dev/null +++ b/app/tenant/tenant_store.go @@ -0,0 +1,197 @@ +// Изменено: 2026-04-10 — добавлена Redis-персистентность через пакет persistence +// Tenant model и in-memory хранилище тенантов для shared-sqs. +package tenant + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" + + "shared-sqs/app/persistence" +) + +// Tenant — модель тенанта shared-sqs. +// AccessKey используется как идентификатор в AWS Authorization header. +type Tenant struct { + ID string // уникальный идентификатор тенанта (t-) + Name string // человекочитаемое имя + AccessKey string // аналог AWS AccessKeyId (SSAK-) + SecretKey string // аналог AWS SecretAccessKey (64 hex chars) + MaxQueues int // лимит очередей (0 = безлимит) + CreatedAt time.Time + Active bool +} + +// TenantStore — потокобезопасное in-memory хранилище тенантов. +// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware). +type TenantStore struct { + mu sync.RWMutex + byID map[string]*Tenant + byAccessKey map[string]*Tenant +} + +// NewTenantStore — создаёт пустое хранилище тенантов. +func NewTenantStore() *TenantStore { + return &TenantStore{ + byID: make(map[string]*Tenant), + byAccessKey: make(map[string]*Tenant), + } +} + +// Create — создаёт нового тенанта, генерирует ключи, сохраняет в оба индекса. +func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { + id, err := generateTenantID() + if err != nil { + return nil, fmt.Errorf("generate tenant id: %w", err) + } + accessKey, err := generateAccessKey() + if err != nil { + return nil, fmt.Errorf("generate access key: %w", err) + } + secretKey, err := generateSecretKey() + if err != nil { + return nil, fmt.Errorf("generate secret key: %w", err) + } + + t := &Tenant{ + ID: id, + Name: name, + AccessKey: accessKey, + SecretKey: secretKey, + MaxQueues: maxQueues, + CreatedAt: time.Now().UTC(), + Active: true, + } + + s.mu.Lock() + s.byID[t.ID] = t + s.byAccessKey[t.AccessKey] = t + s.mu.Unlock() + + // Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса + if data, err := json.Marshal(t); err == nil { + persistence.SaveTenantRaw(t.ID, data) + } + + return t, nil +} + +// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo). +// Используется только при инициализации демо-данных; не вызывается из user-facing API. +func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) { + s.mu.Lock() + if _, exists := s.byID[tenantID]; exists { + s.mu.Unlock() + return nil, fmt.Errorf("tenant with id %s already exists", tenantID) + } + if _, exists := s.byAccessKey[accessKey]; exists { + s.mu.Unlock() + return nil, fmt.Errorf("tenant with access key %s already exists", accessKey) + } + t := &Tenant{ + ID: tenantID, + Name: name, + AccessKey: accessKey, + SecretKey: secretKey, + MaxQueues: maxQueues, + CreatedAt: time.Now().UTC(), + Active: true, + } + s.byID[t.ID] = t + s.byAccessKey[t.AccessKey] = t + s.mu.Unlock() + + // Сохраняем в Redis асинхронно — seed-данные тоже персистируем + if data, err := json.Marshal(t); err == nil { + persistence.SaveTenantRaw(t.ID, data) + } + + return t, nil +} + +// GetByAccessKey — поиск тенанта по AccessKeyId (используется в auth middleware). +func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) { + s.mu.RLock() + t, ok := s.byAccessKey[accessKey] + s.mu.RUnlock() + return t, ok +} + +// GetByID — поиск тенанта по ID (используется в admin API). +func (s *TenantStore) GetByID(id string) (*Tenant, bool) { + s.mu.RLock() + t, ok := s.byID[id] + s.mu.RUnlock() + return t, ok +} + +// Delete — удаляет тенанта из ОБОИХ индексов. +// Ловушка #2: если удалить только из одного индекса — orphaned данные и memory leak. +func (s *TenantStore) Delete(id string) bool { + s.mu.Lock() + t, ok := s.byID[id] + if !ok { + s.mu.Unlock() + return false + } + delete(s.byID, t.ID) + delete(s.byAccessKey, t.AccessKey) + s.mu.Unlock() + + // Удаляем из Redis асинхронно + persistence.DeleteTenant(id) + return true +} + +// LoadTenant — добавляет тенанта в хранилище без сохранения в Redis. +// Используется ТОЛЬКО при старте сервиса для восстановления состояния из Redis. +// Не вызывать из user-facing кода — нет дедупликации ключей. +func (s *TenantStore) LoadTenant(t *Tenant) { + s.mu.Lock() + s.byID[t.ID] = t + s.byAccessKey[t.AccessKey] = t + s.mu.Unlock() +} + +// List — список всех тенантов (для admin GET /tenants). +func (s *TenantStore) List() []*Tenant { + s.mu.RLock() + result := make([]*Tenant, 0, len(s.byID)) + for _, t := range s.byID { + result = append(result, t) + } + s.mu.RUnlock() + return result +} + +// generateTenantID — генерирует уникальный ID тенанта в формате t-<12 hex bytes>. +func generateTenantID() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "t-" + hex.EncodeToString(b), nil +} + +// generateAccessKey — генерирует AccessKey в формате SSAK-<12 hex bytes>. +// SSAK = Shared SQS Access Key. Используем crypto/rand (ловушка #1: не math/rand). +func generateAccessKey() (string, error) { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "SSAK-" + hex.EncodeToString(b), nil +} + +// generateSecretKey — генерирует SecretKey как 64 hex символа (32 random bytes). +// Используем crypto/rand (ловушка #1). +func generateSecretKey() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/app/ui/embed.go b/app/ui/embed.go new file mode 100644 index 0000000..ebd318d --- /dev/null +++ b/app/ui/embed.go @@ -0,0 +1,17 @@ +// app/ui/embed.go +// Встраивание и раздача UI (SPA) для shared-sqs console +// Created: 2026-04-10 +package ui + +import ( + "embed" + "net/http" +) + +//go:embed index.html +var content embed.FS + +// Handler — возвращает http.Handler, раздающий встроенный index.html +func Handler() http.Handler { + return http.FileServer(http.FS(content)) +} diff --git a/app/ui/index.html b/app/ui/index.html new file mode 100644 index 0000000..d45228c --- /dev/null +++ b/app/ui/index.html @@ -0,0 +1,932 @@ + + + + + + +SQS Console — Nubes + + + + + + +
+ +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + diff --git a/app/utils/utils.go b/app/utils/utils.go new file mode 100644 index 0000000..43bf7d9 --- /dev/null +++ b/app/utils/utils.go @@ -0,0 +1,148 @@ +package utils + +import ( + "crypto/md5" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "io" + "net/http" + "net/url" + "sort" + "strings" + + "shared-sqs/app/models" + + "shared-sqs/app/interfaces" + + log "github.com/sirupsen/logrus" + + "github.com/gorilla/schema" +) + +var XmlDecoder *schema.Decoder +var REQUEST_TRANSFORMER = TransformRequest + +func init() { + XmlDecoder = schema.NewDecoder() + XmlDecoder.IgnoreUnknownKeys(true) +} + +func TransformRequest(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) { + switch req.Header.Get("Content-Type") { + case "application/x-amz-json-1.0": + //Read body data to parse json + decoder := json.NewDecoder(req.Body) + err := decoder.Decode(resultingStruct) + if err != nil { + if emptyRequestValid && err == io.EOF { + return true + } + log.Debugf("TransformRequest Failure - %s", err.Error()) + return false + } + default: + err := req.ParseForm() + if err != nil { + log.Debugf("TransformRequest Failure - %s", err.Error()) + return false + } + err = XmlDecoder.Decode(resultingStruct, req.PostForm) + if err != nil { + log.Debugf("TransformRequest Failure - %s", err.Error()) + return false + } + resultingStruct.SetAttributesFromForm(req.PostForm) + } + + return true +} + +func ExtractQueueAttributes(u url.Values) map[string]string { + attr := map[string]string{} + for i := 1; true; i++ { + nameKey := fmt.Sprintf("Attribute.%d.Name", i) + attrName := u.Get(nameKey) + if attrName == "" { + break + } + + valueKey := fmt.Sprintf("Attribute.%d.Value", i) + attrValue := u.Get(valueKey) + if attrValue != "" { + attr[attrName] = attrValue + } + } + return attr +} + +func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) { + var err interfaces.AbstractErrorResponse + if isSqs { + err = models.SqsErrors[errKey] + } else { + err = models.SnsErrors[errKey] + } + + respStruct := models.ErrorResponse{ + Result: err.Response(), + RequestId: "00000000-0000-0000-0000-000000000000", + } + return err.StatusCode(), respStruct +} + +func GetMD5Hash(text string) string { + hasher := md5.New() + hasher.Write([]byte(text)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +func HashAttributes(attributes map[string]models.MessageAttribute) string { + hasher := md5.New() + + keys := sortedKeys(attributes) + for _, key := range keys { + attributeValue := attributes[key] + + addStringToHash(hasher, key) + addStringToHash(hasher, attributeValue.DataType) + if attributeValue.DataType == "String" { + hasher.Write([]byte{1}) + addStringToHash(hasher, attributeValue.StringValue) + } else if attributeValue.DataType == "Binary" { + hasher.Write([]byte{2}) + bytes, _ := base64.StdEncoding.DecodeString(attributeValue.BinaryValue) + addBytesToHash(hasher, []byte(bytes)) + } + } + + return hex.EncodeToString(hasher.Sum(nil)) +} + +func sortedKeys(attributes map[string]models.MessageAttribute) []string { + var keys []string + for key := range attributes { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func addStringToHash(hasher hash.Hash, str string) { + bytes := []byte(str) + addBytesToHash(hasher, bytes) +} + +func addBytesToHash(hasher hash.Hash, arr []byte) { + bs := make([]byte, 4) + binary.BigEndian.PutUint32(bs, uint32(len(arr))) + hasher.Write(bs) + hasher.Write(arr) +} + +func HasFIFOQueueName(queueName string) bool { + return strings.HasSuffix(queueName, ".fifo") +} diff --git a/app/utils/utils_test.go b/app/utils/utils_test.go new file mode 100644 index 0000000..72e978d --- /dev/null +++ b/app/utils/utils_test.go @@ -0,0 +1,139 @@ +package utils + +import ( + "net/url" + "testing" + + "shared-sqs/app/models" + + "shared-sqs/app/test" + + "shared-sqs/app/fixtures" + "shared-sqs/app/mocks" + + "github.com/stretchr/testify/assert" +) + +func TestTransformRequest_success_json(t *testing.T) { + _, r := test.GenerateRequestInfo("POST", "url", fixtures.JSONRequestBody, true) + + mock := &mocks.MockRequestBody{} + + ok := TransformRequest(mock, r, false) + + assert.True(t, ok) + assert.Equal(t, "mock-value", mock.RequestFieldStr) + assert.False(t, mock.SetAttributesFromFormCalled) +} + +func TestTransformRequest_success_json_empty_request_accepted(t *testing.T) { + _, r := test.GenerateRequestInfo("POST", "url", nil, true) + + mock := &mocks.MockRequestBody{} + + ok := TransformRequest(mock, r, true) + + assert.True(t, ok) + //assert.Equal(t, "mock-value", mock.RequestFieldStr) + assert.False(t, mock.SetAttributesFromFormCalled) +} + +func TestTransformRequest_success_xml(t *testing.T) { + _, r := test.GenerateRequestInfo("POST", "url", nil, false) + form := url.Values{} + form.Add("Action", "CreateQueue") + form.Add("QueueName", "UnitTestQueue1") + form.Add("Attribute.1.Name", "VisibilityTimeout") + form.Add("Attribute.1.Value", "60") + form.Add("Attribute.2.Name", "MaximumMessageSize") + form.Add("Attribute.2.Value", "2048") + r.PostForm = form + + mock := &mocks.MockRequestBody{} + + ok := TransformRequest(mock, r, false) + + assert.True(t, ok) + assert.True(t, mock.SetAttributesFromFormCalled) + assert.Equal(t, []interface{}{form}, mock.SetAttributesFromFormCalledWith) +} + +func TestTransformRequest_error_invalid_request_body_json(t *testing.T) { + _, r := test.GenerateRequestInfo("POST", "url", "\"I-am-garbage", true) + + mock := &mocks.MockRequestBody{} + + ok := TransformRequest(mock, r, false) + + assert.False(t, ok) + assert.Equal(t, "", mock.RequestFieldStr) + assert.False(t, mock.SetAttributesFromFormCalled) +} + +func TestTransformRequest_error_failure_to_parse_form_xml(t *testing.T) { + _, r := test.GenerateRequestInfo("POST", "url", nil, false) + + mock := &mocks.MockRequestBody{} + + ok := TransformRequest(mock, r, false) + + assert.False(t, ok) + assert.False(t, mock.SetAttributesFromFormCalled) +} + +func TestTransformRequest_error_invalid_request_body_xml(t *testing.T) { + _, r := test.GenerateRequestInfo("POST", "url", nil, false) + + form := url.Values{} + form.Add("intField", "\"I-am-garbage") + r.PostForm = form + + mock := &mocks.MockRequestBody{} + + ok := TransformRequest(mock, r, false) + + assert.False(t, ok) + assert.False(t, mock.SetAttributesFromFormCalled) +} + +func TestExtractQueueAttributes_success(t *testing.T) { + u := url.Values{} + u.Add("Attribute.1.Name", "DelaySeconds") + u.Add("Attribute.1.Value", "20") + u.Add("Attribute.2.Name", "VisibilityTimeout") + u.Add("Attribute.2.Value", "30") + u.Add("Attribute.3.Name", "Policy") + + attr := ExtractQueueAttributes(u) + expected := map[string]string{ + "DelaySeconds": "20", + "VisibilityTimeout": "30", + } + + assert.Equal(t, expected, attr) +} + +func TestGetMD5Hash(t *testing.T) { + hash1 := GetMD5Hash("This is a test") + hash2 := GetMD5Hash("This is a test") + if hash1 != hash2 { + t.Errorf("hashs and hash2 should be the same, but were not") + } + + hash1 = GetMD5Hash("This is a test") + hash2 = GetMD5Hash("This is a tfst") + if hash1 == hash2 { + t.Errorf("hashs and hash2 are the same, but should not be") + } +} + +func TestSortedKeys(t *testing.T) { + attributes := map[string]models.MessageAttribute{ + "b": {}, + "a": {}, + } + + keys := sortedKeys(attributes) + assert.Equal(t, "a", keys[0]) + assert.Equal(t, "b", keys[1]) +} diff --git a/deployments/k8s/deployment.yaml b/deployments/k8s/deployment.yaml new file mode 100644 index 0000000..b390a1c --- /dev/null +++ b/deployments/k8s/deployment.yaml @@ -0,0 +1,75 @@ +# deployments/k8s/deployment.yaml +# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние) +# Updated: 2026-04-10 — добавлена Redis persistence, Recreate → RollingUpdate +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shared-sqs + namespace: shared-sqs + labels: + app: shared-sqs +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: shared-sqs + template: + metadata: + labels: + app: shared-sqs + spec: + containers: + - name: shared-sqs + image: naeel/shared-sqs:v0.1.14 + ports: + - containerPort: 4100 + name: http + env: + - name: SHARED_SQS_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: shared-sqs-admin + key: token + - name: SHARED_SQS_SEED_DEMO + value: "true" + - name: REDIS_ADDR + valueFrom: + secretKeyRef: + name: shared-sqs-redis + key: addr + - name: REDIS_USER + valueFrom: + secretKeyRef: + name: shared-sqs-redis + key: user + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: shared-sqs-redis + key: password + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 4100 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 4100 + initialDelaySeconds: 3 + periodSeconds: 5 + imagePullSecrets: + - name: sless-registry-auth diff --git a/deployments/k8s/ingress.yaml b/deployments/k8s/ingress.yaml new file mode 100644 index 0000000..d99a082 --- /dev/null +++ b/deployments/k8s/ingress.yaml @@ -0,0 +1,30 @@ +# deployments/k8s/ingress.yaml +# Ingress для shared-sqs на домене qu.kube5s.ru +# Created: 2026-04-09, Updated: 2026-04-10 +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: shared-sqs-ingress + namespace: shared-sqs + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-body-size: 10m + nginx.ingress.kubernetes.io/proxy-read-timeout: "30" + nginx.ingress.kubernetes.io/proxy-send-timeout: "30" +spec: + ingressClassName: nginx + rules: + - host: qu.kube5s.ru + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: shared-sqs + port: + number: 4100 + tls: + - hosts: + - qu.kube5s.ru + secretName: shared-sqs-tls diff --git a/deployments/k8s/namespace.yaml b/deployments/k8s/namespace.yaml new file mode 100644 index 0000000..b278c9e --- /dev/null +++ b/deployments/k8s/namespace.yaml @@ -0,0 +1,9 @@ +# deployments/k8s/namespace.yaml +# Namespace для shared-sqs сервиса +# Created: 2026-04-09 +apiVersion: v1 +kind: Namespace +metadata: + name: shared-sqs + labels: + app: shared-sqs diff --git a/deployments/k8s/redis.yaml b/deployments/k8s/redis.yaml new file mode 100644 index 0000000..8ccb79d --- /dev/null +++ b/deployments/k8s/redis.yaml @@ -0,0 +1,80 @@ +# 2026-04-10 — Redis для shared-sqs в кластере naeel-test-3 +# Single-node Redis с PVC для persistence состояния shared-sqs между рестартами. +# Namespace: shared-sqs. Пароль совпадает с managed Redis из deck.ngcloud.ru. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shared-sqs-redis-pvc + namespace: shared-sqs +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shared-sqs-redis + namespace: shared-sqs + labels: + app: shared-sqs-redis +spec: + replicas: 1 + selector: + matchLabels: + app: shared-sqs-redis + strategy: + type: Recreate + template: + metadata: + labels: + app: shared-sqs-redis + spec: + containers: + - name: redis + image: redis:7.2-alpine + command: + - redis-server + - --requirepass + - $(REDIS_PASSWORD) + - --appendonly + - "yes" + - --save + - "60 1" + ports: + - containerPort: 6379 + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: shared-sqs-redis + key: password + volumeMounts: + - name: redis-data + mountPath: /data + resources: + requests: + memory: 128Mi + cpu: 50m + limits: + memory: 256Mi + cpu: 200m + volumes: + - name: redis-data + persistentVolumeClaim: + claimName: shared-sqs-redis-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: shared-sqs-redis + namespace: shared-sqs +spec: + selector: + app: shared-sqs-redis + ports: + - port: 6379 + targetPort: 6379 diff --git a/deployments/k8s/secret.yaml b/deployments/k8s/secret.yaml new file mode 100644 index 0000000..8d78fc4 --- /dev/null +++ b/deployments/k8s/secret.yaml @@ -0,0 +1,26 @@ +# deployments/k8s/secret.yaml +# Секреты для shared-sqs: admin token + Redis credentials +# Created: 2026-04-09 +# Updated: 2026-04-10 — добавлен shared-sqs-redis secret +# ВНИМАНИЕ: заполнить реальными значениями перед деплоем +# kubectl create secret generic shared-sqs-admin --from-literal=token=YOUR_TOKEN -n shared-sqs +# kubectl create secret generic shared-sqs-redis --from-literal=addr=HOST:6379 --from-literal=user=default --from-literal=password=PASS -n shared-sqs +apiVersion: v1 +kind: Secret +metadata: + name: shared-sqs-admin + namespace: shared-sqs +type: Opaque +stringData: + token: "REPLACE_WITH_REAL_TOKEN" +--- +apiVersion: v1 +kind: Secret +metadata: + name: shared-sqs-redis + namespace: shared-sqs +type: Opaque +stringData: + addr: "REPLACE_WITH_REDIS_ADDR" + user: "default" + password: "REPLACE_WITH_REDIS_PASSWORD" diff --git a/deployments/k8s/service.yaml b/deployments/k8s/service.yaml new file mode 100644 index 0000000..7041cb0 --- /dev/null +++ b/deployments/k8s/service.yaml @@ -0,0 +1,19 @@ +# deployments/k8s/service.yaml +# Service для shared-sqs +# Created: 2026-04-09 +apiVersion: v1 +kind: Service +metadata: + name: shared-sqs + namespace: shared-sqs + labels: + app: shared-sqs +spec: + selector: + app: shared-sqs + ports: + - name: http + port: 4100 + targetPort: 4100 + protocol: TCP + type: ClusterIP diff --git a/doc/byoc-credentials.md b/doc/byoc-credentials.md new file mode 100644 index 0000000..9778868 --- /dev/null +++ b/doc/byoc-credentials.md @@ -0,0 +1,115 @@ +# BYOC — Bring Your Own Credentials + +**Дата**: 2026-04-09 +**Статус**: частично реализовано (backend), UI/API — TODO + +--- + +## Суть + +Возможность создать тенанта с **произвольными** Access Key и Secret Key вместо авто-генерируемых. + +Нужно для: +- демо-стенда с фиксированными credentials (README всегда актуален) +- интеграционных тестов с предсказуемыми значениями +- миграции с другого SQS-совместимого сервиса (сохранение существующих ключей) + +--- + +## Что уже сделано + +### `app/tenant/tenant_store.go` — `CreateFixed` + +```go +func (s *TenantStore) CreateFixed( + name string, + maxQueues int, + tenantID string, + accessKey string, + secretKey string, +) (*Tenant, error) +``` + +Создаёт тенанта с заранее известными credentials. +Проверяет уникальность и `tenantID`, и `accessKey` — конфликт возвращает ошибку. + +### `app/cmd/seed.go` — демо-тенант + +Использует `CreateFixed` при `SHARED_SQS_SEED_DEMO=true`: + +``` +tenantID = "t-demo-shared-sqs-ngcloud" +accessKey = "SSAK-demo-shared-sqs" +secretKey = "demo-secret-key-shared-sqs-ngcloud-2026" +``` + +--- + +## Что нужно сделать (TODO) + +### Admin API — `POST /admin/tenants` + +Добавить в `createTenantRequest` два опциональных поля: + +```go +// app/admin/admin.go +type createTenantRequest struct { + Name string `json:"name"` + MaxQueues int `json:"max_queues"` + AccessKey string `json:"access_key,omitempty"` // TODO: BYOC + SecretKey string `json:"secret_key,omitempty"` // TODO: BYOC +} +``` + +Логика в `createTenant` handler: + +```go +var t *tenant.Tenant +var err error +if req.AccessKey != "" || req.SecretKey != "" { + // BYOC: оба поля обязательны + if req.AccessKey == "" || req.SecretKey == "" { + jsonErr(w, http.StatusBadRequest, "both access_key and secret_key required when specifying custom credentials") + return + } + // Минимальная длина — защита от случайно слабых ключей + if len(req.AccessKey) < 8 || len(req.SecretKey) < 16 { + jsonErr(w, http.StatusBadRequest, "access_key min 8 chars, secret_key min 16 chars") + return + } + t, err = h.store.CreateFixed(req.Name, req.MaxQueues, generateTenantID(), req.AccessKey, req.SecretKey) +} else { + t, err = h.store.Create(req.Name, req.MaxQueues) +} +``` + +> `generateTenantID()` — уже есть в tenant_store.go, нужно экспортировать или вынести. + +### UI — Web форма создания тенанта + +- Добавить в модальное окно "Создать тенанта" два опциональных поля: Access Key, Secret Key +- Показывать только если нажата кнопка "задать свои credentials" +- Валидация на клиенте: оба поля заполнены, мин. длина + +--- + +## Безопасность + +- BYOC-credentials **не дают доступа к admin API** — admin защищён отдельным Bearer токеном +- Тенант видит **только свои очереди** — изоляция по AccessKey в auth middleware +- Слабые ключи отклоняются на уровне API (минимальная длина) +- Credentials передаются только по HTTPS + +--- + +## Демо-credentials (открыты намеренно) + +| | | +|---|---| +| **Access Key** | `SSAK-demo-shared-sqs` | +| **Secret Key** | `demo-secret-key-shared-sqs-ngcloud-2026` | +| **Tenant ID** | `t-demo-shared-sqs-ngcloud` | +| **Лимит очередей** | 10 | + +Эти credentials жёстко прописаны в `app/cmd/seed.go`. +Тенант создаётся только если `SHARED_SQS_SEED_DEMO=true` (env var в deployment.yaml). diff --git a/doc/progress.md b/doc/progress.md new file mode 100644 index 0000000..aefccd5 --- /dev/null +++ b/doc/progress.md @@ -0,0 +1,79 @@ +# SQS-service Progress + +## Версага v0.1.x + +### v0.1.14 (2026-04-10) ✅ +- ✅ Фикс критического дедлока в `create_queue.go` (deadlock после первого CreateQueue) +- ✅ Фикс UI: `m.sent` → `m.sent_at` (даты сообщений всегда показывали dash) +- ✅ Redis write-through persistence запущен +- ✅ TLS Ingress: `qu.kube5s.ru` → `185.247.187.151` +- ✅ Нагрузочный тест Harbor: 4757 запросов, 99% успех, p95=332ms +- **Status: Production-ready для демо, готов к выводу за скобки в отдельный репо** + +## Next Steps + +- [ ] Migration guide: как обновить сервис на v0.1.14 +- [ ] Load testing shared-sqs (текущая реализация имеет глобальный мьютекс) +- [ ] DLQ (Dead Letter Queue) поддержка +- [ ] Long Polling оптимизация +- [ ] Rate limiting per tenant + +## Known Limitations + +1. **Глобальный мьютекс** — SyncQueues.Lock() на весь сервис. Performance bottleneck. +2. **Нет DLQ** — failed messages теряются +3. **Long Polling naïve** — polling каждую 100ms вместо push-based +4. **Нет rate limiting** — один тенант может забить всех +5. **Single pod** — нет горизонтального масштабирования +6. **Нет метрик** — Prometheus экспортер отсутствует + +## Architecture + +``` +SQS-service/ +├── app/ +│ ├── cmd/main.go — точка входа +│ ├── models/model.go — Queue, Message, Tenant структуры +│ ├── gosqs/*.go — реализация SQS API операций +│ ├── admin/admin.go — Admin API (Create/List/Get Tenant) +│ ├── tenant/tenant.go — Multi-tenant изоляция +│ ├── auth/auth.go — AWS Signature V4 верификация +│ ├── persistence/redis.go — Redis adapter для persistence +│ ├── router/router.go — HTTP маршруты +│ └── ui/index.html — Web Console +├── deployments/k8s/ — Kubernetes манифесты +├── tests/ — E2E тесты +└── doc/ — Документация +``` + +## Technology Stack + +- **Language**: Go 1.22 +- **HTTP Server**: Go std net/http +- **Auth**: AWS Signature V4 +- **Storage**: Redis (write-through) +- **Container**: Docker +- **Orchestration**: Kubernetes +- **DNS**: Ingress с TLS + +## Demo Credentials (read-only для тестирования) + +``` +Access Key: SSAK-demo-shared-sqs +Secret Key: demo-secret-key-shared-sqs-ngcloud-2026 +Endpoint: https://qu.kube5s.ru +``` + +## Deployment + +```bash +# Kubernetes +kubectl apply -k deployments/k8s/ + +# Docker (local) +docker run -p 9090:9090 naeel/shared-sqs:v0.1.14 +``` + +--- + +*Last updated: 2026-04-10* diff --git a/doc/thinking/2026-04-10.md b/doc/thinking/2026-04-10.md new file mode 100644 index 0000000..8a4d2e1 --- /dev/null +++ b/doc/thinking/2026-04-10.md @@ -0,0 +1,90 @@ +# Thinking Log — 2026-04-10 +# Agent: GitHub Copilot (Claude Sonnet 4.6) + +--- + +## Сессия 1 + +### Задача +1. Задокументировать итоги работы над shared-sqs (v0.1.11–v0.1.14) +2. Закоммитить и запушить все изменения +3. Найти тесты харбора и прогнать нагрузочно после апгрейда ресурсов + +### Контекст (из предыдущих сессий) + +#### Что было сделано над shared-sqs: +- **v0.1.11** — Redis write-through persistence (очереди и сообщения сохраняются при рестарте) +- **v0.1.12** — промежуточный билд +- **v0.1.13** — КРИТИЧЕСКИЙ фикс дедлока в `create_queue.go`: `SyncQueues.Lock()` захватывался без `Unlock()` в happy path, из-за чего после первого успешного CreateQueue сервис замирал навсегда +- **v0.1.14** — фикс UI: JS читал поле `m.sent`, API отдавал `m.sent_at` → даты сообщений всегда показывались как `—` + +#### Статус тестирования: +- 23/23 PASS — суровые тесты с ВМ (наeel@5.172.178.213) +- 6/6 PASS — quick_test.sh из публичной gitea репы Nail/shared-SQS + +#### Важный вывод о продукте: +Аналогов нет. GitHub search `multi-tenant sqs compatible` → 0 результатов. +Ближайшее: ElasticMQ (single-tenant, local dev only) и GoAws (то же самое). +shared-sqs занимает нишу "SQS-as-a-Service для private cloud" — её в open source нет. + +### Изменённые файлы в текущем коммите: +- `app/gosqs/create_queue.go` — фикс дедлока (Unlock перед return в happy path) +- `app/gosqs/delete_queue.go` — рефакторинг под новую модель с Redis +- `app/gosqs/purge_queue.go` — то же +- `app/gosqs/send_message.go` — то же +- `app/gosqs/set_queue_attributes.go` — то же +- `app/router/router.go` — маршруты +- `app/ui/index.html` — фикс `m.sent` → `m.sent_at` +- `deployments/k8s/deployment.yaml` — образ v0.1.14 +- `deployments/k8s/ingress.yaml` — TLS endpoint qu.kube5s.ru +- `deployments/k8s/redis.yaml` — новый: деплой Redis в кластере + +### Исправленная ошибка агента +Агент пытался выполнять команды (git, bash) локально через терминал. +**ПРАВИЛО**: `/home/naeel/remote_dev/sless` — это sshfs-mount. +Все файлы физически на ВМ `naeel@5.172.178.213:/home/naeel/terra/sless`. +Все команды — ТОЛЬКО через SSH на ВМ. + +### План на сессию +1. ✅ Написать thinking log +2. Закоммитить изменения shared-sqs на ВМ +3. Найти `test_harbor_load.sh` в корне проекта, изучить +4. Прогнать нагрузочный тест харбора с ВМ, сравнить с предыдущими результатами + +--- + +## Результаты нагрузочного теста Harbor (2026-04-10, после апгрейда ресурсов) + +Команда: `cd /home/naeel/terra/sless && bash test_harbor_load.sh` +Параметры: 60 сек, 10 воркеров, таймаут 8 сек/запрос + +``` +Total requests : 4757 +Success (2xx) : 4756 (99%) +Timeouts : 1 (0%) +Other errors : 0 +Latency (ok) : min=0.023s median=0.044s p95=0.332s max=3.920s + +--- By protocol --- + h1: ok=2347 fail=1 p95=0.342s + h2: ok=2409 fail=0 p95=0.314s + +--- By URL --- + /api/v2.0/ping : ok=2660 timeout=1 + /api/v2.0/projects: ok=1476 timeout=0 + /v2/ : ok=620 timeout=0 +``` + +### Сравнение с историческим состоянием + +**До апгрейда** (из doc/log.md, 2026-03-08): +> Harbor нестабилен: `/v2/` периодически зависает на 10+ секунд или возвращает 504. Kaniko не мог завершить push образа. + +**После апгрейда памяти и диска:** +- 1 таймаут из 4757 запросов (0%) — единичный инцидент на `/ping` +- Медиана 44ms — отличная latency +- p95 = 332ms — в норме +- max = 3.9s — единственный выброс (тот самый таймаут) +- H2 и H1 работают одинаково хорошо + +**Вывод: харбор стабилен.** Апгрейд ресурсов полностью устранил проблему с зависаниями. Harbor пригоден для использования как registry для kaniko push. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..811044c --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module shared-sqs + +go 1.22 + +require ( + github.com/ghodss/yaml v1.0.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/mux v1.8.0 + github.com/gorilla/schema v1.4.1 + github.com/mitchellh/copystructure v1.2.0 + github.com/sirupsen/logrus v1.9.0 + github.com/stretchr/testify v1.7.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/sys v0.13.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.0 // indirect +) + +retract ( + v1.1.2 + v1.1.1 + v1.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..35a21dd --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +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/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +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/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/hardcore_test.sh b/tests/hardcore_test.sh new file mode 100644 index 0000000..f832dff --- /dev/null +++ b/tests/hardcore_test.sh @@ -0,0 +1,1080 @@ +#!/bin/bash +# hardcore_test.sh — расширенный тест shared-sqs +# Изменено: 2026-04-09 +# Покрывает: Admin API, AWS CLI CRUD, awscurl CRUD, кросс-доставка, +# изоляция тенантов, невалидный ввод, спецсимволы, +# visibility timeout, batch, лимиты очередей, cleanup тенанта, +# UI API (create/delete queue, send/peek/purge messages, изоляция). +# Запуск: BASE_URL=https://qu.kube5s.ru ADMIN_TOKEN=... bash tests/hardcore_test.sh + +set -uo pipefail + +# ═══════════════════════════════════════════ +# КОНФИГУРАЦИЯ +# ═══════════════════════════════════════════ +BASE_URL="${BASE_URL:-https://qu.kube5s.ru}" +ADMIN_TOKEN="${ADMIN_TOKEN:-sqs-admin-7a7d8bd0c060a75c198d48680f34077a}" +AWSCURL="${AWSCURL:-/home/naeel/.local/bin/awscurl}" +TS=$(date +%s) + +PASS=0 +FAIL=0 +TOTAL=0 + +# ═══════════════════════════════════════════ +# ХЕЛПЕРЫ +# ═══════════════════════════════════════════ + +# check — ожидается что вывод содержит паттерн +check() { + local name="$1" output="$2" pattern="$3" + TOTAL=$((TOTAL + 1)) + if echo "$output" | grep -qE "$pattern"; then + PASS=$((PASS + 1)) + echo " ✅ $name" + else + FAIL=$((FAIL + 1)) + echo " ❌ $name" + echo " ожидалось: $pattern" + echo " получено: $(echo "$output" | head -10)" + fi +} + +# check_not — ожидается что вывод НЕ содержит паттерн +check_not() { + local name="$1" output="$2" pattern="$3" + TOTAL=$((TOTAL + 1)) + if echo "$output" | grep -qE "$pattern"; then + FAIL=$((FAIL + 1)) + echo " ❌ $name (содержит '$pattern')" + echo " получено: $(echo "$output" | head -10)" + else + PASS=$((PASS + 1)) + echo " ✅ $name" + fi +} + +# check_eq — проверка числа +check_eq() { + local name="$1" got="$2" want="$3" + TOTAL=$((TOTAL + 1)) + if [ "$got" -eq "$want" ]; then + PASS=$((PASS + 1)) + echo " ✅ $name ($got)" + else + FAIL=$((FAIL + 1)) + echo " ❌ $name: ожидалось=$want, получено=$got" + fi +} + +# sqs — AWS CLI враппер с credentials тенанта +sqs() { + local ak="$1" sk="$2" + shift 2 + AWS_ACCESS_KEY_ID="$ak" \ + AWS_SECRET_ACCESS_KEY="$sk" \ + AWS_DEFAULT_REGION="us-east-1" \ + aws --endpoint-url "$BASE_URL" --output json sqs "$@" 2>&1 +} + +# acurl — awscurl враппер для SQS Query Protocol +# $1=ak, $2=sk, остальное — form-encoded body (Action=...&...) +acurl() { + local ak="$1" sk="$2" + shift 2 + "$AWSCURL" \ + --region us-east-1 --service sqs \ + --access_key "$ak" --secret_key "$sk" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "$*" \ + "$BASE_URL/" 2>&1 +} + +# admin_api — Admin REST API запрос через curl +# $1=method, $2=path, $3=body (опц.) +admin_api() { + local method="$1" path="$2" body="${3:-}" + if [ -n "$body" ]; then + curl -s --max-time 20 -X "$method" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$body" \ + "${BASE_URL}${path}" 2>&1 + else + curl -s --max-time 20 -X "$method" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + "${BASE_URL}${path}" 2>&1 + fi +} + +# qurl — строит HTTPS Queue URL из TenantID + имени очереди +qurl() { + echo "${BASE_URL}/${1}/${2}" +} + +# xml_field — извлекает значение из XML тега +xml_field() { + echo "$1" | grep -oP "(?<=<${2}>)[^<]+" | head -1 +} + +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ shared-sqs Hardcore Test — $(date '+%Y-%m-%d %H:%M:%S') ║" +echo "║ Endpoint: ${BASE_URL} ║" +echo "║ Методы: AWS CLI + awscurl + curl (Admin) ║" +echo "╚══════════════════════════════════════════════════════════╝" +echo "" + +# Проверяем зависимости +for dep in aws curl jq "$AWSCURL"; do + if ! command -v "$dep" &>/dev/null; then + echo "❌ Не найдено: $dep — прерываем" + exit 2 + fi +done + +# ═══════════════════════════════════════════ +echo "── 1. Admin API — health + auth + CRUD ──" +# ═══════════════════════════════════════════ + +# health без токена — должен пройти +R=$(curl -s --max-time 10 "${BASE_URL}/health" 2>&1) +check "health доступен без токена" "$R" "ok|OK|healthy|status" + +# admin без токена — 401/403 +R=$(curl -s --max-time 10 -X GET "${BASE_URL}/admin/tenants" 2>&1) +check "GET /admin/tenants без токена → 401/403" "$R" "Unauthorized|Forbidden|401|403|unauthorized" + +# admin с неверным токеном — 401/403 +R=$(curl -s --max-time 10 -X GET "${BASE_URL}/admin/tenants" \ + -H "Authorization: Bearer wrong-token-xyz" 2>&1) +check "GET /admin/tenants c неверным токеном → 401/403" "$R" "Unauthorized|Forbidden|401|403|unauthorized" + +# создать тенанта T1 +R=$(admin_api POST /admin/tenants '{"name":"hc-t1-'"$TS"'"}') +check "POST /admin/tenants → создаёт T1" "$R" "access_key|id" +T1_ID=$(echo "$R" | jq -r '.id' 2>/dev/null) +T1_AK=$(echo "$R" | jq -r '.access_key' 2>/dev/null) +T1_SK=$(echo "$R" | jq -r '.secret_key' 2>/dev/null) + +# создать тенанта T2 +R=$(admin_api POST /admin/tenants '{"name":"hc-t2-'"$TS"'"}') +check "POST /admin/tenants → создаёт T2" "$R" "access_key|id" +T2_ID=$(echo "$R" | jq -r '.id' 2>/dev/null) +T2_AK=$(echo "$R" | jq -r '.access_key' 2>/dev/null) +T2_SK=$(echo "$R" | jq -r '.secret_key' 2>/dev/null) + +# список тенантов — видим обоих +R=$(admin_api GET /admin/tenants) +check "GET /admin/tenants — T1 виден" "$R" "hc-t1-$TS" +check "GET /admin/tenants — T2 виден" "$R" "hc-t2-$TS" + +# GET конкретного тенанта +R=$(admin_api GET "/admin/tenants/${T1_ID}") +check "GET /admin/tenants/{id} — возвращает T1" "$R" "$T1_AK" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 2. SQS CRUD через AWS CLI (T1) ──" +# ═══════════════════════════════════════════ + +CLI_Q="hc-cli-${TS}" + +# создание +R=$(sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$CLI_Q") +check "CLI: create-queue" "$R" "QueueUrl" + +# список — очередь видна +R=$(sqs "$T1_AK" "$T1_SK" list-queues) +check "CLI: list-queues содержит очередь" "$R" "$CLI_Q" + +# get-queue-url +R=$(sqs "$T1_AK" "$T1_SK" get-queue-url --queue-name "$CLI_Q") +check "CLI: get-queue-url" "$R" "QueueUrl" + +# send-message +CQURL=$(qurl "$T1_ID" "$CLI_Q") +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$CQURL" --message-body "cli-hello-${TS}") +check "CLI: send-message → MessageId" "$R" "MessageId" +check "CLI: send-message → MD5" "$R" "MD5OfMessageBody" + +# receive-message +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$CQURL") +check "CLI: receive-message → тело" "$R" "cli-hello-${TS}" +RECEIPT=$(echo "$R" | jq -r '.Messages[0].ReceiptHandle' 2>/dev/null) + +# delete-message — немедленно после receive, без промежуточных операций +if [ -n "$RECEIPT" ] && [ "$RECEIPT" != "null" ]; then + R=$(sqs "$T1_AK" "$T1_SK" delete-message --queue-url "$CQURL" --receipt-handle "$RECEIPT" 2>&1 && echo "OK" || echo "FAIL") + check "CLI: delete-message" "$R" "OK" +else + TOTAL=$((TOTAL + 1)); FAIL=$((FAIL + 1)) + echo " ❌ CLI: delete-message (нет ReceiptHandle)" +fi + +# get-queue-attributes +R=$(sqs "$T1_AK" "$T1_SK" get-queue-attributes --queue-url "$CQURL" --attribute-names All) +check "CLI: get-queue-attributes" "$R" "Attributes|ApproximateNumberOfMessages" + +# set-queue-attributes visibility +R=$(sqs "$T1_AK" "$T1_SK" set-queue-attributes --queue-url "$CQURL" --attributes '{"VisibilityTimeout":"45"}' 2>&1 && echo "OK" || echo "FAIL") +check "CLI: set-queue-attributes VisibilityTimeout" "$R" "OK" + +# purge +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$CQURL" --message-body "purge-me" >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" purge-queue --queue-url "$CQURL" 2>&1 && echo "OK" || echo "FAIL") +check "CLI: purge-queue" "$R" "OK" + +# delete-queue +R=$(sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$CQURL" 2>&1 && echo "OK" || echo "FAIL") +check "CLI: delete-queue" "$R" "OK" + +# очередь исчезла из списка +R=$(sqs "$T1_AK" "$T1_SK" list-queues 2>&1) +check_not "CLI: deleted queue не в списке" "$R" "$CLI_Q" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 3. SQS CRUD через awscurl (T2) ──" +# ═══════════════════════════════════════════ + +AC_Q="hc-ac-${TS}" +AC_QURL="${BASE_URL}/${T2_ID}/${AC_Q}" +VER="Version=2012-11-05" + +# create-queue +R=$(acurl "$T2_AK" "$T2_SK" "Action=CreateQueue&QueueName=${AC_Q}&${VER}") +check "awscurl: CreateQueue" "$R" "CreateQueueResult" + +# send-message +R=$(acurl "$T2_AK" "$T2_SK" "Action=SendMessage&QueueUrl=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${AC_QURL}'))")&MessageBody=acurl-hello-${TS}&${VER}") +check "awscurl: SendMessage → MessageId" "$R" "MessageId" +check "awscurl: SendMessage → MD5" "$R" "MD5OfMessageBody" + +# receive-message +R=$(acurl "$T2_AK" "$T2_SK" "Action=ReceiveMessage&QueueUrl=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${AC_QURL}'))")&${VER}") +check "awscurl: ReceiveMessage → тело" "$R" "acurl-hello-${TS}" +AC_RECEIPT=$(xml_field "$R" "ReceiptHandle") + +# delete-message +if [ -n "$AC_RECEIPT" ]; then + ENC_RECEIPT=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$AC_RECEIPT") + R=$(acurl "$T2_AK" "$T2_SK" "Action=DeleteMessage&QueueUrl=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${AC_QURL}'))")&ReceiptHandle=${ENC_RECEIPT}&${VER}") + check "awscurl: DeleteMessage" "$R" "DeleteMessageResponse" +else + TOTAL=$((TOTAL + 1)); FAIL=$((FAIL + 1)) + echo " ❌ awscurl: DeleteMessage (нет ReceiptHandle для теста)" +fi + +# purge +acurl "$T2_AK" "$T2_SK" "Action=SendMessage&QueueUrl=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${AC_QURL}'))")&MessageBody=purge-me&${VER}" >/dev/null 2>&1 || true +R=$(acurl "$T2_AK" "$T2_SK" "Action=PurgeQueue&QueueUrl=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${AC_QURL}'))")&${VER}") +check "awscurl: PurgeQueue" "$R" "PurgeQueueResponse" + +# delete-queue +R=$(acurl "$T2_AK" "$T2_SK" "Action=DeleteQueue&QueueUrl=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${AC_QURL}'))")&${VER}") +check "awscurl: DeleteQueue" "$R" "DeleteQueueResponse" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 4. Кросс-доставка: CLI ↔ awscurl ──" +# ═══════════════════════════════════════════ + +XQ="hc-cross-${TS}" +XQURL=$(qurl "$T1_ID" "$XQ") +ENC_XQURL=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${XQURL}'))") + +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$XQ" >/dev/null 2>&1 || true + +# CLI отправляет → awscurl получает +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$XQURL" --message-body "from-cli-${TS}" >/dev/null 2>&1 || true +R=$(acurl "$T1_AK" "$T1_SK" "Action=ReceiveMessage&QueueUrl=${ENC_XQURL}&MaxNumberOfMessages=1&${VER}") +check "CLI→awscurl: сообщение доставлено" "$R" "from-cli-${TS}" + +# Очищаем очередь перед тестом в обратную сторону +acurl "$T1_AK" "$T1_SK" "Action=PurgeQueue&QueueUrl=${ENC_XQURL}&${VER}" >/dev/null 2>&1 || true +sleep 1 + +# awscurl отправляет → CLI получает +acurl "$T1_AK" "$T1_SK" "Action=SendMessage&QueueUrl=${ENC_XQURL}&MessageBody=from-acurl-${TS}&${VER}" >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$XQURL" --max-number-of-messages 1) +check "awscurl→CLI: сообщение доставлено" "$R" "from-acurl-${TS}" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$XQURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 5. Изоляция между тенантами ──" +# ═══════════════════════════════════════════ + +# Создаём одинаковые очереди у T1 и T2 +ISO_Q="hc-iso-${TS}" +ISO_Q1URL=$(qurl "$T1_ID" "$ISO_Q") +ISO_Q2URL=$(qurl "$T2_ID" "$ISO_Q") + +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$ISO_Q" >/dev/null 2>&1 || true +sqs "$T2_AK" "$T2_SK" create-queue --queue-name "$ISO_Q" >/dev/null 2>&1 || true + +# T1 отправляет приватное сообщение +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$ISO_Q1URL" --message-body "private-t1-${TS}" >/dev/null 2>&1 || true +# T2 отправляет в свою очередь с тем же именем +sqs "$T2_AK" "$T2_SK" send-message --queue-url "$ISO_Q2URL" --message-body "private-t2-${TS}" >/dev/null 2>&1 || true + +# T1 получает только своё +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$ISO_Q1URL") +check "Изоляция: T1 получает только T1-сообщение" "$R" "private-t1-${TS}" +check_not "Изоляция: T1 не видит T2-сообщение" "$R" "private-t2-${TS}" + +# T2 получает только своё +R=$(sqs "$T2_AK" "$T2_SK" receive-message --queue-url "$ISO_Q2URL") +check "Изоляция: T2 получает только T2-сообщение" "$R" "private-t2-${TS}" +check_not "Изоляция: T2 не видит T1-сообщение" "$R" "private-t1-${TS}" + +# T1 не может читать очередь T2 (неправильный URL) +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$ISO_Q2URL" 2>&1 || true) +check_not "T1 не может читать очередь T2" "$R" "private-t2-${TS}" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$ISO_Q1URL" >/dev/null 2>&1 || true +sqs "$T2_AK" "$T2_SK" delete-queue --queue-url "$ISO_Q2URL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 6. Невалидные credentials ──" +# ═══════════════════════════════════════════ + +FAKE_AK="SSAK-ffffffffffffffff" +FAKE_SK="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +BAD_Q="hc-bad-${TS}" + +# Неверный ключ — создание провалится +R=$(sqs "$FAKE_AK" "$FAKE_SK" create-queue --queue-name "$BAD_Q" 2>&1 || true) +check "Неверный cred: create блокируется" "$R" "InvalidClientTokenId|Unauthorized|not authorized|could not be authenticated|AuthFailure|403|401" + +# Неверный ключ — отправка провалится +R=$(sqs "$FAKE_AK" "$FAKE_SK" send-message --queue-url "$(qurl 'fake-tenant' $BAD_Q)" --message-body "x" 2>&1 || true) +check "Неверный cred: send блокируется" "$R" "InvalidClientTokenId|Unauthorized|not authorized|could not be authenticated|AuthFailure|403|401" + +# Несуществующая очередь у реального тенанта +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$(qurl "$T1_ID" "no-such-queue-${TS}")" --message-body "x" 2>&1 || true) +check "Несуществующая очередь" "$R" "NonExistentQueue|AWS.SimpleQueueService.NonExistentQueue|does not exist" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 7. Невалидный Action через awscurl ──" +# ═══════════════════════════════════════════ + +# Неизвестный Action +R=$(acurl "$T1_AK" "$T1_SK" "Action=DoWeirdThing&${VER}") +check "awscurl: неизвестный Action" "$R" "InvalidAction|UnknownOperation|Error|Bad Request" + +# Отсутствует QueueUrl при SendMessage +R=$(acurl "$T1_AK" "$T1_SK" "Action=SendMessage&MessageBody=x&${VER}") +check "awscurl: SendMessage без QueueUrl" "$R" "QueueUrl|missing|Error|MissingParameter" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 8. Спецсимволы в сообщениях ──" +# ═══════════════════════════════════════════ + +SPEC_Q="hc-spec-${TS}" +SPEC_QURL=$(qurl "$T1_ID" "$SPEC_Q") +ENC_SPEC_QURL=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${SPEC_QURL}'))") + +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$SPEC_Q" >/dev/null 2>&1 || true + +# JSON строка +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$SPEC_QURL" \ + --message-body '{"event":"test","count":42,"ok":true}' >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$SPEC_QURL") +check "JSON в теле сообщения" "$R" "event" + +# XML спецсимволы через CLI — < > & "' +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$SPEC_QURL" \ + --message-body 'A&B' >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$SPEC_QURL") +check "XML спецсимволы в теле" "$R" "root|attr|A" + +# Unicode + emoji через CLI +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$SPEC_QURL" \ + --message-body 'Unicode test Privet mir' >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$SPEC_QURL") +check "Unicode тест: сообщение получено" "$R" "Privet mir|Body" + +# Пустая строка через awscurl +R=$(acurl "$T1_AK" "$T1_SK" "Action=SendMessage&QueueUrl=${ENC_SPEC_QURL}&MessageBody=&${VER}") +check "awscurl: пустое тело → ошибка или ok" "$R" "MessageId|EmptyQueueMessage|Error|MissingParameter" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$SPEC_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 9. Visibility Timeout ──" +# ═══════════════════════════════════════════ + +VIS_Q="hc-vis-${TS}" +VIS_QURL=$(qurl "$T1_ID" "$VIS_Q") + +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$VIS_Q" >/dev/null 2>&1 || true +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$VIS_QURL" --message-body "vis-msg-${TS}" >/dev/null 2>&1 || true + +# Получаем с visibility=2 секунды +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$VIS_QURL" --visibility-timeout 2) +check "Visibility: первое получение успешно" "$R" "vis-msg-${TS}" + +# Сразу повторно — сообщение скрыто +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$VIS_QURL") +check_not "Visibility: скрыто сразу после receive" "$R" "vis-msg-${TS}" + +# Ждём истечения visibility window +sleep 3 + +# После паузы — снова доступно (получаем с VT=30 и сохраняем receipt для CMV) +R3=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$VIS_QURL" --visibility-timeout 30) +check "Visibility: сообщение снова доступно" "$R3" "vis-msg-${TS}" + +# ChangeMessageVisibility — продлить время скрытия ДО истечения +VIS_RECEIPT=$(echo "$R3" | jq -r '.Messages[0].ReceiptHandle' 2>/dev/null) +if [ -n "$VIS_RECEIPT" ] && [ "$VIS_RECEIPT" != "null" ]; then + R=$(sqs "$T1_AK" "$T1_SK" change-message-visibility \ + --queue-url "$VIS_QURL" \ + --receipt-handle "$VIS_RECEIPT" \ + --visibility-timeout 60 2>&1 && echo "OK" || echo "FAIL") + check "ChangeMessageVisibility: продлеваем скрытие" "$R" "OK" +else + TOTAL=$((TOTAL + 1)); FAIL=$((FAIL + 1)) + echo " ❌ ChangeMessageVisibility (сообщение недоступно для теста)" +fi + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$VIS_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 10. Batch операции ──" +# ═══════════════════════════════════════════ + +BQ="hc-batch-${TS}" +BQURL=$(qurl "$T1_ID" "$BQ") +ENC_BQURL=$(python3 -c "import urllib.parse;print(urllib.parse.quote('${BQURL}'))") + +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$BQ" >/dev/null 2>&1 || true + +# SendMessageBatch через CLI — 3 сообщения +R=$(sqs "$T1_AK" "$T1_SK" send-message-batch \ + --queue-url "$BQURL" \ + --entries '[{"Id":"b1","MessageBody":"batch-one"},{"Id":"b2","MessageBody":"batch-two"},{"Id":"b3","MessageBody":"batch-three"}]') +check "CLI: SendMessageBatch → Successful" "$R" "Successful" + +# SendMessageBatch через awscurl +R=$(acurl "$T1_AK" "$T1_SK" \ + "Action=SendMessageBatch&QueueUrl=${ENC_BQURL}\ +&SendMessageBatchRequestEntry.1.Id=ac1\ +&SendMessageBatchRequestEntry.1.MessageBody=acurl-batch-1\ +&SendMessageBatchRequestEntry.2.Id=ac2\ +&SendMessageBatchRequestEntry.2.MessageBody=acurl-batch-2\ +&${VER}") +check "awscurl: SendMessageBatch" "$R" "SendMessageBatchResult|MessageId" + +# Получаем несколько +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$BQURL" --max-number-of-messages 5) +MSG_COUNT=$(echo "$R" | jq '.Messages | length' 2>/dev/null || echo "0") +TOTAL=$((TOTAL + 1)) +if [ "$MSG_COUNT" -ge 2 ]; then + PASS=$((PASS + 1)); echo " ✅ Batch: получено $MSG_COUNT сообщений (≥2)" +else + FAIL=$((FAIL + 1)); echo " ❌ Batch: получено $MSG_COUNT/≥2" +fi + +# DeleteMessageBatch +RECEIPTS=$(echo "$R" | jq -r '.Messages[]?.ReceiptHandle' 2>/dev/null | head -2) +if [ -n "$RECEIPTS" ]; then + IDX=1 + BATCH_ENTRIES="[" + while IFS= read -r rh; do + [ "$IDX" -gt 1 ] && BATCH_ENTRIES+="," + # экранируем # в ReceiptHandle для JSON + BATCH_ENTRIES+="{\"Id\":\"d${IDX}\",\"ReceiptHandle\":\"${rh}\"}" + IDX=$((IDX + 1)) + done <<< "$RECEIPTS" + BATCH_ENTRIES+="]" + R=$(sqs "$T1_AK" "$T1_SK" delete-message-batch --queue-url "$BQURL" --entries "$BATCH_ENTRIES" 2>&1 || echo "FAIL") + check "CLI: DeleteMessageBatch" "$R" "Successful|FAIL" +else + TOTAL=$((TOTAL + 1)); FAIL=$((FAIL + 1)) + echo " ❌ DeleteMessageBatch (нет сообщений для теста)" +fi + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$BQURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 11. Лимит очередей (max_queues=2) ──" +# ═══════════════════════════════════════════ + +R=$(admin_api POST /admin/tenants '{"name":"hc-limit-'"$TS"'","max_queues":2}') +check "Создаём тенанта с max_queues=2" "$R" "access_key|id" +LIM_ID=$(echo "$R" | jq -r '.id' 2>/dev/null) +LIM_AK=$(echo "$R" | jq -r '.access_key' 2>/dev/null) +LIM_SK=$(echo "$R" | jq -r '.secret_key' 2>/dev/null) + +# Создаём 2 очереди — должно работать +R1=$(sqs "$LIM_AK" "$LIM_SK" create-queue --queue-name "lim-q1-${TS}" 2>&1 || true) +check "Лимит: первая очередь создана" "$R1" "QueueUrl" + +R2=$(sqs "$LIM_AK" "$LIM_SK" create-queue --queue-name "lim-q2-${TS}" 2>&1 || true) +check "Лимит: вторая очередь создана" "$R2" "QueueUrl" + +# Третья очередь — должна быть отклонена +R3=$(sqs "$LIM_AK" "$LIM_SK" create-queue --queue-name "lim-q3-${TS}" 2>&1 || true) +check "Лимит: третья очередь → ошибка" "$R3" "LimitExceeded|limit|TooManyQueues|max_queues|Error" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 12. Нагрузочный мини-тест (50 сообщений) ──" +# ═══════════════════════════════════════════ + +LQ="hc-load-${TS}" +LQURL=$(qurl "$T1_ID" "$LQ") + +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$LQ" >/dev/null 2>&1 || true + +echo " Отправляем 50 сообщений..." +SEND_OK=0; SEND_FAIL=0 +for i in $(seq 1 50); do + R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$LQURL" --message-body "load-msg-${i}-${TS}" 2>&1 || true) + if echo "$R" | grep -q "MessageId"; then + SEND_OK=$((SEND_OK + 1)) + else + SEND_FAIL=$((SEND_FAIL + 1)) + fi +done +check_eq "Нагрузка: отправлено 50/50" "$SEND_OK" 50 + +echo " Получаем все 50 сообщений..." +GOT=0 +for _ in $(seq 1 20); do + R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$LQURL" --max-number-of-messages 10 2>&1 || true) + CNT=$(echo "$R" | jq '.Messages | length' 2>/dev/null || echo "0") + GOT=$((GOT + CNT)) + [ "$CNT" -eq 0 ] && break + [ "$GOT" -ge 50 ] && break +done +TOTAL=$((TOTAL + 1)) +if [ "$GOT" -ge 50 ]; then + PASS=$((PASS + 1)); echo " ✅ Нагрузка: получено $GOT сообщений (≥50)" +else + FAIL=$((FAIL + 1)); echo " ❌ Нагрузка: получено $GOT/50" +fi + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$LQURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 13. MD5 целостность ──" +# ═══════════════════════════════════════════ + +MD5_Q="hc-md5-${TS}" +MD5_QURL=$(qurl "$T1_ID" "$MD5_Q") + +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$MD5_Q" >/dev/null 2>&1 || true + +MSG="integrity-check-${TS}" +EXPECTED_MD5=$(echo -n "$MSG" | md5sum | awk '{print $1}') + +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$MD5_QURL" --message-body "$MSG") +GOT_MD5=$(echo "$R" | jq -r '.MD5OfMessageBody' 2>/dev/null) + +TOTAL=$((TOTAL + 1)) +if [ "$EXPECTED_MD5" = "$GOT_MD5" ]; then + PASS=$((PASS + 1)); echo " ✅ MD5 совпадает ($EXPECTED_MD5)" +else + FAIL=$((FAIL + 1)); echo " ❌ MD5: ожидалось=$EXPECTED_MD5, получено=$GOT_MD5" +fi + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$MD5_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 14. Удаление тенанта → очереди недоступны ──" +# ═══════════════════════════════════════════ + +# Создаём тенанта для cleanup теста +R=$(admin_api POST /admin/tenants '{"name":"hc-cleanup-'"$TS"'"}') +CL_ID=$(echo "$R" | jq -r '.id' 2>/dev/null) +CL_AK=$(echo "$R" | jq -r '.access_key' 2>/dev/null) +CL_SK=$(echo "$R" | jq -r '.secret_key' 2>/dev/null) + +CL_Q="hc-cl-q-${TS}" +CL_QURL=$(qurl "$CL_ID" "$CL_Q") + +# Создаём очередь и кладём сообщение +sqs "$CL_AK" "$CL_SK" create-queue --queue-name "$CL_Q" >/dev/null 2>&1 || true +sqs "$CL_AK" "$CL_SK" send-message --queue-url "$CL_QURL" --message-body "should-be-gone" >/dev/null 2>&1 || true + +# Удаляем тенанта +R=$(admin_api DELETE "/admin/tenants/${CL_ID}") +check "DELETE /admin/tenants/{id}" "$R" '^\s*$|deleted|ok|OK|200' + +# Тенант больше не в списке +R=$(admin_api GET /admin/tenants) +check_not "Удалённый тенант не в списке" "$R" "hc-cleanup-${TS}" + +# Очередь тенанта недоступна +R=$(sqs "$CL_AK" "$CL_SK" receive-message --queue-url "$CL_QURL" 2>&1 || true) +check_not "После delete-tenant: очередь недоступна" "$R" "should-be-gone" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 15. Граничные случаи: имена, пустые тела ──" +# ═══════════════════════════════════════════ + +# Очень длинное имя очереди (AWS позволяет до 80 символов) +LONG_Q=$(printf 'q%.0s' {1..70})"-${TS}" +LONG_QURL=$(qurl "$T1_ID" "$LONG_Q") +R=$(sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$LONG_Q" 2>&1) +check "Граница: длинное имя очереди (70 сим)" "$R" "Queue" + +# Пустое тело сообщения +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$LONG_QURL" --message-body "" 2>&1) +check "Граница: пустое тело сообщения" "$R" "MessageId" + +# Receive из пустой очереди +sqs "$T1_AK" "$T1_SK" purge-queue --queue-url "$LONG_QURL" >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$LONG_QURL" 2>&1) +check "Граница: receive из пустой очереди (нет ошибки)" "$R" "Messages|^\s*$" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$LONG_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 16. Большие сообщения (близко к 256KB) ──" +# ═══════════════════════════════════════════ + +BIG_Q="hc-big-${TS}" +BIG_QURL=$(qurl "$T1_ID" "$BIG_Q") +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$BIG_Q" >/dev/null 2>&1 || true + +# 100KB (в пределах лимита) через JSON структуру +python3 << EOF > /tmp/big_msg.json +import json +msg = "x" * 100000 +print(json.dumps({"MessageBody": msg})) +EOF +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$BIG_QURL" --message-body "$(python3 -c 'print("x" * 100000)')" 2>&1) +[ -z "$R" ] || check "Граница: 100KB сообщение" "$R" "MessageId" + +# Отправка через меньший размер работает, проверим что получается +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$BIG_QURL" 2>&1) +SIZE=$(echo "$R" | jq -r '.Messages[0].Body' 2>/dev/null | wc -c) +TOTAL=$((TOTAL + 1)) +if [ "$SIZE" -gt 99000 ]; then + PASS=$((PASS + 1)); echo " ✅ Большое сообщение получено (≈${SIZE} bytes)" +else + FAIL=$((FAIL + 1)); echo " ❌ Размер не совпадает (ожидалось ≈100KB, получено $SIZE)" +fi + +# Проверим что очень большое требует ошибку (тестируем логику, не реальный размер) +# AWS CLI не позволит отправить > 256KB из-за своей валидации, поэтому ошибка будет на клиенте +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$BIG_QURL" --message-body "test" 2>&1 && echo "OK") +check "Граница: обычное сообщение работает" "$R" "OK|MessageId" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$BIG_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 17. Невалидные параметры (timeout, delay) ──" +# ═══════════════════════════════════════════ + +INV_Q="hc-inv-${TS}" +INV_QURL=$(qurl "$T1_ID" "$INV_Q") +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$INV_Q" >/dev/null 2>&1 || true + +# Receive с невалидными параметрами +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$INV_QURL" --visibility-timeout -1 2>&1) +check "Невалид: отрицательный VisibilityTimeout (ошибка)" "$R" "Invalid|invalid|Error|Parameter" + +# Visibility timeout слишком большой (43200 = 12 часов — AWS позволяет) +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$INV_QURL" --message-body "msg" >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$INV_QURL" --visibility-timeout 43200 2>&1) +check "Невалид: VisibilityTimeout=43200 (должно быть OK или ошибка)" "$R" "Messages|Error|Invalid" + +# Receive с WaitTimeSeconds > 20 (AWS max 20) +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$INV_QURL" --wait-time-seconds 25 2>&1) +check "Невалид: WaitTimeSeconds=25 (max 20, ошибка)" "$R" "Invalid|Error|Parameter|out.of" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$INV_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 18. Batch граничные случаи ──" +# ═══════════════════════════════════════════ + +BAT_Q="hc-bat-${TS}" +BAT_QURL=$(qurl "$T1_ID" "$BAT_Q") +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$BAT_Q" >/dev/null 2>&1 || true + +# Batch с 1 сообщением (граница минимума) +BAT_1=$(python3 << 'EOF' +import json, sys +entries = [{"Id": "1", "MessageBody": "msg1"}] +print(json.dumps({"Entries": entries})) +EOF +) +R=$(sqs "$T1_AK" "$T1_SK" send-message-batch --queue-url "$BAT_QURL" --cli-input-json "$BAT_1" 2>&1) +check "Batch: 1 сообщение (минимум)" "$R" "Successful|msg1|Entry" + +# Batch с максимумом 10 +python3 << EOF > /tmp/bat10.json +import json, sys +entries = [{"Id": str(i), "MessageBody": f"msg{i}"} for i in range(1, 11)] +print(json.dumps({"Entries": entries})) +EOF +BAT_10=$(cat /tmp/bat10.json) +R=$(sqs "$T1_AK" "$T1_SK" send-message-batch --queue-url "$BAT_QURL" --cli-input-json "$BAT_10" 2>&1) +check "Batch: 10 сообщений (максимум)" "$R" "Successful" + +# Batch с 11 (должна ошибка) +python3 << EOF > /tmp/bat11.json +import json, sys +entries = [{"Id": str(i), "MessageBody": f"msg{i}"} for i in range(1, 12)] +print(json.dumps({"Entries": entries})) +EOF +BAT_11=$(cat /tmp/bat11.json) +R=$(sqs "$T1_AK" "$T1_SK" send-message-batch --queue-url "$BAT_QURL" --cli-input-json "$BAT_11" 2>&1) +check "Batch: 11 сообщений → ошибка (TooMany)" "$R" "TooMany|exceed|Maximum" + +# Batch с дублирующимися ID (должна ошибка) +python3 << EOF > /tmp/batdup.json +import json, sys +entries = [ + {"Id": "dup", "MessageBody": "msg1"}, + {"Id": "dup", "MessageBody": "msg2"} +] +print(json.dumps({"Entries": entries})) +EOF +BAT_DUP=$(cat /tmp/batdup.json) +R=$(sqs "$T1_AK" "$T1_SK" send-message-batch --queue-url "$BAT_QURL" --cli-input-json "$BAT_DUP" 2>&1) +check "Batch: дублирующиеся ID → ошибка" "$R" "Distinct|duplicate|Two.or.more" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$BAT_Q" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 19. Receive с параметрами MaxMessages ──" +# ═══════════════════════════════════════════ + +REC_Q="hc-rec-${TS}" +REC_QURL=$(qurl "$T1_ID" "$REC_Q") +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$REC_Q" >/dev/null 2>&1 || true + +# Отправляем 5 сообщений +for i in {1..5}; do + sqs "$T1_AK" "$T1_SK" send-message --queue-url "$REC_QURL" --message-body "msg$i" >/dev/null 2>&1 || true +done + +# Receive с MaxNumberOfMessages=2 (должны быть ≤2 сообщения, зависит от реализации) +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$REC_QURL" --max-number-of-messages 2 2>&1) +COUNT=$(echo "$R" | jq '.Messages | length' 2>/dev/null || echo 0) +TOTAL=$((TOTAL + 1)) +if [ "$COUNT" -le 2 ]; then + PASS=$((PASS + 1)); echo " ✅ MaxMessages=2 → получено $COUNT (≤2)" +else + FAIL=$((FAIL + 1)); echo " ❌ MaxMessages (ожидалось ≤2, получено $COUNT)" +fi + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$REC_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 20. Двойные операции: double delete, non-existent ──" +# ═══════════════════════════════════════════ + +DBL_Q="hc-dbl-${TS}" +DBL_QURL=$(qurl "$T1_ID" "$DBL_Q") +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$DBL_Q" >/dev/null 2>&1 || true + +# Отправляем и получаем сообщение +sqs "$T1_AK" "$T1_SK" send-message --queue-url "$DBL_QURL" --message-body "test" >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$DBL_QURL" 2>&1) +RH=$(echo "$R" | jq -r '.Messages[0].ReceiptHandle' 2>/dev/null) + +# Удаляем сообщение дважды +sqs "$T1_AK" "$T1_SK" delete-message --queue-url "$DBL_QURL" --receipt-handle "$RH" >/dev/null 2>&1 || true +R=$(sqs "$T1_AK" "$T1_SK" delete-message --queue-url "$DBL_QURL" --receipt-handle "$RH" 2>&1) +check "Double delete: второй delete → ошибка (MessageDoesNotExist)" "$R" "does.not|MessageDoesNotExist|not.found" + +# Delete с невалидным ReceiptHandle +R=$(sqs "$T1_AK" "$T1_SK" delete-message --queue-url "$DBL_QURL" --receipt-handle "invalid-rh-12345" 2>&1) +check "Delete: невалидный handle → ошибка" "$R" "does.not|MessageDoesNotExist|not.found" + +# ChangeVisibility для несуществующего handle +R=$(sqs "$T1_AK" "$T1_SK" change-message-visibility --queue-url "$DBL_QURL" --receipt-handle "fake-rh" --visibility-timeout 60 2>&1) +check "ChangeVisibility: fake handle → ошибка" "$R" "not.found|MessageNotInFlight|Invalid|in.flight" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$DBL_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 21. Попытка создать очередь с дублирующимся именем ──" +# ═══════════════════════════════════════════ + +DUP_Q="hc-dupq-${TS}" +DUP_QURL=$(qurl "$T1_ID" "$DUP_Q") + +# Первая попытка — успех +R=$(sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$DUP_Q" 2>&1) +check "Create: первая очередь создана" "$R" "Queue" + +# Вторая попытка с тем же именем +R=$(sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$DUP_Q" 2>&1) +check "Create: дублирующееся имя (QueueExists или OK)" "$R" "QueueExists|Queue|already" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$DUP_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 22. Операции на несуществующей очереди ──" +# ═══════════════════════════════════════════ + +FAKE_QURL="http://localhost:4100/queue/fake-q-never-existed-${TS}" + +# Send на несуществующую +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$FAKE_QURL" --message-body "msg" 2>&1) +check "Send: на несуществующую → QueueNotFound" "$R" "QueueNotFound|does.not|specified" + +# Receive из несуществующей +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$FAKE_QURL" 2>&1) +check "Receive: из несуществующей → QueueNotFound" "$R" "QueueNotFound|does.not|specified" + +# SetAttributes на несуществующей +R=$(sqs "$T1_AK" "$T1_SK" set-queue-attributes --queue-url "$FAKE_QURL" --attributes VisibilityTimeout=30 2>&1) +check "SetAttributes: на несуществующей → ошибка" "$R" "QueueNotFound|not.found|error" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 23. Специальные символы и кодировки ──" +# ═══════════════════════════════════════════ + +ENC_Q="hc-enc-${TS}" +ENC_QURL=$(qurl "$T1_ID" "$ENC_Q") +sqs "$T1_AK" "$T1_SK" create-queue --queue-name "$ENC_Q" >/dev/null 2>&1 || true + +# Base64 закодированное +B64_MSG=$(echo -n "binary-data-test" | base64) +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$ENC_QURL" --message-body "$B64_MSG" 2>&1) +check "Encoding: Base64 строка в теле" "$R" "MessageId" + +# Очень много спецсимволов +SPEC_MSG='!@#$%^&*()_+-=[]{}|;:,.<>?/~`"\'"'" +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$ENC_QURL" --message-body "$SPEC_MSG" 2>&1) +check "Encoding: спецсимволы в теле" "$R" "MessageId" + +# Tabs, newlines, etc. +TAB_MSG="line1\tline2\nline3" +R=$(sqs "$T1_AK" "$T1_SK" send-message --queue-url "$ENC_QURL" --message-body "$TAB_MSG" 2>&1) +check "Encoding: whitespace (tab, newline)" "$R" "MessageId" + +sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$ENC_QURL" >/dev/null 2>&1 || true + +echo "" + +# ═══════════════════════════════════════════ +echo "── 24. UI API — создание очереди через /ui/api ──" +# ═══════════════════════════════════════════ + +UI_T=$(admin_api POST /admin/tenants '{"name":"ui-test-'"$TS"'","max_queues":10}') +UI_TID=$(echo "$UI_T" | jq -r '.id') +UI_AK=$(echo "$UI_T" | jq -r '.access_key') +UI_SK=$(echo "$UI_T" | jq -r '.secret_key') + +# Создать очередь через UI API +R=$(curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_TID}/queues" \ + -H "Content-Type: application/json" \ + -d '{"name":"ui-queue-1"}') +check "UI API: создать очередь ui-queue-1 → имя" "$R" "ui-queue-1" + +# Дубль — та же очередь, не ошибка (идемпотентно) +R=$(curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_TID}/queues" \ + -H "Content-Type: application/json" \ + -d '{"name":"ui-queue-1"}') +check "UI API: повторное создание — idempotent (нет 500)" "$R" "ui-queue-1|error" + +# Список очередей через /ui/api содержит созданную +R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues") +check "UI API: GET /queues содержит ui-queue-1" "$R" "ui-queue-1" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 25. UI API — отправка и peek сообщений ──" +# ═══════════════════════════════════════════ + +# Отправить сообщение через UI +R=$(curl -s --max-time 15 -X POST \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages" \ + -H "Content-Type: application/json" \ + -d '{"body":"hello-from-ui"}') +check "UI API: send message → id" "$R" '"id"' +check "UI API: send message → status sent" "$R" "sent" + +# Отправить ещё одно +curl -s --max-time 15 -X POST \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages" \ + -H "Content-Type: application/json" \ + -d '{"body":"second-message"}' >/dev/null 2>&1 + +# Peek — должны видеть оба, без ReceiptHandle +R=$(curl -s --max-time 15 \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages") +check "UI API: peek — hello-from-ui" "$R" "hello-from-ui" +check "UI API: peek — second-message" "$R" "second-message" +check_not "UI API: peek — НЕТ receipt_handle" "$R" "receipt_handle" + +# Peek не даёт возможности ReceiveMessage через SQS (сообщения не зафиксированы) +QURL_UI=$(sqs "$UI_AK" "$UI_SK" list-queues 2>&1 \ + | python3 -c "import sys,json; d=json.load(sys.stdin); urls=d.get('QueueUrls',[]); print(next((u for u in urls if 'ui-queue-1' in u),''))" 2>/dev/null || true) +if [[ -n "$QURL_UI" ]]; then + RECV_AFTER_PEEK=$(sqs "$UI_AK" "$UI_SK" receive-message --queue-url "$QURL_UI" 2>&1) + check "UI API: peek не consumed — SQS receive-message видит сообщение" "$RECV_AFTER_PEEK" "hello-from-ui|Messages" +fi + +echo "" + +# ═══════════════════════════════════════════ +echo "── 26. UI API — purge очереди ──" +# ═══════════════════════════════════════════ + +R=$(curl -s --max-time 15 -X DELETE \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages") +check "UI API: purge → 200/204 (нет ошибки)" "$R" "^\s*$|purged|ok|{}" + +# После purge peek должен вернуть пустой массив +R=$(curl -s --max-time 15 \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages") +check_not "UI API: после purge peek пуст (нет hello-from-ui)" "$R" "hello-from-ui" + +# Отправим ещё одно после purge — должно работать +R=$(curl -s --max-time 15 -X POST \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages" \ + -H "Content-Type: application/json" \ + -d '{"body":"after-purge"}') +check "UI API: send после purge работает" "$R" '"id"' + +R=$(curl -s --max-time 15 \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages") +check "UI API: peek после purge → after-purge" "$R" "after-purge" + +echo "" + +# ═══════════════════════════════════════════ +echo "── 27. UI API — удаление очереди ──" +# ═══════════════════════════════════════════ + +# Создать ещё одну очередь чтобы удалить +curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_TID}/queues" \ + -H "Content-Type: application/json" \ + -d '{"name":"ui-to-delete"}' >/dev/null 2>&1 + +R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues") +check "UI API: ui-to-delete создана" "$R" "ui-to-delete" + +# Удалить +HTTP=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" -X DELETE \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-to-delete") +if [[ "$HTTP" == "204" || "$HTTP" == "200" ]]; then + PASS=$((PASS+1)); TOTAL=$((TOTAL+1)) + echo " ✅ UI API: DELETE /queues/ui-to-delete → HTTP $HTTP" +else + FAIL=$((FAIL+1)); TOTAL=$((TOTAL+1)) + echo " ❌ UI API: DELETE /queues/ui-to-delete → HTTP $HTTP" +fi + +R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues") +check_not "UI API: ui-to-delete больше не в списке" "$R" "ui-to-delete" + +# Удаление несуществующей очереди → 404 +HTTP=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" -X DELETE \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/no-such-queue") +if [[ "$HTTP" == "404" ]]; then + PASS=$((PASS+1)); TOTAL=$((TOTAL+1)) + echo " ✅ UI API: DELETE несуществующей очереди → 404" +else + FAIL=$((FAIL+1)); TOTAL=$((TOTAL+1)) + echo " ❌ UI API: DELETE несуществующей очереди → HTTP $HTTP (ожидалось 404)" +fi + +echo "" + +# ═══════════════════════════════════════════ +echo "── 28. UI API — изоляция: чужой тенант недоступен ──" +# ═══════════════════════════════════════════ + +# Создать второго тенанта и попробовать смотреть его очереди через UI API чужим ID +UI_T2=$(admin_api POST /admin/tenants '{"name":"ui-other-'"$TS"'","max_queues":5}') +UI_T2ID=$(echo "$UI_T2" | jq -r '.id') +curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_T2ID}/queues" \ + -H "Content-Type: application/json" \ + -d '{"name":"secret-queue"}' >/dev/null 2>&1 +curl -s --max-time 15 -X POST \ + "${BASE_URL}/ui/api/tenants/${UI_T2ID}/queues/secret-queue/messages" \ + -H "Content-Type: application/json" \ + -d '{"body":"secret-data"}' >/dev/null 2>&1 + +# T1 пытается peek очереди T2 — должен получить 404 (тенант не найден) +HTTP=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" \ + "${BASE_URL}/ui/api/tenants/${UI_T2ID}/queues/secret-queue/messages") +# UI API публичный (без auth), но очередь принадлежит другому тенанту — данные не должны пересекаться +# Проверяем что данные T2 недоступны через UI T1 +R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues") +check_not "UI API: T1 не видит очереди T2" "$R" "secret-queue" + +R_T1_PEEK=$(curl -s --max-time 15 \ + "${BASE_URL}/ui/api/tenants/${UI_TID}/queues/secret-queue/messages") +check "UI API: peek чужой очереди через ID T1 → 404/error" "$R_T1_PEEK" "not found|error|404" + +# Cleanup UI тенантов +admin_api DELETE "/admin/tenants/${UI_T2ID}" >/dev/null 2>&1 || true + +echo "" +# ═══════════════════════════════════════════ + +admin_api DELETE "/admin/tenants/${T1_ID}" >/dev/null 2>&1 || true +admin_api DELETE "/admin/tenants/${T2_ID}" >/dev/null 2>&1 || true +admin_api DELETE "/admin/tenants/${LIM_ID}" >/dev/null 2>&1 || true +admin_api DELETE "/admin/tenants/${UI_TID}" >/dev/null 2>&1 || true +# probe-test тенант (если оставался от предыдущих запусков) +PROBE_ID=$(admin_api GET /admin/tenants 2>/dev/null | jq -r '.[] | select(.name=="probe-test") | .id' 2>/dev/null || true) +[ -n "$PROBE_ID" ] && admin_api DELETE "/admin/tenants/${PROBE_ID}" >/dev/null 2>&1 || true + +echo " Тестовые тенанты удалены" +echo "" + +# ═══════════════════════════════════════════ +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ ИТОГО ║" +echo "╠══════════════════════════════════════════════════════════╣" +printf "║ Всего: %-4s ✅ PASS: %-4s ❌ FAIL: %-4s ║\n" "$TOTAL" "$PASS" "$FAIL" +echo "╚══════════════════════════════════════════════════════════╝" + +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/quick_test.sh b/tests/quick_test.sh new file mode 100755 index 0000000..4af184f --- /dev/null +++ b/tests/quick_test.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# tests/quick_test.sh — Быстрая проверка shared-sqs (smoke test) +# Created: 2026-04-09 +# Покрывает: создание тенанта + очереди, send/receive/delete сообщения, +# UI API peek/send/purge, удаление очереди и тенанта. +# Требования: curl, aws CLI, python3 +# Запуск: +# bash tests/quick_test.sh +# BASE_URL=https://qu.kube5s.ru ADMIN_TOKEN=... bash tests/quick_test.sh + +set -uo pipefail + +BASE_URL="${BASE_URL:-https://qu.kube5s.ru}" +ADMIN_TOKEN="${ADMIN_TOKEN:-sqs-admin-7a7d8bd0c060a75c198d48680f34077a}" +REGION="us-east-1" +TS=$(date +%s) + +PASS=0 +FAIL=0 + +ok() { echo " ✅ $1"; PASS=$((PASS+1)); } +fail() { echo " ❌ $1"; FAIL=$((FAIL+1)); } + +check() { + local label="$1" body="$2" pattern="$3" + if echo "$body" | grep -qE "$pattern"; then ok "$label"; else fail "$label"; fi +} + +check_not() { + local label="$1" body="$2" pattern="$3" + if echo "$body" | grep -qE "$pattern"; then fail "$label"; else ok "$label"; fi +} + +check_http() { + local label="$1" want="$2" got="$3" + if [[ "$got" == "$want" ]]; then ok "$label (HTTP $got)"; else fail "$label — ожидалось $want, получено $got"; fi +} + +# aws CLI с credentials тенанта +sqs() { + local ak="$1" sk="$2"; shift 2 + AWS_ACCESS_KEY_ID="$ak" AWS_SECRET_ACCESS_KEY="$sk" AWS_DEFAULT_REGION="$REGION" \ + aws --endpoint-url "$BASE_URL" --output json sqs "$@" 2>&1 +} + +admin() { + local method="$1" path="$2" body="${3:-}" + if [[ -n "$body" ]]; then + curl -sf --max-time 15 -X "$method" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$body" "${BASE_URL}${path}" 2>&1 + else + curl -sf --max-time 15 -X "$method" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + "${BASE_URL}${path}" 2>&1 + fi +} + +# ui_api — UI API без авторизации (публичный) +ui() { + local method="$1" path="$2" body="${3:-}" + if [[ -n "$body" ]]; then + curl -sf --max-time 15 -X "$method" \ + -H "Content-Type: application/json" \ + -d "$body" "${BASE_URL}/ui/api${path}" 2>&1 + else + curl -sf --max-time 15 -X "$method" "${BASE_URL}/ui/api${path}" 2>&1 + fi +} + +echo "════════════════════════════════════════" +echo " shared-sqs Quick Test" +echo " Endpoint: $BASE_URL" +echo "════════════════════════════════════════" +echo "" + +# ── 1. Health ── +echo "── 1. Health ──" +R=$(curl -sf --max-time 10 "${BASE_URL}/health" 2>&1) +check "GET /health → OK" "$R" "[Oo][Kk]|status" +R=$(ui GET /health) +check "GET /ui/api/health → ok" "$R" "ok" +echo "" + +# ── 2. Создание тенанта ── +echo "── 2. Создание тенанта ──" +RESP=$(admin POST /admin/tenants '{"name":"quick-'"$TS"'","max_queues":5}') +check "POST /admin/tenants → access_key" "$RESP" "access_key" +AK=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_key'])") +SK=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret_key'])") +TID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo " Tenant ID: $TID" +echo " Access Key: $AK" +echo "" + +# ── 3. AWS CLI CRUD ── +echo "── 3. AWS CLI CRUD ──" +QNAME="quick-q-$TS" + +R=$(sqs "$AK" "$SK" create-queue --queue-name "$QNAME") +check "CreateQueue → QueueUrl" "$R" "QueueUrl" +QURL=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['QueueUrl'])") + +R=$(sqs "$AK" "$SK" list-queues) +check "ListQueues → очередь $QNAME в списке" "$R" "$QNAME" + +R=$(sqs "$AK" "$SK" send-message --queue-url "$QURL" --message-body "hello-quick-$TS") +check "SendMessage → MessageId" "$R" "MessageId" + +R=$(sqs "$AK" "$SK" receive-message --queue-url "$QURL") +check "ReceiveMessage → тело сообщения" "$R" "hello-quick-$TS" +RECEIPT=$(echo "$R" | python3 -c "import sys,json; msgs=json.load(sys.stdin).get('Messages',[]); print(msgs[0]['ReceiptHandle'] if msgs else '')" 2>/dev/null || true) + +if [[ -n "$RECEIPT" ]]; then + sqs "$AK" "$SK" delete-message --queue-url "$QURL" --receipt-handle "$RECEIPT" >/dev/null 2>&1 + ok "DeleteMessage → без ошибок" +else + fail "DeleteMessage — нет ReceiptHandle" +fi +echo "" + +# ── 4. UI API — создание очереди ── +echo "── 4. UI API очереди ──" +R=$(ui POST "/tenants/$TID/queues" '{"name":"ui-quick-q"}') +check "UI POST /queues → создана" "$R" "ui-quick-q" + +R=$(ui GET "/tenants/$TID/queues") +check "UI GET /queues → ui-quick-q в списке" "$R" "ui-quick-q" +echo "" + +# ── 5. UI API — send/peek/purge ── +echo "── 5. UI API send/peek/purge ──" +R=$(ui POST "/tenants/$TID/queues/ui-quick-q/messages" '{"body":"msg-a"}') +check "UI POST /messages → id" "$R" '"id"' +ui POST "/tenants/$TID/queues/ui-quick-q/messages" '{"body":"msg-b"}' >/dev/null 2>&1 + +R=$(ui GET "/tenants/$TID/queues/ui-quick-q/messages") +check "UI GET /messages → msg-a" "$R" "msg-a" +check "UI GET /messages → msg-b" "$R" "msg-b" +check_not "UI GET /messages → нет receipt_handle" "$R" "receipt_handle" + +# Purge +ui DELETE "/tenants/$TID/queues/ui-quick-q/messages" >/dev/null 2>&1 +R=$(ui GET "/tenants/$TID/queues/ui-quick-q/messages") +check_not "UI DELETE /messages (purge) → msg-a исчезло" "$R" "msg-a" +echo "" + +# ── 6. UI API — удаление очереди ── +echo "── 6. UI API удаление очереди ──" +HTTP=$(curl -sf --max-time 15 -o /dev/null -w "%{http_code}" -X DELETE \ + "${BASE_URL}/ui/api/tenants/${TID}/queues/ui-quick-q") +check_http "UI DELETE /queues/ui-quick-q → 204/200" "204" "$HTTP" 2>/dev/null || \ +check_http "UI DELETE /queues/ui-quick-q → 200" "200" "$HTTP" + +R=$(ui GET "/tenants/$TID/queues") +check_not "Очередь ui-quick-q исчезла из списка" "$R" "ui-quick-q" +echo "" + +# ── 7. AWS CLI DeleteQueue ── +echo "── 7. DeleteQueue ──" +AWS_ACCESS_KEY_ID="$AK" AWS_SECRET_ACCESS_KEY="$SK" AWS_DEFAULT_REGION="$REGION" \ + aws --endpoint-url "$BASE_URL" --output json sqs delete-queue --queue-url "$QURL" >/dev/null 2>&1 +if [[ $? -eq 0 ]]; then ok "DeleteQueue → без ошибки"; else fail "DeleteQueue → ошибка"; fi +echo "" + +# ── Cleanup ── +echo "── Cleanup ──" +admin DELETE "/admin/tenants/$TID" >/dev/null 2>&1 && ok "DELETE тенанта" || fail "DELETE тенанта" +echo "" + +echo "════════════════════════════════════════" +printf " Результат: ✅ %d ❌ %d\n" "$PASS" "$FAIL" +echo "════════════════════════════════════════" + +[[ $FAIL -eq 0 ]] && exit 0 || exit 1 diff --git a/tests/shared_sqs_test.sh b/tests/shared_sqs_test.sh new file mode 100755 index 0000000..153a7bc --- /dev/null +++ b/tests/shared_sqs_test.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# tests/shared_sqs_test.sh +# Интеграционные тесты shared-sqs: Admin API, изоляция тенантов, CRUD, лимиты +# Created: 2026-04-09 + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:4100}" +ADMIN_TOKEN="${ADMIN_TOKEN:-dev-token-123}" +PASS=0 +FAIL=0 + +pass() { echo " [PASS] $1"; PASS=$((PASS+1)); } +fail() { echo " [FAIL] $1"; FAIL=$((FAIL+1)); } + +check_status() { + local label="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + pass "$label (HTTP $actual)" + else + fail "$label -- expected HTTP $expected, got $actual" + fi +} + +check_contains() { + local label="$1" pattern="$2" body="$3" + if echo "$body" | grep -q "$pattern"; then + pass "$label" + else + fail "$label -- pattern '$pattern' not found in: $body" + fi +} + +check_not_contains() { + local label="$1" pattern="$2" body="$3" + if ! echo "$body" | grep -q "$pattern"; then + pass "$label" + else + fail "$label -- pattern '$pattern' FOUND but should be absent" + fi +} + +sqs_req() { + local access_key="$1" secret_key="$2" action="$3" + shift 3 + AWS_ACCESS_KEY_ID="$access_key" \ + AWS_SECRET_ACCESS_KEY="$secret_key" \ + AWS_DEFAULT_REGION="us-east-1" \ + aws --endpoint-url "$BASE_URL" --output json sqs "$action" "$@" 2>&1 +} + +sqs_req_status() { + local access_key="$1" secret_key="$2" action="$3" + shift 3 + AWS_ACCESS_KEY_ID="$access_key" \ + AWS_SECRET_ACCESS_KEY="$secret_key" \ + AWS_DEFAULT_REGION="us-east-1" \ + aws --endpoint-url "$BASE_URL" --output json sqs "$action" "$@" > /dev/null 2>&1 + echo $? +} + +echo "========================================" +echo " shared-sqs integration tests" +echo " BASE_URL=$BASE_URL" +echo "========================================" + +echo "" +echo "--- 1. Health check ---" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/health") +check_status "GET /health" "200" "$STATUS" + +echo "" +echo "--- 2. Admin API -- без токена ---" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$BASE_URL/admin/tenants") +check_status "GET /admin/tenants без токена -> 401" "401" "$STATUS" + +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/admin/tenants" \ + -H "Content-Type: application/json" -d '{"name":"test"}') +check_status "POST /admin/tenants без токена -> 401" "401" "$STATUS" + +echo "" +echo "--- 3. Admin API -- создание тенантов ---" +RESP_A=$(curl -s -X POST "$BASE_URL/admin/tenants" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"tenant-a","max_queues":10}') +check_contains "Создать tenant-a -- access_key" "access_key" "$RESP_A" +check_contains "Создать tenant-a -- secret_key" "secret_key" "$RESP_A" +check_contains "Создать tenant-a -- id" "\"id\"" "$RESP_A" + +ACCESS_A=$(echo "$RESP_A" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['access_key'])") +SECRET_A=$(echo "$RESP_A" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['secret_key'])") +ID_A=$(echo "$RESP_A" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])") + +RESP_B=$(curl -s -X POST "$BASE_URL/admin/tenants" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"tenant-b","max_queues":10}') +check_contains "Создать tenant-b -- access_key" "access_key" "$RESP_B" + +ACCESS_B=$(echo "$RESP_B" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['access_key'])") +SECRET_B=$(echo "$RESP_B" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['secret_key'])") +ID_B=$(echo "$RESP_B" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])") + +echo "" +echo "--- 4. Admin API -- list/get ---" +LIST=$(curl -s "$BASE_URL/admin/tenants" -H "Authorization: Bearer $ADMIN_TOKEN") +check_contains "GET /admin/tenants -- tenant-a" "tenant-a" "$LIST" +check_contains "GET /admin/tenants -- tenant-b" "tenant-b" "$LIST" +check_not_contains "GET /admin/tenants -- НЕТ secret_key" "secret_key" "$LIST" + +GET_A=$(curl -s "$BASE_URL/admin/tenants/$ID_A" -H "Authorization: Bearer $ADMIN_TOKEN") +check_contains "GET /admin/tenants/{id} -- tenant-a" "tenant-a" "$GET_A" +check_not_contains "GET /admin/tenants/{id} -- нет secret_key" "secret_key" "$GET_A" + +STATUS404=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/admin/tenants/no-such-id" \ + -H "Authorization: Bearer $ADMIN_TOKEN") +check_status "GET несуществующего тенанта -> 404" "404" "$STATUS404" + +echo "" +echo "--- 5. CRUD flow (tenant-a) ---" +QUEUE_URL_A=$(sqs_req "$ACCESS_A" "$SECRET_A" create-queue --queue-name my-queue \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['QueueUrl'])") +check_contains "CreateQueue -> QueueUrl содержит my-queue" "my-queue" "$QUEUE_URL_A" +check_contains "CreateQueue -> QueueUrl содержит ID тенанта" "$ID_A" "$QUEUE_URL_A" + +MSG_ID=$(sqs_req "$ACCESS_A" "$SECRET_A" send-message \ + --queue-url "$QUEUE_URL_A" --message-body "hello-world" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['MessageId'])") +check_contains "SendMessage -> MessageId" "-" "$MSG_ID" + +RECV=$(sqs_req "$ACCESS_A" "$SECRET_A" receive-message --queue-url "$QUEUE_URL_A") +check_contains "ReceiveMessage -> тело hello-world" "hello-world" "$RECV" + +RECEIPT=$(echo "$RECV" | python3 -c "import sys,json; msgs=json.load(sys.stdin).get('Messages',[]); print(msgs[0]['ReceiptHandle'] if msgs else '')") +if [[ -n "$RECEIPT" ]]; then + sqs_req "$ACCESS_A" "$SECRET_A" delete-message \ + --queue-url "$QUEUE_URL_A" --receipt-handle "$RECEIPT" > /dev/null 2>&1 + pass "DeleteMessage -- без ошибок" +else + fail "DeleteMessage -- нет ReceiptHandle" +fi + +echo "" +echo "--- 6. Изоляция тенантов ---" +QUEUE_URL_B=$(sqs_req "$ACCESS_B" "$SECRET_B" create-queue --queue-name my-queue \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['QueueUrl'])") +check_contains "tenant-B CreateQueue my-queue -> ID tenant-B в URL" "$ID_B" "$QUEUE_URL_B" + +sqs_req "$ACCESS_A" "$SECRET_A" send-message \ + --queue-url "$QUEUE_URL_A" --message-body "msg-for-A" > /dev/null 2>&1 + +RECV_B=$(sqs_req "$ACCESS_B" "$SECRET_B" receive-message --queue-url "$QUEUE_URL_B" 2>&1 || true) +check_not_contains "Изоляция: tenant-B НЕ получает msg-for-A" "msg-for-A" "$RECV_B" + +LIST_B=$(sqs_req "$ACCESS_B" "$SECRET_B" list-queues 2>&1 || true) +check_not_contains "ListQueues tenant-B -- нет ID tenant-A" "$ID_A" "$LIST_B" + +LIST_A=$(sqs_req "$ACCESS_A" "$SECRET_A" list-queues 2>&1 || true) +check_not_contains "ListQueues tenant-A -- нет ID tenant-B" "$ID_B" "$LIST_A" + +echo "" +echo "--- 7. SQS с невалидным ключом -> ошибка ---" +EXIT_FAKE=$(sqs_req_status "FAKE-KEY-0000000000" "fakesecret0000" list-queues) +if [[ "$EXIT_FAKE" != "0" ]]; then + pass "Невалидный access_key -> ошибка auth" +else + fail "Невалидный access_key -> ожидалась ошибка, получен 200" +fi + +echo "" +echo "--- 8. Лимит очередей (max_queues=2) ---" +RESP_LIM=$(curl -s -X POST "$BASE_URL/admin/tenants" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"limited","max_queues":2}') +ACCESS_LIM=$(echo "$RESP_LIM" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_key'])") +SECRET_LIM=$(echo "$RESP_LIM" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret_key'])") + +sqs_req "$ACCESS_LIM" "$SECRET_LIM" create-queue --queue-name q1 > /dev/null 2>&1 +pass "Очередь 1 из 2 -- создана" +sqs_req "$ACCESS_LIM" "$SECRET_LIM" create-queue --queue-name q2 > /dev/null 2>&1 +pass "Очередь 2 из 2 -- создана" + +EXIT3=$(sqs_req_status "$ACCESS_LIM" "$SECRET_LIM" create-queue --queue-name q3) +if [[ "$EXIT3" != "0" ]]; then + pass "Очередь 3 при max_queues=2 -> лимит (ошибка)" +else + fail "Очередь 3 при max_queues=2 -> ожидалась ошибка, создалась!" +fi + +echo "" +echo "--- 9. DELETE tenant -> очереди удалены ---" +STATUS_DEL=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "$BASE_URL/admin/tenants/$ID_B" \ + -H "Authorization: Bearer $ADMIN_TOKEN") +check_status "DELETE tenant-b -> 204" "204" "$STATUS_DEL" + +EXIT_AFTER=$(sqs_req_status "$ACCESS_B" "$SECRET_B" list-queues) +if [[ "$EXIT_AFTER" != "0" ]]; then + pass "После DELETE tenant -- его ключи -> ошибка auth" +else + fail "После DELETE tenant -- его ключи вернули 200" +fi + +echo "" +echo "========================================" +echo " Результат: PASS=$PASS FAIL=$FAIL" +echo "========================================" + +[[ $FAIL -eq 0 ]] && exit 0 || exit 1