diff --git a/internal/service/api/handler/devices.go b/internal/service/api/handler/devices.go index 389b343..0d9f5cf 100644 --- a/internal/service/api/handler/devices.go +++ b/internal/service/api/handler/devices.go @@ -42,19 +42,19 @@ type iotDeviceUpdateRequest struct { // устройства (отдаётся один раз) и НИКОГДА при чтении — чтобы пароль не утекал // через GET и не хранился в логах/истории. type iotDeviceResponse struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - DeviceID string `json:"device_id"` - Enabled bool `json:"enabled"` - Phase string `json:"phase"` - MQTTUsername string `json:"mqtt_username,omitempty"` - MQTTPassword string `json:"mqtt_password,omitempty"` - TopicPrefix string `json:"topic_prefix,omitempty"` + Name string `json:"name"` + Namespace string `json:"namespace"` + DeviceID string `json:"device_id"` + Enabled bool `json:"enabled"` + Phase string `json:"phase"` + MQTTUsername string `json:"mqtt_username,omitempty"` + MQTTPassword string `json:"mqtt_password,omitempty"` + TopicPrefix string `json:"topic_prefix,omitempty"` // TelemetryTopic — полный MQTT-топик телеметрии устройства «ns/telemetry/device». // Явное поле (в отличие от topic_prefix = «ns/»): Terraform должен знать // точный топик для публикации/подписки (ревью 2026-10-10). - TelemetryTopic string `json:"telemetry_topic"` - LastConnected string `json:"last_connected,omitempty"` + TelemetryTopic string `json:"telemetry_topic"` + LastConnected string `json:"last_connected,omitempty"` // Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}», // иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10). Metadata map[string]string `json:"metadata"` @@ -127,8 +127,8 @@ func hashPassword(plain string) (string, error) { // только [a-zA-Z0-9] и дефисы, не с дефиса. Исключает _, +, #, /, пробелы, юникод. // Причины запретов (ревью 2026-10-10): // - _ — username = «namespace_device_id»; _ делает его неоднозначным -// (auth-запрос EMQX: namespace || '_' || device_id, LIMIT 1 без ORDER BY -// → может сматчить чужое устройство); +// (auth-запрос EMQX: namespace || '_' || device_id, LIMIT 1 без ORDER BY +// → может сматчить чужое устройство); // - +/# — wildcard MQTT: в ACL-топике даст подписку/публикацию чужих топиков; // - / — ломает топик «ns/telemetry/device» (лишний уровень). var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`) diff --git a/internal/service/api/handler/devices_test.go b/internal/service/api/handler/devices_test.go new file mode 100644 index 0000000..025882d --- /dev/null +++ b/internal/service/api/handler/devices_test.go @@ -0,0 +1,42 @@ +// devices_test.go — тесты валидации идентификаторов (validateIdentifier). +// +// Покрывают charset/лимиты, критичные для MQTT (username/topic) и PG (имя БД): +// запрет _, +, #, /, ведущего дефиса, пробелов и превышения длины. +package handler + +import ( + "strings" + "testing" +) + +func TestValidateIdentifier(t *testing.T) { + cases := []struct { + name string + value string + maxLen int + wantOK bool + }{ + {"простое имя", "boiler", 64, true}, + {"дефис внутри", "boiler-1", 64, true}, + {"одна буква", "a", 64, true}, + {"цифры", "dev001", 64, true}, + {"подчёркивание запрещено", "a_b", 64, false}, + {"плюс запрещён (wildcard MQTT)", "a+b", 64, false}, + {"решётка запрещена (wildcard MQTT)", "a#b", 64, false}, + {"слэш запрещён (уровень топика)", "a/b", 64, false}, + {"ведущий дефис", "-abc", 64, false}, + {"пробел", "abc def", 64, false}, + {"пусто", "", 64, false}, + {"юникод", "имя", 64, false}, + {"ровно максимум", strings.Repeat("a", 56), 56, true}, + {"больше максимума", strings.Repeat("a", 57), 56, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotOK := validateIdentifier(c.value, "x", c.maxLen) == "" + if gotOK != c.wantOK { + t.Errorf("validateIdentifier(%q, max=%d) ok=%v, want %v", c.value, c.maxLen, gotOK, c.wantOK) + } + }) + } +} diff --git a/internal/storage/iotpg/dsn_test.go b/internal/storage/iotpg/dsn_test.go new file mode 100644 index 0000000..85dd86b --- /dev/null +++ b/internal/storage/iotpg/dsn_test.go @@ -0,0 +1,91 @@ +// dsn_test.go — тесты replaceDSNDatabase и safeNamespaceRe. +// +// Критичны для изоляции тенантов: неверная замена БД = телеметрия всех тенантов +// уходит в одну БД; небезопасный namespace = коллизия имени БД/username. +package iotpg + +import ( + "strings" + "testing" +) + +func TestReplaceDSNDatabase(t *testing.T) { + cases := []struct { + name string + dsn string + newDB string + want string + wantErr bool + }{ + { + name: "обычный URL-DSN", + dsn: "postgresql://u:p@h:5432/iot_platform?sslmode=require", + newDB: "tenant_abc", + want: "postgresql://u:p@h:5432/tenant_abc?sslmode=require", + }, + { + name: "sslrootcert со слэшем в query не ломает замену", + dsn: "postgresql://u:p@h:5432/iot_platform?sslmode=require&sslrootcert=/etc/ssl/ca.pem", + newDB: "tenant_abc", + want: "postgresql://u:p@h:5432/tenant_abc?sslmode=require&sslrootcert=/etc/ssl/ca.pem", + }, + { + name: "DSN без query", + dsn: "postgresql://u:p@h:5432/iot_platform", + newDB: "tenant_x", + want: "postgresql://u:p@h:5432/tenant_x", + }, + { + name: "key=value DSN — ошибка (иначе смешение тенантов)", + dsn: "host=h user=u dbname=x sslmode=disable", + wantErr: true, + }, + { + name: "URL без имени БД — ошибка", + dsn: "postgresql://u:p@h:5432", + wantErr: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := replaceDSNDatabase(c.dsn, c.newDB) + if c.wantErr { + if err == nil { + t.Fatalf("ожидалась ошибка, получено %q", got) + } + return + } + if err != nil { + t.Fatalf("неожиданная ошибка: %v", err) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestSafeNamespaceRe(t *testing.T) { + cases := []struct { + ns string + want bool + }{ + {"myhome", true}, + {"sless-abc123", true}, + {"a", true}, + {"tenant", true}, + {"a_b", false}, // _ + {"a+b", false}, // + + {"a#b", false}, // # + {"a/b", false}, // / + {"", false}, // пусто + {"-x", false}, // ведущий дефис + {strings.Repeat("a", 56), true}, + {strings.Repeat("a", 57), false}, + } + for _, c := range cases { + if got := safeNamespaceRe.MatchString(c.ns); got != c.want { + t.Errorf("safeNamespaceRe(%q) = %v, want %v", c.ns, got, c.want) + } + } +} diff --git a/internal/storage/iotpg/iot_telemetry_store.go b/internal/storage/iotpg/iot_telemetry_store.go index dcc79ac..8f79b9c 100644 --- a/internal/storage/iotpg/iot_telemetry_store.go +++ b/internal/storage/iotpg/iot_telemetry_store.go @@ -43,7 +43,7 @@ type IoTPostgresStore struct { log *slog.Logger // батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT) - mu sync.Mutex + mu sync.Mutex // flushMu сериализует фактическую запись (grab буфера + INSERT) между // фоновым flusher'ом, Flush() из consumer и синхронной записью полного // батча в InsertTelemetry. Без него Flush видел бы пустой буфер и возвращал