cb20d8d28f
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним, поэтому сессия, установленная по отозванным учётным данным, была неотличима от законной, и цикл учёта не имел признака, по которому её следовало завершить. У состояния есть путь без единой неудачи — Hysteria регистрирует соединение в Traffic Stats API только после возврата backend-auth, поэтому успешный /kick может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт по старому, пережившая сессия становится orphan. Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4, админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала лимит устройств, учёт трафика и принудительное отключение разом. Адрес зафиксирован, а расхождение файла с ним админка называет. Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним значением, а доступность Traffic Stats API выводилась из него же. Журнал Hysteria разбирается в фактическом формате upstream (time — дробное число), страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в браузер, санитайзер выгрузки следует по YAML-якорям. Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
530 lines
21 KiB
Go
530 lines
21 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"hy2xs-admin/dao"
|
||
"hy2xs-admin/model/bo"
|
||
"hy2xs-admin/model/constant"
|
||
"hy2xs-admin/model/dto"
|
||
)
|
||
|
||
// Отзыв секрета сходится ДАЖЕ ТОГДА, когда `/kick` был бесполезен.
|
||
//
|
||
// Проверяемое свойство одно: после смены секрета сессия, установленная по
|
||
// старому, не может остаться жить неограниченно долго. Способов оказаться в
|
||
// таком состоянии два, и они разной природы:
|
||
//
|
||
// `/kick` вернул 500 — второй шаг операции не удался;
|
||
// `/kick` вернул 200, но сессии ещё не — второй шаг удался и всё равно
|
||
// было: backend-auth по старому секрету не подействовал.
|
||
// ещё выполнялся
|
||
//
|
||
// Второй случай — не отказ и не гонка по памяти. Hysteria дожидается ответа
|
||
// backend-auth и только ПОСЛЕ `ok=true` выставляет `authenticated = true` и
|
||
// сообщает Traffic Stats API о новом онлайне, поэтому `/kick`, прошедший в это
|
||
// окно, соединения просто не видит. Атомарной пары «решение авторизации +
|
||
// регистрация онлайна» upstream API не даёт.
|
||
//
|
||
// Сходимость обеспечивается тем, что новое поколение учётных данных получает
|
||
// новый `auth_id`: пережившая сессия называется старым значением, которого в
|
||
// базе больше нет, и очередной цикл учёта видит её как orphan.
|
||
|
||
// rotationStats — Traffic Stats API, у которого ОДНО названное обращение к
|
||
// `/online` удерживается до команды теста, а `/kick` при этом продолжает
|
||
// обслуживаться.
|
||
//
|
||
// Обслуживание `/kick` во время удержания обязательно: воспроизводимое
|
||
// состояние — это именно «отзыв прошёл целиком, пока авторизация висела», и
|
||
// стенд, сериализующий оба запроса, его бы не собрал.
|
||
type rotationStats struct {
|
||
mu sync.Mutex
|
||
|
||
online map[string]int64
|
||
kickStatus int
|
||
kickedKeys [][]string
|
||
|
||
holdOnlineCall int
|
||
onlineCalls int
|
||
|
||
held chan struct{}
|
||
released chan struct{}
|
||
}
|
||
|
||
func startRotationStats(t *testing.T, online map[string]int64, holdOnlineCall int) *rotationStats {
|
||
t.Helper()
|
||
|
||
if online == nil {
|
||
online = map[string]int64{}
|
||
}
|
||
stats := &rotationStats{
|
||
online: online,
|
||
holdOnlineCall: holdOnlineCall,
|
||
held: make(chan struct{}),
|
||
released: make(chan struct{}),
|
||
}
|
||
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/online":
|
||
stats.mu.Lock()
|
||
stats.onlineCalls++
|
||
call := stats.onlineCalls
|
||
// Снимок делается на ПРИХОДЕ запроса: к моменту доставки ответа
|
||
// картина уже другая, и это ровно то, что воспроизводится.
|
||
snapshot := make(map[string]int64, len(stats.online))
|
||
for key, value := range stats.online {
|
||
snapshot[key] = value
|
||
}
|
||
stats.mu.Unlock()
|
||
|
||
if call == stats.holdOnlineCall {
|
||
close(stats.held)
|
||
select {
|
||
case <-stats.released:
|
||
case <-time.After(5 * time.Second):
|
||
// Команды не последовало — отпускаем, чтобы падение было по
|
||
// существу, а не по таймауту всего прогона.
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(snapshot)
|
||
case "/traffic":
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]bo.Hysteria2UserTraffic{})
|
||
case "/kick":
|
||
stats.mu.Lock()
|
||
defer stats.mu.Unlock()
|
||
|
||
var keys []string
|
||
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
return
|
||
}
|
||
stats.kickedKeys = append(stats.kickedKeys, keys)
|
||
if stats.kickStatus != 0 {
|
||
w.WriteHeader(stats.kickStatus)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusOK)
|
||
default:
|
||
w.WriteHeader(http.StatusNotFound)
|
||
}
|
||
}))
|
||
t.Cleanup(server.Close)
|
||
|
||
pointHysteriaConfigAt(t, server.URL)
|
||
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
|
||
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
|
||
}
|
||
return stats
|
||
}
|
||
|
||
func (s *rotationStats) setOnline(online map[string]int64) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.online = online
|
||
}
|
||
|
||
func (s *rotationStats) setKickStatus(status int) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.kickStatus = status
|
||
}
|
||
|
||
func (s *rotationStats) forgetKicks() {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.kickedKeys = nil
|
||
}
|
||
|
||
func (s *rotationStats) kicked() []string {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
out := []string{}
|
||
for _, keys := range s.kickedKeys {
|
||
out = append(out, keys...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *rotationStats) awaitHeld(t *testing.T) {
|
||
t.Helper()
|
||
|
||
select {
|
||
case <-s.held:
|
||
case <-time.After(5 * time.Second):
|
||
t.Fatal("удерживаемое обращение к /online так и не пришло")
|
||
}
|
||
}
|
||
|
||
func (s *rotationStats) release() { close(s.released) }
|
||
|
||
// authIDOfPeer возвращает текущую идентичность сессий пира.
|
||
func authIDOfPeer(t *testing.T, name string) string {
|
||
t.Helper()
|
||
|
||
peer, ok := snapshotPeers(t)[name]
|
||
if !ok {
|
||
t.Fatalf("пир %q не найден", name)
|
||
}
|
||
return strVal(peer.AuthId)
|
||
}
|
||
|
||
func secretDigestOfPeer(t *testing.T, name string) string {
|
||
t.Helper()
|
||
|
||
peer, ok := snapshotPeers(t)[name]
|
||
if !ok {
|
||
t.Fatalf("пир %q не найден", name)
|
||
}
|
||
return strVal(peer.SecretDigest)
|
||
}
|
||
|
||
// --- Ротация через форму панели ----------------------------------------------
|
||
|
||
// Смена секрета меняет идентичность живых сессий, а рвётся при этом СТАРАЯ.
|
||
func TestUpdatePeerSecretRotationRotatesAuthID(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startRotationStats(t, nil, 0)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
|
||
t.Fatalf("смена секрета отказала: %v", err)
|
||
}
|
||
|
||
after := authIDOfPeer(t, "alpha1")
|
||
if after == "alpha-auth-id" {
|
||
t.Fatal("новое поколение учётных данных сохранило прежнюю идентичность сессий")
|
||
}
|
||
if after == "" {
|
||
t.Fatal("идентичность сессий стёрта вместо ротации")
|
||
}
|
||
|
||
// Разрыв идёт по СТАРОМУ значению: именно им Hysteria знает отзываемую
|
||
// сессию. Разрыв по новому не завершил бы ничего.
|
||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||
}
|
||
}
|
||
|
||
// Сессия, установленная по уже отозванному секрету ПОСЛЕ успешного `/kick`,
|
||
// завершается очередным циклом учёта.
|
||
//
|
||
// Это главный сценарий: ни одна операция здесь не отказала. Отзыв прошёл
|
||
// целиком, а соединение всё равно оказалось живым — потому что Hysteria
|
||
// регистрирует его только после возврата backend-auth.
|
||
func TestSecretRotationReconcilesInFlightOldCredential(t *testing.T) {
|
||
newTestDB(t)
|
||
// Удерживается ПЕРВОЕ обращение к `/online` — то самое, внутри которого
|
||
// зависает авторизация по старому секрету.
|
||
stub := startRotationStats(t, map[string]int64{}, 1)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
// 1. Клиент со старым секретом начинает авторизацию и застревает в /online.
|
||
type authOutcome struct {
|
||
authID string
|
||
err error
|
||
}
|
||
inflight := make(chan authOutcome, 1)
|
||
go func() {
|
||
_, authID, err := Hysteria2Auth("alpha1-secret")
|
||
inflight <- authOutcome{authID: authID, err: err}
|
||
}()
|
||
stub.awaitHeld(t)
|
||
|
||
// 2. Оператор меняет секрет. Запись проходит, `/kick` отвечает 200 —
|
||
// операция успешна целиком.
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
|
||
t.Fatalf("смена секрета отказала: %v", err)
|
||
}
|
||
rotated := authIDOfPeer(t, "alpha1")
|
||
if rotated == "alpha-auth-id" {
|
||
t.Fatal("идентичность сессий не сменилась вместе с секретом")
|
||
}
|
||
|
||
// 3. Задержанная авторизация возвращается уже ПОСЛЕ разрыва, и Hysteria
|
||
// регистрирует сессию под старым идентификатором.
|
||
stub.release()
|
||
outcome := <-inflight
|
||
if outcome.err != nil {
|
||
t.Fatalf("подготовка сценария: авторизация по старому секрету не дошла до ответа: %v", outcome.err)
|
||
}
|
||
if outcome.authID != "alpha-auth-id" {
|
||
t.Fatalf("подготовка сценария: авторизация вернула %q, ожидался старый идентификатор", outcome.authID)
|
||
}
|
||
stub.setOnline(map[string]int64{"alpha-auth-id": 1})
|
||
stub.forgetKicks()
|
||
|
||
// 4. Сходимость: сессия названа значением, которого в базе больше нет.
|
||
CronHandleAccount()
|
||
|
||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||
t.Fatalf("сессия по отозванному секрету пережила цикл учёта: в /kick ушло %v", got)
|
||
}
|
||
}
|
||
|
||
// Тот же исход, когда `/kick` при ротации не удался вовсе.
|
||
func TestSecretRotationRecoversAfterKickFailure(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startRotationStats(t, map[string]int64{"alpha-auth-id": 1}, 0)
|
||
stub.setKickStatus(http.StatusInternalServerError)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}))
|
||
|
||
// Неудача разрыва не откатывает ни новый секрет, ни новую идентичность:
|
||
// безопасная половина операции достигнута.
|
||
if authIDOfPeer(t, "alpha1") == "alpha-auth-id" {
|
||
t.Fatal("идентичность сессий откачена после неудачного разрыва")
|
||
}
|
||
|
||
stub.setKickStatus(0)
|
||
stub.forgetKicks()
|
||
|
||
CronHandleAccount()
|
||
|
||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||
t.Fatalf("старая сессия не завершена следующим циклом учёта: %v", got)
|
||
}
|
||
}
|
||
|
||
// Повтор ТОГО ЖЕ секрета — это повторная попытка отзыва, а не новое поколение
|
||
// учётных данных.
|
||
//
|
||
// Разрыв обязан выполниться снова (иначе после неудавшегося `/kick` оператору
|
||
// пришлось бы придумывать пиру ещё один секрет), а идентичность сессий обязана
|
||
// остаться прежней: менять её без смены credentials незачем — это обесценило бы
|
||
// привязку трафика на ровном месте.
|
||
func TestUpdatePeerSameSecretDoesNotRotateAuthID(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startRotationStats(t, nil, 0)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("alpha1-secret")}); err != nil {
|
||
t.Fatalf("повторная отправка секрета отказала: %v", err)
|
||
}
|
||
|
||
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
|
||
t.Fatalf("идентичность сессий сменилась без смены учётных данных: %q", got)
|
||
}
|
||
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
|
||
t.Fatalf("повторный отзыв не дошёл до /kick: %v", got)
|
||
}
|
||
}
|
||
|
||
// Секрет из одних пробелов означает «не менять» на ОБОИХ путях сразу.
|
||
//
|
||
// Раньше правило было записано дважды: запись проверяла `!= ""`, а решение о
|
||
// разрыве — `TrimSpace(...) != ""`. Такой секрет записывался бы в базу как
|
||
// новые учётные данные, не разрывая сессий, — то есть отзыв, о котором механизм
|
||
// сходимости не знает.
|
||
func TestUpdatePeerBlankSecretChangesNothing(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startRotationStats(t, nil, 0)
|
||
id := seedPeer(t, "alpha1", "alpha-auth-id")
|
||
|
||
before := secretDigestOfPeer(t, "alpha1")
|
||
|
||
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr(" ")}); err != nil {
|
||
t.Fatalf("правка отказала: %v", err)
|
||
}
|
||
|
||
if got := secretDigestOfPeer(t, "alpha1"); got != before {
|
||
t.Fatal("секрет из одних пробелов записан как новые учётные данные")
|
||
}
|
||
if got := authIDOfPeer(t, "alpha1"); got != "alpha-auth-id" {
|
||
t.Fatalf("идентичность сессий сменилась без смены секрета: %q", got)
|
||
}
|
||
if got := stub.kicked(); len(got) != 0 {
|
||
t.Fatalf("сессия разорвана без единого изменения: %v", got)
|
||
}
|
||
}
|
||
|
||
// --- Ротация импортом ---------------------------------------------------------
|
||
|
||
// Импорт — вторая дверь к смене учётных данных, и правило поколения у неё то же.
|
||
//
|
||
// Случай, проходивший мимо: файл несёт ПРЕЖНИЙ `auth_id` и НОВЫЙ секрет.
|
||
// Идентичность сессий оставалась той же, и сессия по отозванному секрету
|
||
// выглядела законной.
|
||
func TestImportSecretRotationRotatesAuthID(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startRotationStats(t, map[string]int64{"keeper-auth": 1}, 0)
|
||
seedPeer(t, "keeper", "keeper-auth")
|
||
|
||
item := importItem("keeper", "keeper-auth")
|
||
item.Secret = "brand-new-secret"
|
||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||
t.Fatalf("импорт отказал: %v", err)
|
||
}
|
||
|
||
rotated := authIDOfPeer(t, "keeper")
|
||
if rotated == "keeper-auth" {
|
||
t.Fatalf("импорт нового секрета сохранил прежнюю идентичность сессий")
|
||
}
|
||
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||
}
|
||
|
||
// И сходимость: сессия, пережившая разрыв, становится orphan.
|
||
stub.forgetKicks()
|
||
CronHandleAccount()
|
||
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||
t.Fatalf("сессия по отозванному импортом секрету пережила цикл учёта: %v", got)
|
||
}
|
||
}
|
||
|
||
// Совпадение по ИМЕНИ, файл без `auth_id`: идентичность всё равно обязана
|
||
// смениться вместе с секретом.
|
||
func TestImportSecretRotationRotatesAuthIDWhenMatchedByName(t *testing.T) {
|
||
newTestDB(t)
|
||
stub := startRotationStats(t, nil, 0)
|
||
seedPeer(t, "keeper", "keeper-auth")
|
||
|
||
item := importItem("keeper", "")
|
||
item.Secret = "brand-new-secret"
|
||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||
t.Fatalf("импорт отказал: %v", err)
|
||
}
|
||
|
||
if got := authIDOfPeer(t, "keeper"); got == "keeper-auth" {
|
||
t.Fatal("импорт нового секрета по совпадению имени сохранил прежнюю идентичность сессий")
|
||
}
|
||
if got := stub.kicked(); len(got) != 1 || got[0] != "keeper-auth" {
|
||
t.Fatalf("в /kick ушло %v, ожидался старый идентификатор", got)
|
||
}
|
||
}
|
||
|
||
// Повторный импорт ТОГО ЖЕ файла идентичность не трогает: поколение учётных
|
||
// данных не изменилось.
|
||
//
|
||
// Без этого перенос пиров превращал бы каждую повторную попытку в смену
|
||
// идентификаторов всех пиров партии.
|
||
func TestImportSameSecretKeepsAuthID(t *testing.T) {
|
||
newTestDB(t)
|
||
startRotationStats(t, nil, 0)
|
||
seedPeer(t, "keeper", "keeper-auth")
|
||
|
||
item := importItem("keeper", "keeper-auth")
|
||
item.Secret = "keeper-secret" // ровно тот, что уже записан seedPeer
|
||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||
t.Fatalf("импорт отказал: %v", err)
|
||
}
|
||
|
||
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
|
||
t.Fatalf("повторный импорт того же секрета сменил идентичность сессий: %q", got)
|
||
}
|
||
}
|
||
|
||
// Файл, несущий СВОЙ новый `auth_id`, ротации не требует: идентичность уже
|
||
// сменилась им самим, и старая сессия становится orphan без нашего участия.
|
||
func TestImportKeepsExplicitNewAuthID(t *testing.T) {
|
||
newTestDB(t)
|
||
startRotationStats(t, nil, 0)
|
||
seedPeer(t, "keeper", "keeper-auth")
|
||
|
||
item := importItem("keeper", "new-auth-id")
|
||
item.Secret = "brand-new-secret"
|
||
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
|
||
t.Fatalf("импорт отказал: %v", err)
|
||
}
|
||
|
||
if got := authIDOfPeer(t, "keeper"); got != "new-auth-id" {
|
||
t.Fatalf("значение auth_id из файла подменено ротацией: %q", got)
|
||
}
|
||
}
|
||
|
||
// Импорт без секрета учётных данных не меняет, поэтому и идентичность остаётся.
|
||
func TestImportWithoutSecretKeepsAuthID(t *testing.T) {
|
||
newTestDB(t)
|
||
startRotationStats(t, nil, 0)
|
||
seedPeer(t, "keeper", "keeper-auth")
|
||
|
||
if err := UpsertPeerExport([]bo.PeerExport{importItem("keeper", "keeper-auth")}); err != nil {
|
||
t.Fatalf("импорт отказал: %v", err)
|
||
}
|
||
|
||
if got := authIDOfPeer(t, "keeper"); got != "keeper-auth" {
|
||
t.Fatalf("импорт без секрета сменил идентичность сессий: %q", got)
|
||
}
|
||
}
|
||
|
||
// --- Правило поколения --------------------------------------------------------
|
||
|
||
func TestCredentialGenerationChanged(t *testing.T) {
|
||
stored := "digest-1"
|
||
|
||
cases := []struct {
|
||
name string
|
||
stored *string
|
||
next string
|
||
want bool
|
||
}{
|
||
{"другой digest", &stored, "digest-2", true},
|
||
{"тот же digest", &stored, "digest-1", false},
|
||
{"строка без digest", nil, "digest-1", true},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
if got := credentialGenerationChanged(tc.stored, tc.next); got != tc.want {
|
||
t.Fatalf("credentialGenerationChanged = %v, ожидалось %v", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRequestedSecretTreatsBlankAsAbsent(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
provided *string
|
||
want string
|
||
requested bool
|
||
}{
|
||
{"поле отсутствует", nil, "", false},
|
||
{"пустая строка", strPtr(""), "", false},
|
||
{"одни пробелы", strPtr(" "), "", false},
|
||
{"значение с краевыми пробелами", strPtr(" secret "), "secret", true},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
secret, requested := requestedSecret(tc.provided)
|
||
if requested != tc.requested || secret != tc.want {
|
||
t.Fatalf("requestedSecret = (%q, %v), ожидалось (%q, %v)",
|
||
secret, requested, tc.want, tc.requested)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Идентификатор, выданный ротацией, неотличим от выданного при создании: одна
|
||
// длина, один алфавит, один генератор.
|
||
func TestNewPeerAuthIDShape(t *testing.T) {
|
||
seen := map[string]struct{}{}
|
||
for i := 0; i < 64; i++ {
|
||
authID, err := newPeerAuthID()
|
||
if err != nil {
|
||
t.Fatalf("генерация идентичности отказала: %v", err)
|
||
}
|
||
if len(authID) != peerAuthIDLength {
|
||
t.Fatalf("длина %d, ожидалось %d", len(authID), peerAuthIDLength)
|
||
}
|
||
if !peerAuthIDPattern.MatchString(authID) {
|
||
t.Fatalf("идентификатор %q не проходит собственную проверку продукта", authID)
|
||
}
|
||
if _, duplicate := seen[authID]; duplicate {
|
||
t.Fatalf("генератор повторил значение %q", authID)
|
||
}
|
||
seen[authID] = struct{}{}
|
||
}
|
||
}
|