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:
@@ -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 принимает решение по счётчикам, поэтому счётчики обязаны быть
|
||||
|
||||
Reference in New Issue
Block a user