fix(api): валидировать {namespace} во всех эндпоинтах (было только в POST) + регрессионные тесты
This commit is contained in:
@@ -151,10 +151,9 @@ func validateIdentifier(value, what string, maxLen int) string {
|
|||||||
|
|
||||||
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
|
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
|
||||||
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := pathVar(r, "namespace")
|
// Порядок важен: сначала разбор тела (400 «invalid JSON»), потом валидация
|
||||||
if ns == "" {
|
// идентификаторов — так и было до вынесения общей проверки.
|
||||||
ns = "default"
|
ns := namespaceFromPath(r)
|
||||||
}
|
|
||||||
|
|
||||||
var req iotDeviceCreateRequest
|
var req iotDeviceCreateRequest
|
||||||
if err := decodeJSON(w, r, &req); err != nil {
|
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 (защита от
|
// Пагинация: ?limit=N&offset=M. limit — дефолт 100, потолок 1000 (защита от
|
||||||
// безлимитного запроса при ошибке парсинга).
|
// безлимитного запроса при ошибке парсинга).
|
||||||
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := pathVar(r, "namespace")
|
ns, ok := requireNamespace(w, r)
|
||||||
if ns == "" {
|
if !ok {
|
||||||
ns = "default"
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// limit: дефолт 100, потолок 1000 — иначе ошибка парсинга/омиссия = безлимит
|
// 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} (с паролем).
|
// GetIoTDevice — GET /v1/namespaces/{ns}/iot/devices/{name} (с паролем).
|
||||||
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := pathVar(r, "namespace")
|
ns, ok := requireNamespace(w, r)
|
||||||
if ns == "" {
|
if !ok {
|
||||||
ns = "default"
|
return
|
||||||
}
|
}
|
||||||
name := pathVar(r, "name")
|
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}.
|
// DeleteIoTDevice — DELETE /v1/namespaces/{ns}/iot/devices/{name}.
|
||||||
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DeleteIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := pathVar(r, "namespace")
|
ns, ok := requireNamespace(w, r)
|
||||||
if ns == "" {
|
if !ok {
|
||||||
ns = "default"
|
return
|
||||||
}
|
}
|
||||||
name := pathVar(r, "name")
|
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).
|
// UpdateIoTDevice — PATCH /v1/namespaces/{ns}/iot/devices/{name} (enabled).
|
||||||
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
ns := pathVar(r, "namespace")
|
ns, ok := requireNamespace(w, r)
|
||||||
if ns == "" {
|
if !ok {
|
||||||
ns = "default"
|
return
|
||||||
}
|
}
|
||||||
name := pathVar(r, "name")
|
name := pathVar(r, "name")
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,31 @@ func errResp(msg string) map[string]string {
|
|||||||
func pathVar(r *http.Request, key string) string {
|
func pathVar(r *http.Request, key string) string {
|
||||||
return mux.Vars(r)[key]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_<ns>: подчёркивание (коллизия с дефисом), 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,10 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ns := pathVar(r, "namespace")
|
ns, ok := requireNamespace(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
deviceID := r.URL.Query().Get("device_id")
|
deviceID := r.URL.Query().Get("device_id")
|
||||||
limit := 50
|
limit := 50
|
||||||
if ls := r.URL.Query().Get("limit"); ls != "" {
|
if ls := r.URL.Query().Get("limit"); ls != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user