fix(admin): дать отзыву доступа вторую попытку, а лимиту устройств — порядок снимков

Предыдущий проход сделал правильным порядок «сначала долговременная запись,
потом разрыв сессии» и правильно запретил откат при неудаче разрыва. Способа
прийти к согласованному состоянию ПОТОМ он не дал: у двух операций повтор не
работал вовсе.

Импорт, заменивший auth_id: после неудавшегося /kick старое значение не
хранится нигде, повтор того же файла читает из базы уже новое и рвёт его, а
cron пропускал незнакомый authID молча — dao.ListPeer просто не возвращала
строку. Живая сессия оставалась навсегда.

Снижение maxDevices: повтор формы даёт 1 < 1 -> false, разрыва больше нет.
Лимит устройств в политику доступа не входит и входить не должен — это
свойство сессий, — поэтому механизма схождения у него не было.

enforcePeerAccess стал сверкой живых сессий: обход идёт по каждому authID из
/online. Нет строки в базе -> kick; peerAccessDenied -> kick; непригодный
maxDevices -> kick; устройств больше разрешённого -> kick. Отказ базы при этом
не рвёт ничего. Ни таблицы отложенных операций, ни очереди retry: список живых
сессий уже есть, и это /online.

Отдельно закрыт второй TOCTOU лимита устройств. Учёт выданных разрешений
закрыл сравнение двух одинаковых снимков, но сетевой запрос выполнялся вне
блокировки, поэтому снимки приходили в резервацию в произвольном порядке и
устаревший откатывал lastOnline назад, возвращая уже занятое место. Это не
data race — память защищена мьютексом, и -race здесь молчит принципиально.
Последовательность «прочитать /online -> занять место» выполняется под замком
по authId; глобальный замок не годится, внутри идёт сетевой запрос.

Учёт разрешений больше не растёт бесконечно: запись снималась только на ветке
отказа, поэтому в карте копились удалённые пиры и переписанные импортом
идентификаторы. Уборка идёт по фактической картине подключений.

Гейты приёмки доращены под все три инварианта и проверены в обе стороны.
Go 1.26.7 -> 1.26.8. Документация приведена в соответствие в двух местах,
где описывала снятую архитектуру.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc3-preflight-findings.md
This commit is contained in:
2026-09-02 07:15:43 +05:00
parent 6d1686b2be
commit 8dcb50a07c
16 changed files with 1511 additions and 104 deletions
+192
View File
@@ -13,6 +13,7 @@ import (
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
)
// Цикл учёта проверяется против НАСТОЯЩЕГО Traffic Stats API.
@@ -289,6 +290,197 @@ func TestCronIgnoresOfflinePeers(t *testing.T) {
}
}
// --- Сверка живых сессий -----------------------------------------------------
// Живая сессия, которой в базе больше ничего не соответствует, завершается.
//
// Прежний обход шёл по НАЙДЕННЫМ пирам, поэтому authID, которого нет в базе,
// молча выпадал: `dao.ListPeer("auth_id in ?")` просто не возвращала строку.
// Такое состояние возникает после неудавшегося второго шага удаления или
// импорта, заменившего `auth_id`, и восстановить его переподключением нельзя —
// авторизация нового значения не знает. Сессия жила неограниченно долго.
func TestCronKicksSessionWithoutPeerRow(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
online: map[string]int64{"ghost-auth-id": 1},
})
seedPeer(t, "alpha1", "alpha-auth-id")
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "ghost-auth-id" {
t.Fatalf("сессия без пира в базе не завершена: %v", got)
}
}
// Превышение лимита устройств — свойство живых сессий, а не хранимого
// состояния пира, поэтому peerAccessDenied его не видит и видеть не должен.
// Без этой проверки неудавшийся разрыв при снижении `maxDevices` оставался бы
// навсегда: повторное сохранение формы сравнивает `1 < 1` и разрыва не делает.
func TestCronKicksWhenOnlineExceedsMaxDevices(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
online: map[string]int64{"alpha-auth-id": 3},
})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"max_devices": int64(1)})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("превышение лимита устройств не отключено: %v", got)
}
}
// Граница: устройств ровно столько, сколько разрешено, — рвать нечего.
func TestCronDoesNotKickAtExactDeviceLimit(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
online: map[string]int64{"alpha-auth-id": 3},
})
// seedPeer создаёт пира с maxDevices = 3.
seedPeer(t, "alpha1", "alpha-auth-id")
CronHandleAccount()
if got := stub.kicked(); len(got) != 0 {
t.Fatalf("пир на границе лимита отключён: %v", got)
}
}
// Повреждённая граница — не «безлимит». На пути авторизации такая строка ведёт
// к отказу, и живая сессия обязана следовать тому же правилу.
func TestCronKicksPeerWithUnusableMaxDevices(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
online: map[string]int64{"alpha-auth-id": 1},
})
id := seedPeer(t, "alpha1", "alpha-auth-id")
peerUsage(t, id, map[string]interface{}{"max_devices": int64(0)})
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 {
t.Fatalf("пир с непригодным лимитом устройств не отключён: %v", got)
}
}
// Отказ базы НЕ является основанием рвать сессии.
//
// «Пира нет» и «прочитать не удалось» — разные ответы, и решение «сессии
// неизвестны, значит лишние» на втором из них отключило бы всех подключённых
// пиров сразу при недоступной SQLite. Проверка существует именно потому, что
// правило «неизвестный authID -> kick» делает это различие решающим.
func TestCronSendsNoKickWhenPeerLookupFails(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
online: map[string]int64{"alpha-auth-id": 1},
})
seedPeer(t, "alpha1", "alpha-auth-id")
apiPort, err := GetHysteria2ApiPort()
if err != nil {
t.Fatalf("порт Traffic Stats API: %v", err)
}
// Порт и секрет читаются из конфига Hysteria, поэтому база после этого уже
// не нужна ни для чего, кроме самой выборки пиров.
if err := dao.CloseSqliteDB(); err != nil {
t.Fatalf("не удалось закрыть базу: %v", err)
}
if err := enforcePeerAccess(apiPort, testTrafficStatsSecret); err == nil {
t.Fatal("отказ базы не сообщён вызывающему")
}
if got := stub.kicked(); len(got) != 0 {
t.Fatalf("отказ базы привёл к разрыву сессий: %v", got)
}
}
// Учёт выданных разрешений чистится по фактической картине подключений.
func TestCronSweepsAdmissionsOfOfflinePeers(t *testing.T) {
newTestDB(t)
startAccountStats(t, &accountStatsStub{online: map[string]int64{}})
seedPeer(t, "alpha1", "alpha-auth-id")
// Разрешение выдано давно и уже протухло, подключения так и не случилось.
if !reserveDeviceSlot("alpha-auth-id", 0, 3, time.Now().Add(-2*pendingAdmissionTTL)) {
t.Fatal("подготовка учёта: разрешение отклонено")
}
if admissionEntries() != 1 {
t.Fatal("подготовка учёта: запись не создана")
}
CronHandleAccount()
if got := admissionEntries(); got != 0 {
t.Fatalf("учёт не убран: записей %d", got)
}
}
// --- Сходимость после неудавшегося разрыва -----------------------------------
// Импорт заменил `auth_id`, а разрыв старой сессии не удался. Повторить его
// операцией импорта невозможно: в базе уже новое значение, и повтор того же
// файла разорвал бы именно его. Сходимость обеспечивает cron.
func TestCronReconcilesSessionAfterFailedImportKick(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
kickStatus: http.StatusInternalServerError,
online: map[string]int64{"old-auth-id": 1},
})
seedPeer(t, "keeper", "old-auth-id")
requireDisconnectError(t, UpsertPeerExport([]bo.PeerExport{importItem("keeper", "new-auth-id")}))
after := snapshotPeers(t)["keeper"]
if after.AuthId == nil || *after.AuthId != "new-auth-id" {
t.Fatalf("импорт не применён: authId=%v", after.AuthId)
}
stub.mu.Lock()
stub.kickStatus = 0
stub.kickedKeys = nil
stub.mu.Unlock()
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "old-auth-id" {
t.Fatalf("старая сессия не завершена следующим циклом учёта: %v", got)
}
}
// Лимит устройств снижен, разрыв не удался, оператор повторяет сохранение
// формы — и получает успех без разрыва, потому что новое значение уже в базе.
// Единственный механизм схождения здесь — cron.
func TestCronReconcilesSessionAfterFailedMaxDevicesReduction(t *testing.T) {
newTestDB(t)
stub := startAccountStats(t, &accountStatsStub{
kickStatus: http.StatusInternalServerError,
online: map[string]int64{"alpha-auth-id": 3},
})
// seedPeer создаёт пира с maxDevices = 3.
id := seedPeer(t, "alpha1", "alpha-auth-id")
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{MaxDevices: int64Ptr(1)}))
// Повтор формы: значение то же самое, разрыва не будет — и это правильно,
// иначе каждое сохранение любой правки рвало бы сессии.
if err := UpdatePeer(id, dto.PeerUpdateDto{MaxDevices: int64Ptr(1)}); err != nil {
t.Fatalf("повторное сохранение формы отказало: %v", err)
}
stub.mu.Lock()
stub.kickStatus = 0
stub.kickedKeys = nil
stub.mu.Unlock()
CronHandleAccount()
if got := stub.kicked(); len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("превышение лимита не устранено следующим циклом учёта: %v", got)
}
}
// --- Порядок и устройство цикла ----------------------------------------------
// Enforcement принимает решение по счётчикам, поэтому счётчики обязаны быть