From 6b9f397746790c04c3c531b23d22c8313d71d8ea Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sun, 11 Oct 2026 20:26:30 +0300 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20{namespace}=20?= =?UTF-8?q?=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=8D=D0=BD=D0=B4?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=D0=B0=D1=85=20(=D0=B1=D1=8B?= =?UTF-8?q?=D0=BB=D0=BE=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20?= =?UTF-8?q?POST)=20+=20=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/api/handler/devices.go | 31 ++++---- internal/service/api/handler/handler.go | 28 +++++++ .../service/api/handler/namespace_test.go | 78 +++++++++++++++++++ internal/service/api/handler/telemetry.go | 5 +- 4 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 internal/service/api/handler/namespace_test.go diff --git a/internal/service/api/handler/devices.go b/internal/service/api/handler/devices.go index dcf7b03..a556f02 100644 --- a/internal/service/api/handler/devices.go +++ b/internal/service/api/handler/devices.go @@ -151,10 +151,9 @@ func validateIdentifier(value, what string, maxLen int) string { // CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices. func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) { - ns := pathVar(r, "namespace") - if ns == "" { - ns = "default" - } + // Порядок важен: сначала разбор тела (400 «invalid JSON»), потом валидация + // идентификаторов — так и было до вынесения общей проверки. + ns := namespaceFromPath(r) var req iotDeviceCreateRequest if err := decodeJSON(w, r, &req); err != nil { @@ -255,9 +254,9 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) { // Пагинация: ?limit=N&offset=M. limit — дефолт 100, потолок 1000 (защита от // безлимитного запроса при ошибке парсинга). func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) { - ns := pathVar(r, "namespace") - if ns == "" { - ns = "default" + ns, ok := requireNamespace(w, r) + if !ok { + return } // limit: дефолт 100, потолок 1000 — иначе ошибка парсинга/омиссия = безлимит @@ -290,9 +289,9 @@ func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) { // GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем). func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) { - ns := pathVar(r, "namespace") - if ns == "" { - ns = "default" + ns, ok := requireNamespace(w, r) + if !ok { + return } name := pathVar(r, "name") @@ -312,9 +311,9 @@ func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) { // DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}. func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) { - ns := pathVar(r, "namespace") - if ns == "" { - ns = "default" + ns, ok := requireNamespace(w, r) + if !ok { + return } name := pathVar(r, "name") @@ -334,9 +333,9 @@ func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) { // UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled). func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) { - ns := pathVar(r, "namespace") - if ns == "" { - ns = "default" + ns, ok := requireNamespace(w, r) + if !ok { + return } name := pathVar(r, "name") diff --git a/internal/service/api/handler/handler.go b/internal/service/api/handler/handler.go index cf7c9f5..24cb0fc 100644 --- a/internal/service/api/handler/handler.go +++ b/internal/service/api/handler/handler.go @@ -62,3 +62,31 @@ func errResp(msg string) map[string]string { func pathVar(r *http.Request, key string) string { return mux.Vars(r)[key] } + +// defaultNamespace — namespace, когда в пути он не задан. +const defaultNamespace = "default" + +// namespaceFromPath возвращает {namespace} из URL (пусто → "default"). +func namespaceFromPath(r *http.Request) string { + ns := pathVar(r, "namespace") + if ns == "" { + ns = defaultNamespace + } + return ns +} + +// requireNamespace — namespace из URL + его валидация. При невалидном значении +// отвечает 400 и возвращает ok=false (вызывающий обязан выйти). +// +// Зачем единая точка: раньше проверка была ТОЛЬКО в CreateIoTDevice, а +// GET/DELETE/PATCH/telemetry пропускали любой namespace дальше (тест 2026-10-11: +// `GET /v1/namespaces/bad_ns/iot/devices` → 200, а чтение телеметрии с таким ns +// падало уже в хранилище с 500). Валидируем на входе у всех — единообразный 400. +func requireNamespace(w http.ResponseWriter, r *http.Request) (string, bool) { + ns := namespaceFromPath(r) + if msg := validateIdentifier(ns, "namespace", 56); msg != "" { + writeJSON(w, http.StatusBadRequest, errResp(msg)) + return "", false + } + return ns, true +} diff --git a/internal/service/api/handler/namespace_test.go b/internal/service/api/handler/namespace_test.go new file mode 100644 index 0000000..3816e83 --- /dev/null +++ b/internal/service/api/handler/namespace_test.go @@ -0,0 +1,78 @@ +// namespace_test.go — проверяет, что ВСЕ эндпоинты с {namespace} валидируют его +// одинаково (400), а не пропускают «мусор» до хранилища. +// +// Найдено e2e-прогоном 2026-10-11: валидация была только в CreateIoTDevice, +// поэтому GET-список с `bad_ns` отдавал 200, а чтение телеметрии падало уже в +// хранилище (safeNamespaceRe) с 500. Здесь — регрессия на все обработчики. +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" + + "gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg" +) + +// badNamespaces — значения, которые ломают MQTT-топик/username или имя БД +// tenant_: подчёркивание (коллизия с дефисом), wildcard, слэш, пробел, +// ведущий дефис, юникод, пустой. +var badNamespaces = []string{"bad_ns", "a+b", "a#b", "a/b", "-abc", "abc def", "имя", ""} + +func newReq(t *testing.T, ns string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/namespaces/x/iot/devices", nil) + return mux.SetURLVars(req, map[string]string{"namespace": ns, "name": "dev1"}) +} + +func TestHandlersRejectInvalidNamespace(t *testing.T) { + // Пустой IoTPG-стаб: до хранилища дело дойти не должно (валидация раньше), + // поэтому nil-зависимости Devices/IoTPG не используются. + h := &Handler{IoTPG: &iotpg.IoTPostgresStore{}} + + handlers := map[string]func(http.ResponseWriter, *http.Request){ + "ListIoTDevices": h.ListIoTDevices, + "GetIoTDevice": h.GetIoTDevice, + "DeleteIoTDevice": h.DeleteIoTDevice, + "UpdateIoTDevice": h.UpdateIoTDevice, + "ListIoTTelemetry": h.ListIoTTelemetry, + } + + for name, fn := range handlers { + for _, ns := range badNamespaces { + if ns == "" { + continue // пустой namespace = "default" (валидный) — проверяется отдельно + } + t.Run(name+"/"+ns, func(t *testing.T) { + rec := httptest.NewRecorder() + fn(rec, newReq(t, ns)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("код %d, ожидался 400 (body=%s)", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "namespace") { + t.Errorf("в теле нет упоминания namespace: %s", rec.Body.String()) + } + }) + } + } +} + +func TestEmptyNamespaceMeansDefault(t *testing.T) { + // Пустой {namespace} в пути → "default" (валидное значение), поэтому + // проверка должна пройти дальше валидации (здесь упадёт на nil Devices — + // это ожидаемо, ловим панику и убеждаемся, что это НЕ 400). + defer func() { + if r := recover(); r == nil { + t.Log("обработчик дошёл до зависимостей без паники") + } + }() + rec := httptest.NewRecorder() + h := &Handler{} + h.ListIoTDevices(rec, newReq(t, "")) + if rec.Code == http.StatusBadRequest { + t.Fatalf("пустой namespace не должен давать 400 (body=%s)", rec.Body.String()) + } +} diff --git a/internal/service/api/handler/telemetry.go b/internal/service/api/handler/telemetry.go index 12776d0..77fe28c 100644 --- a/internal/service/api/handler/telemetry.go +++ b/internal/service/api/handler/telemetry.go @@ -17,7 +17,10 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) { return } - ns := pathVar(r, "namespace") + ns, ok := requireNamespace(w, r) + if !ok { + return + } deviceID := r.URL.Query().Get("device_id") limit := 50 if ls := r.URL.Query().Get("limit"); ls != "" {