diff --git a/shared-sqs/Dockerfile b/shared-sqs/Dockerfile index 05210bb..3c2546e 100644 --- a/shared-sqs/Dockerfile +++ b/shared-sqs/Dockerfile @@ -11,7 +11,8 @@ RUN CGO_ENABLED=0 go build -o shared-sqs app/cmd/goaws.go 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"] +ENTRYPOINT ["shared-sqs", "--config", "/conf/goaws.yaml"] diff --git a/shared-sqs/app/conf/config.go b/shared-sqs/app/conf/config.go index 764d579..804a4b6 100644 --- a/shared-sqs/app/conf/config.go +++ b/shared-sqs/app/conf/config.go @@ -23,6 +23,9 @@ 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 { @@ -77,30 +80,7 @@ models.LogFile = envs[env].LogFile } } -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.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds <= 0 { -models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds = 0 -} - -if models.CurrentEnvironment.AccountID == "" { -models.CurrentEnvironment.AccountID = "queue" -} - -if models.CurrentEnvironment.Host == "" { -models.CurrentEnvironment.Host = "localhost" -models.CurrentEnvironment.Port = "4100" -} +// Дефолты применяются через defer applyEnvironmentDefaults() в начале функции models.SyncQueues.Lock() for _, queue := range envs[env].Queues { @@ -156,6 +136,28 @@ 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 { diff --git a/shared-sqs/app/gosqs/delete_message.go b/shared-sqs/app/gosqs/delete_message.go index 1e2edbf..e8919fe 100644 --- a/shared-sqs/app/gosqs/delete_message.go +++ b/shared-sqs/app/gosqs/delete_message.go @@ -3,62 +3,63 @@ package gosqs import ( -"net/http" -"strings" + "net/http" + "strings" -"shared-sqs/app/interfaces" -"shared-sqs/app/models" -"shared-sqs/app/utils" -"github.com/gorilla/mux" -log "github.com/sirupsen/logrus" + "shared-sqs/app/interfaces" + "shared-sqs/app/models" + "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) -} + 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) -} + 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] -} + 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) + 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) -respStruct := models.DeleteMessageResponse{ -Xmlns: models.BaseXmlns, -Metadata: models.BaseResponseMetadata, -} -return 200, &respStruct -} -} -log.Warning("Receipt Handle not found") -} else { -log.Warning("Queue not found") -} + 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) + 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) + return utils.CreateErrorResponseV1("MessageDoesNotExist", true) } diff --git a/shared-sqs/app/models/errors.go b/shared-sqs/app/models/errors.go index 5495c7a..b3f9774 100644 --- a/shared-sqs/app/models/errors.go +++ b/shared-sqs/app/models/errors.go @@ -16,6 +16,12 @@ func init() { "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."}, diff --git a/shared-sqs/app/models/requests.go b/shared-sqs/app/models/requests.go index 5049e79..606bf21 100644 --- a/shared-sqs/app/models/requests.go +++ b/shared-sqs/app/models/requests.go @@ -230,8 +230,25 @@ type SendMessageBatchRequest struct { } func (r *SendMessageBatchRequest) SetAttributesFromForm(values url.Values) { - for entryIndex := range r.Entries { - r.Entries[entryIndex].MessageAttributes = parseMessageAttributes(values, fmt.Sprintf("Entries.%d.MessageAttributes", entryIndex)) + // Парсим записи по 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) } } diff --git a/shared-sqs/tests/hardcore_test.sh b/shared-sqs/tests/hardcore_test.sh new file mode 100644 index 0000000..4ca3693 --- /dev/null +++ b/shared-sqs/tests/hardcore_test.sh @@ -0,0 +1,661 @@ +#!/bin/bash +# hardcore_test.sh — расширенный тест shared-sqs +# Изменено: 2026-04-09 +# Покрывает: Admin API, AWS CLI CRUD, awscurl CRUD, кросс-доставка, +# изоляция тенантов, невалидный ввод, спецсимволы, +# visibility timeout, batch, лимиты очередей, cleanup тенанта. +# Запуск: 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 -3)" + 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 -3)" + 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 + +# После паузы — снова доступно +R=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$VIS_QURL") +check "Visibility: сообщение снова доступно" "$R" "vis-msg-${TS}" + +# ChangeMessageVisibility — продлить время скрытия ДО истечения +R3=$(sqs "$T1_AK" "$T1_SK" receive-message --queue-url "$VIS_QURL" --visibility-timeout 30) +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 "── Cleanup: удаляем тестовых тенантов ──" +# ═══════════════════════════════════════════ + +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 +# 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