test(iot): юнит-тесты валидации идентификаторов и разбора DSN (P1-11)
- handler/devices_test.go: validateIdentifier (charset/лимиты, запрет _ + # /). - iotpg/dsn_test.go: replaceDSNDatabase (вкл. sslrootcert со слэшем, ошибка для key=value) + safeNamespaceRe. - gofmt для затронутых файлов. go test ./... зелёный.
This commit is contained in:
@@ -42,19 +42,19 @@ type iotDeviceUpdateRequest struct {
|
|||||||
// устройства (отдаётся один раз) и НИКОГДА при чтении — чтобы пароль не утекал
|
// устройства (отдаётся один раз) и НИКОГДА при чтении — чтобы пароль не утекал
|
||||||
// через GET и не хранился в логах/истории.
|
// через GET и не хранился в логах/истории.
|
||||||
type iotDeviceResponse struct {
|
type iotDeviceResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Phase string `json:"phase"`
|
Phase string `json:"phase"`
|
||||||
MQTTUsername string `json:"mqtt_username,omitempty"`
|
MQTTUsername string `json:"mqtt_username,omitempty"`
|
||||||
MQTTPassword string `json:"mqtt_password,omitempty"`
|
MQTTPassword string `json:"mqtt_password,omitempty"`
|
||||||
TopicPrefix string `json:"topic_prefix,omitempty"`
|
TopicPrefix string `json:"topic_prefix,omitempty"`
|
||||||
// TelemetryTopic — полный MQTT-топик телеметрии устройства «ns/telemetry/device».
|
// TelemetryTopic — полный MQTT-топик телеметрии устройства «ns/telemetry/device».
|
||||||
// Явное поле (в отличие от topic_prefix = «ns/»): Terraform должен знать
|
// Явное поле (в отличие от topic_prefix = «ns/»): Terraform должен знать
|
||||||
// точный топик для публикации/подписки (ревью 2026-10-10).
|
// точный топик для публикации/подписки (ревью 2026-10-10).
|
||||||
TelemetryTopic string `json:"telemetry_topic"`
|
TelemetryTopic string `json:"telemetry_topic"`
|
||||||
LastConnected string `json:"last_connected,omitempty"`
|
LastConnected string `json:"last_connected,omitempty"`
|
||||||
// Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}»,
|
// Metadata — без omitempty: всегда присутствует в ответе. Пустой → «{}»,
|
||||||
// иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10).
|
// иначе Terraform видел бы drift «{}» против null (ревью 2026-10-10).
|
||||||
Metadata map[string]string `json:"metadata"`
|
Metadata map[string]string `json:"metadata"`
|
||||||
@@ -127,8 +127,8 @@ func hashPassword(plain string) (string, error) {
|
|||||||
// только [a-zA-Z0-9] и дефисы, не с дефиса. Исключает _, +, #, /, пробелы, юникод.
|
// только [a-zA-Z0-9] и дефисы, не с дефиса. Исключает _, +, #, /, пробелы, юникод.
|
||||||
// Причины запретов (ревью 2026-10-10):
|
// Причины запретов (ревью 2026-10-10):
|
||||||
// - _ — username = «namespace_device_id»; _ делает его неоднозначным
|
// - _ — 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-топике даст подписку/публикацию чужих топиков;
|
// - +/# — wildcard MQTT: в ACL-топике даст подписку/публикацию чужих топиков;
|
||||||
// - / — ломает топик «ns/telemetry/device» (лишний уровень).
|
// - / — ломает топик «ns/telemetry/device» (лишний уровень).
|
||||||
var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)
|
var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ type IoTPostgresStore struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
||||||
// батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT)
|
// батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT)
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// flushMu сериализует фактическую запись (grab буфера + INSERT) между
|
// flushMu сериализует фактическую запись (grab буфера + INSERT) между
|
||||||
// фоновым flusher'ом, Flush() из consumer и синхронной записью полного
|
// фоновым flusher'ом, Flush() из consumer и синхронной записью полного
|
||||||
// батча в InsertTelemetry. Без него Flush видел бы пустой буфер и возвращал
|
// батча в InsertTelemetry. Без него Flush видел бы пустой буфер и возвращал
|
||||||
|
|||||||
Reference in New Issue
Block a user