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:
Repinoid
2026-10-10 19:23:09 +03:00
parent 3a73207abd
commit 7c80c7b7f8
4 changed files with 146 additions and 13 deletions
@@ -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)
}
})
}
}
+91
View File
@@ -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)
}
}
}