fix(admin): свести access-control к одному правилу и одному пути отзыва

Второй разбор того же слоя, уже по состоянию после 162759c. Тема: границы между
частями access-control. Прошлый проход починил одну операцию отзыва доступа и
оставил остальные; правило доступа при этом продолжало существовать в двух
экземплярах. Проведены три границы: состояние пира -> решение о доступе,
сохранённое изменение -> живая сессия, планировщик -> принадлежащая ему работа.

Правило доступа. Оно было записано двумя разными SQL-условиями: одним в выборке
Hysteria2Auth, другим в выборке cron. Второе не является отрицанием первого, и
расхождение приходилось ровно на границы — quota=0, usage=quota, now=expiresAt,
now=bannedUntil: авторизация отказывала, cron сессию не рвал. Условие cron
требовало СТРОГОГО превышения квоты, а счётчики растут порциями по ответу
Traffic Stats API, поэтому точное равенство — обычный исход очередного сбора.
Пир с исчерпанной квотой не пускался заново, но его живая сессия не разрывалась
никогда. Политика вынесена в peerAccessDenied; авторизация ищет пира только по
secret_digest, cron применяет ту же функцию. quota=-1 — единственный безлимит,
quota=0 — ноль байтов, bannedUntil=now — блокировка уже закончилась. Строка без
решающего поля трактуется как повреждённая и ведёт к отказу.

Операции, оставлявшие живую сессию. DeletePeer состоял из одного dao.DeletePeer:
строка исчезала вместе с auth_id, то есть вместе с единственным, чем эту сессию
можно было завершить, — состояние становилось невосстановимым. Разрыв при
изменении выполнялся только при disabled=1, поэтому мимо проходили смена
секрета, урезание квоты ниже израсходованного, перенос срока в прошлое и
снижение maxDevices. Импорт переписывает auth_id, секрет, квоту, срок и disabled
целиком и не трогал сессий вовсе. Все операции идут теперь через один
reconcileLiveSessions, а он — через disconnectAuthIDs, единственный вход к /kick:
он принимает готовые идентификаторы, дедуплицирует их, разбивает на части и не
обращается к базе. Импорт собирает старые auth_id ВНУТРИ транзакции (после
commit их в базе уже нет) и рвёт ПОСЛЕ commit (до него клиент успел бы
переподключиться к ещё не изменённому пиру). Правило асимметрично намеренно:
ограничение применяется немедленно, послабление — нет.

Цикл учёта. CronHandleAccount запускала горутину, которая запускала ещё две, —
для планировщика джоба заканчивалась почти мгновенно, поэтому StopCron не ждал
настоящей работы: releaseResource закрывал SQLite, а горутины продолжали в неё
писать. Параллельность обеих половин означала ещё и то, что enforcement читал
счётчики до записи снятой дельты. Джоба стала синхронной, под одним мьютексом на
весь цикл, порядок строгий. Закрыты три nil-разыменования — trafficSecretConfig,
item.AuthId и item.Id, — каждое из которых роняло процесс целиком вместе с
обработчиком machine-auth. Гейт Hysteria2IsRunning убран: util.Exec не отличает
«служба неактивна» от «спросить не удалось», и сломанный systemctl при живой
Hysteria молча отключал и учёт, и enforcement. Потеря дельты при отказе SQLite
больше не молчит: чтение /traffic?clear=1 деструктивно, и каждая потеря
считается. Checkpoint accounting в 1.0.0 намеренно не вводится — квота здесь
операционный предел доступа, а не учёт с финансово значимым каждым байтом.

Лимит устройств. Между чтением /online и ответом allow место ничем не
удерживалось: при online=max-1 два одновременных запроса получали разрешение
оба. Мьютекс вокруг /online этого не чинит — ответив allow, админка не создаёт
подключение, и следующий запрос продолжает видеть прежнее число. Появился
process-local учёт выданных, но ещё не проявившихся разрешений: решение по сумме
«подключено плюс зарезервировано», рост online снимает соответствующее их число,
протухшие снимаются по внутреннему TTL. Сеть опрашивается вне блокировки.

Гейты. Проверка «авторизация не возвращает успех из ветки ошибки» была записана
регуляркой err != nil \{[\s\S]*?return \*peer\.Id, а ленивый [\s\S]*? свободно
пересекает границы блоков: она даёт совпадение на коде из HEAD, то есть гейт
нельзя было удовлетворить, не сломав продукт. Тело ветки теперь выделяется по
балансу фигурных скобок, и логика проверена в обе стороны. go test -race стал
обязательным шагом сборки: состояние трекера разрешений и мьютекс цикла учёта
принадлежат процессу, и их корректность не наблюдаема ни в go test, ни в go vet;
пропуск при недоступном компиляторе не предусмотрен.

Панель. importPeerApi не объявлял skipErrorToast, а handleImport не имел ни try,
ни catch: после появления частичного результата отказ уходил бы необработанным
отклонением промиса, список не обновлялся бы при уже изменённой базе, а общий
перехватчик показал бы предупреждение красной ошибкой. Формулировка
peer_disconnect_failed во всех трёх местах сделана operation-neutral: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
This commit is contained in:
2026-09-01 20:46:21 +05:00
parent 162759c599
commit 6d1686b2be
25 changed files with 3675 additions and 244 deletions
+589
View File
@@ -0,0 +1,589 @@
package service
import (
"net/http"
"testing"
"time"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
)
// Любая операция, делающая живую сессию устаревшей, обязана её завершить.
//
// Прежние проверки доказывали существование пути отзыва доступа, но не
// полноту операций, которые по нему обязаны идти: разрыв выполнялся ровно при
// `disabled=1`, а удаление, импорт, смена секрета и урезание квоты/срока/лимита
// проходили мимо. Здесь доказывается именно полнота.
func int64Ptr(v int64) *int64 { return &v }
// kickedAuthIDs собирает все идентификаторы, ушедшие в `/kick`.
func kickedAuthIDs(stub *trafficStatsStub) []string {
stub.mu.Lock()
defer stub.mu.Unlock()
out := make([]string, 0, len(stub.kickedKeys))
for _, keys := range stub.kickedKeys {
out = append(out, keys...)
}
return out
}
func kickCalls(stub *trafficStatsStub) int {
stub.mu.Lock()
defer stub.mu.Unlock()
return stub.kickCalls
}
func requireKicked(t *testing.T, stub *trafficStatsStub, want ...string) {
t.Helper()
got := kickedAuthIDs(stub)
if len(got) != len(want) {
t.Fatalf("в /kick ушло %v, ожидалось %v", got, want)
}
seen := map[string]int{}
for _, authID := range got {
seen[authID]++
}
for _, authID := range want {
if seen[authID] == 0 {
t.Fatalf("идентификатор %q не был разорван; ушло %v", authID, got)
}
seen[authID]--
}
}
// --- Удаление пира -----------------------------------------------------------
// Что было: `return dao.DeletePeer([]int64{id})`. Строка исчезала, живая
// QUIC-сессия оставалась, и вместе со строкой исчезал auth_id — то есть
// единственное, чем эту сессию можно было бы завершить. Удалённый пир
// пользовался доступом, пока не переподключался сам.
func TestDeletePeerTerminatesLiveSession(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DeletePeer(id); err != nil {
t.Fatalf("удаление отказало: %v", err)
}
requireKicked(t, stub, "alpha-auth-id")
if _, err := dao.GetPeer("id = ?", id); err == nil {
t.Fatal("строка пира осталась в базе после успешного удаления")
}
}
// Запрет новых подключений записывается ДО разрыва. Проверить это после
// операции нельзя — строки уже нет, — поэтому состояние снимается в момент
// прихода `/kick`.
func TestDeletePeerWritesDisabledBeforeKick(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DeletePeer(id); err != nil {
t.Fatalf("удаление отказало: %v", err)
}
if len(stub.disabledAtKick) == 0 {
t.Fatal("разрыв сессии не выполнялся")
}
if got := stub.disabledAtKick[0]["alpha1"]; got != 1 {
t.Fatalf("/kick пришёл раньше записи disabled: на момент разрыва disabled=%d", got)
}
}
// Неудача разрыва оставляет пира В БАЗЕ и ОТКЛЮЧЁННЫМ.
//
// Удалить строку, не сумев завершить сессию, значит потерять auth_id и вместе
// с ним всякую возможность отозвать доступ. Оставшаяся строка с disabled=1
// закрывает новые подключения и позволяет оператору повторить удаление.
func TestDeletePeerKeepsDisabledRowWhenKickFails(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
requireDisconnectError(t, DeletePeer(id))
peer, err := dao.GetPeer("id = ?", id)
if err != nil {
t.Fatalf("пир удалён, хотя его сессию завершить не удалось: %v", err)
}
if peer.Disabled == nil || *peer.Disabled != 1 {
t.Fatalf("оставшийся пир не отключён: %v", peer.Disabled)
}
}
// Повторное удаление после неудавшегося разрыва обязано пройти целиком.
func TestDeletePeerIsRetryableAfterKickFailure(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
requireDisconnectError(t, DeletePeer(id))
stub.mu.Lock()
stub.kickStatus = 0
stub.mu.Unlock()
if err := DeletePeer(id); err != nil {
t.Fatalf("повторное удаление отказало: %v", err)
}
if _, err := dao.GetPeer("id = ?", id); err == nil {
t.Fatal("повторное удаление не убрало строку")
}
if kickCalls(stub) != 2 {
t.Fatalf("повторная попытка не дошла до /kick: вызовов %d", kickCalls(stub))
}
}
// Пир без authId Hysteria не знает: разрывать нечего, и это не повод
// отказывать в удалении.
func TestDeletePeerWithoutAuthIDSkipsKick(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "")
if err := DeletePeer(id); err != nil {
t.Fatalf("удаление пира без authId отказало: %v", err)
}
if kickCalls(stub) != 0 {
t.Fatalf("сделан вызов /kick без цели: %d", kickCalls(stub))
}
}
// --- Изменение пира ----------------------------------------------------------
// Смена секрета делает недействительными учётные данные, по которым сессия
// была установлена. Раньше разрыв выполнялся только при disabled=1, поэтому
// ротация секрета оставляла клиента на связи по старому секрету.
func TestUpdatePeerSecretRotationDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}); err != nil {
t.Fatalf("смена секрета отказала: %v", err)
}
requireKicked(t, stub, "alpha-auth-id")
}
// Новый секрет остаётся сохранённым и при неудавшемся разрыве: безопасная
// половина операции достигнута, и возвращать прежние учётные данные нельзя.
func TestUpdatePeerSecretRotationKeepsNewSecretWhenKickFails(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
before := snapshotPeers(t)["alpha1"]
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Secret: strPtr("brand-new-secret")}))
after := snapshotPeers(t)["alpha1"]
if *after.SecretDigest == *before.SecretDigest {
t.Fatal("новый секрет откачен после неудачного разрыва")
}
}
// Сочетание изменений — одна операция над одним пиром, а значит один `/kick`.
func TestUpdatePeerSendsSingleKickForCombinedChanges(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
err := UpdatePeer(id, dto.PeerUpdateDto{
Secret: strPtr("brand-new-secret"),
Disabled: int64Ptr(1),
QuotaBytes: int64Ptr(1),
MaxDevices: int64Ptr(1),
})
if err != nil {
t.Fatalf("правка отказала: %v", err)
}
if kickCalls(stub) != 1 {
t.Fatalf("ожидался ровно один /kick, было %d", kickCalls(stub))
}
}
// Урезание квоты ниже израсходованного закрывает доступ немедленно — значит и
// живую сессию тоже.
func TestUpdatePeerQuotaReductionBelowUsageDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{
"quota_bytes": int64(100_000),
"download_bytes": int64(6_000),
"upload_bytes": int64(4_000),
}); err != nil {
t.Fatalf("подготовка расхода: %v", err)
}
// 10 000 израсходовано, новая квота 5 000.
if err := UpdatePeer(id, dto.PeerUpdateDto{QuotaBytes: int64Ptr(5_000)}); err != nil {
t.Fatalf("урезание квоты отказало: %v", err)
}
requireKicked(t, stub, "alpha-auth-id")
}
// Граница та же, что и в политике: израсходовано ровно столько, сколько теперь
// разрешено.
func TestUpdatePeerQuotaReducedToExactUsageDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{
"quota_bytes": int64(100_000),
"download_bytes": int64(10_000),
"upload_bytes": int64(0),
}); err != nil {
t.Fatalf("подготовка расхода: %v", err)
}
if err := UpdatePeer(id, dto.PeerUpdateDto{QuotaBytes: int64Ptr(10_000)}); err != nil {
t.Fatalf("урезание квоты отказало: %v", err)
}
requireKicked(t, stub, "alpha-auth-id")
}
// Урезание квоты, которое доступ ещё НЕ закрывает, сессию не рвёт: у оператора
// нет причины ронять работающее соединение.
func TestUpdatePeerQuotaReductionAboveUsageDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{
"quota_bytes": int64(100_000),
"download_bytes": int64(1_000),
}); err != nil {
t.Fatalf("подготовка расхода: %v", err)
}
if err := UpdatePeer(id, dto.PeerUpdateDto{QuotaBytes: int64Ptr(50_000)}); err != nil {
t.Fatalf("правка квоты отказала: %v", err)
}
if kickCalls(stub) != 0 {
t.Fatalf("сессия разорвана при действующем доступе: вызовов /kick %d", kickCalls(stub))
}
}
// Перенос срока в прошлое закрывает доступ немедленно.
func TestUpdatePeerExpiryMovedIntoPastDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
past := time.Now().UnixMilli() - 60_000
if err := UpdatePeer(id, dto.PeerUpdateDto{ExpiresAt: int64Ptr(past)}); err != nil {
t.Fatalf("перенос срока отказал: %v", err)
}
requireKicked(t, stub, "alpha-auth-id")
}
// Продление срока — послабление, сессию оно не трогает.
func TestUpdatePeerExpiryExtensionDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
future := time.Now().UnixMilli() + 3_600_000
if err := UpdatePeer(id, dto.PeerUpdateDto{ExpiresAt: int64Ptr(future)}); err != nil {
t.Fatalf("продление срока отказало: %v", err)
}
if kickCalls(stub) != 0 {
t.Fatalf("продление срока разорвало сессию: вызовов /kick %d", kickCalls(stub))
}
}
// Снижение лимита устройств применяется немедленно. Выбирать «лишнее
// устройство» не нужно: `/kick` оперирует идентификатором клиента, и после
// переподключения новый лимит пропустит ровно столько, сколько разрешено.
func TestUpdatePeerMaxDevicesDecreaseDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
// seedPeer создаёт пира с maxDevices = 3.
if err := UpdatePeer(id, dto.PeerUpdateDto{MaxDevices: int64Ptr(1)}); err != nil {
t.Fatalf("снижение лимита отказало: %v", err)
}
requireKicked(t, stub, "alpha-auth-id")
}
func TestUpdatePeerMaxDevicesIncreaseDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{MaxDevices: int64Ptr(5)}); err != nil {
t.Fatalf("повышение лимита отказало: %v", err)
}
if kickCalls(stub) != 0 {
t.Fatalf("повышение лимита разорвало сессию: вызовов /kick %d", kickCalls(stub))
}
}
// Переименование и правка пометки доступа не касаются.
func TestUpdatePeerCosmeticChangesDoNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{
Name: strPtr("alpha2"),
Remark: strPtr("ноутбук"),
}); err != nil {
t.Fatalf("правка отказала: %v", err)
}
if kickCalls(stub) != 0 {
t.Fatalf("косметическая правка разорвала сессию: вызовов /kick %d", kickCalls(stub))
}
}
// Повторная запись того же значения квоты изменением не является.
func TestUpdatePeerUnchangedQuotaDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{
"quota_bytes": int64(1_000),
"download_bytes": int64(5_000),
}); err != nil {
t.Fatalf("подготовка: %v", err)
}
// Доступ закрыт и до правки: его сессию уже завершил тот, кто его закрыл.
if err := UpdatePeer(id, dto.PeerUpdateDto{QuotaBytes: int64Ptr(1_000)}); err != nil {
t.Fatalf("правка отказала: %v", err)
}
if kickCalls(stub) != 0 {
t.Fatalf("повторная запись того же значения разорвала сессию: вызовов /kick %d", kickCalls(stub))
}
}
// --- Импорт ------------------------------------------------------------------
// Импорт переписывает credential- и access-состояние существующего пира
// целиком, поэтому его старая сессия обязана быть завершена.
func TestImportDisconnectsExistingPeerAfterCommit(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
seedPeer(t, "keeper", "keeper-auth")
item := importItem("keeper", "keeper-auth")
item.Disabled = 1
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
requireKicked(t, stub, "keeper-auth")
}
// Разрывается СТАРЫЙ auth ID: после commit в строке уже стоит новый, и по нему
// Hysteria про сессию ничего не знает.
func TestImportDisconnectsOldAuthIDWhenItChanges(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
seedPeer(t, "keeper", "old-auth-id")
// Совпадение по имени: authId в файле другой, значит он будет заменён.
item := importItem("keeper", "new-auth-id")
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
requireKicked(t, stub, "old-auth-id")
after := snapshotPeers(t)["keeper"]
if after.AuthId == nil || *after.AuthId != "new-auth-id" {
t.Fatalf("authId не заменён импортом: %v", after.AuthId)
}
}
// Вновь созданные пиры не разрываются: до импорта их живых сессий существовать
// не могло.
func TestImportDoesNotDisconnectNewPeers(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
items := []bo.PeerExport{
importItem("brand1", ""),
importItem("brand2", ""),
}
if err := UpsertPeerExport(items); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
if kickCalls(stub) != 0 {
t.Fatalf("новые пиры разорваны: вызовов /kick %d", kickCalls(stub))
}
}
// Партия — одна операция: один `/kick` со всеми старыми идентификаторами.
func TestImportSendsSingleBatchKick(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
seedPeer(t, "first1", "first-auth")
seedPeer(t, "secnd2", "secnd-auth")
items := []bo.PeerExport{
importItem("first1", "first-auth"),
importItem("secnd2", "secnd-auth"),
importItem("brand3", ""),
}
if err := UpsertPeerExport(items); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
if kickCalls(stub) != 1 {
t.Fatalf("ожидался один batch /kick, было %d", kickCalls(stub))
}
requireKicked(t, stub, "first-auth", "secnd-auth")
}
// Откат транзакции означает, что состояние не менялось: рвать нечего.
func TestImportRollbackSendsNoKick(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
seedPeer(t, "alice1", "aaa")
seedPeer(t, "bob123", "bbb")
items := []bo.PeerExport{
importItem("first1", ""),
// UNIQUE(name): поиск найдёт alice1 по auth_id и переименует её в bob123.
importItem("bob123", "aaa"),
}
if err := UpsertPeerExport(items); err == nil {
t.Fatal("импорт с конфликтом UNIQUE должен быть отклонён")
}
if kickCalls(stub) != 0 {
t.Fatalf("откаченный импорт разорвал сессии: вызовов /kick %d", kickCalls(stub))
}
}
// Отклонение партии валидацией не доходит ни до базы, ни до Hysteria.
func TestImportInvalidBatchSendsNoKick(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
seedPeer(t, "keeper", "keeper-auth")
items := []bo.PeerExport{
importItem("keeper", "keeper-auth"),
importItem("bad", ""), // короче 6 символов
}
if err := UpsertPeerExport(items); err == nil {
t.Fatal("партия с невалидной записью должна быть отклонена")
}
if kickCalls(stub) != 0 {
t.Fatalf("отклонённая партия разорвала сессии: вызовов /kick %d", kickCalls(stub))
}
}
// Неудача разрыва НЕ откатывает импорт: транзакция уже зафиксирована, и
// оператору сообщается частичный результат.
func TestImportStaysCommittedWhenKickFails(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
seedPeer(t, "keeper", "keeper-auth")
item := importItem("keeper", "keeper-auth")
item.Remark = "перенесено"
requireDisconnectError(t, UpsertPeerExport([]bo.PeerExport{item}))
after := snapshotPeers(t)["keeper"]
if after.Remark == nil || *after.Remark != "перенесено" {
t.Fatalf("импорт откачен из-за неудачного разрыва: remark=%v", after.Remark)
}
}
// Разрыв идёт СТРОГО после commit: до него клиент успел бы переподключиться к
// ещё не изменённому пиру. Доказывается снимком базы в момент прихода `/kick`.
func TestImportCommitsBeforeKick(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
seedPeer(t, "keeper", "keeper-auth")
item := importItem("keeper", "keeper-auth")
item.Disabled = 1
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отказал: %v", err)
}
if len(stub.disabledAtKick) == 0 {
t.Fatal("разрыв сессии не выполнялся")
}
if got := stub.disabledAtKick[0]["keeper"]; got != 1 {
t.Fatalf("/kick пришёл раньше commit: на момент разрыва disabled=%d", got)
}
}
// --- Правило выбора reconcile ------------------------------------------------
// Правило проверяется и напрямую: так видно, что асимметрия «ограничение
// применяется немедленно, послабление — нет» является решением, а не побочным
// эффектом порядка условий.
func TestUpdateRequiresReconcileRules(t *testing.T) {
base := func() entity.Peer {
return policyPeer(0, 100_000, 1_000, 0, 0, 0)
}
withMaxDevices := func(peer entity.Peer, value int64) entity.Peer {
peer.MaxDevices = &value
return peer
}
cases := []struct {
name string
peer entity.Peer
dto dto.PeerUpdateDto
want bool
}{
{"смена секрета", base(), dto.PeerUpdateDto{Secret: strPtr("new-secret")}, true},
{"пустой секрет — «не менять»", base(), dto.PeerUpdateDto{Secret: strPtr(" ")}, false},
{"отключение", base(), dto.PeerUpdateDto{Disabled: int64Ptr(1)}, true},
{"повторное отключение — retry", policyPeer(1, 100_000, 0, 0, 0, 0), dto.PeerUpdateDto{Disabled: int64Ptr(1)}, true},
{"включение", policyPeer(1, 100_000, 0, 0, 0, 0), dto.PeerUpdateDto{Disabled: int64Ptr(0)}, false},
{"снижение лимита устройств", withMaxDevices(base(), 3), dto.PeerUpdateDto{MaxDevices: int64Ptr(1)}, true},
{"повышение лимита устройств", withMaxDevices(base(), 3), dto.PeerUpdateDto{MaxDevices: int64Ptr(5)}, false},
{"тот же лимит устройств", withMaxDevices(base(), 3), dto.PeerUpdateDto{MaxDevices: int64Ptr(3)}, false},
{"квота ниже расхода", base(), dto.PeerUpdateDto{QuotaBytes: int64Ptr(500)}, true},
{"квота ровно по расходу", base(), dto.PeerUpdateDto{QuotaBytes: int64Ptr(1_000)}, true},
{"квота выше расхода", base(), dto.PeerUpdateDto{QuotaBytes: int64Ptr(2_000)}, false},
{"квота снята в безлимит", base(), dto.PeerUpdateDto{QuotaBytes: int64Ptr(-1)}, false},
{"срок в прошлое", base(), dto.PeerUpdateDto{ExpiresAt: int64Ptr(policyNow - 1)}, true},
{"срок в будущее", base(), dto.PeerUpdateDto{ExpiresAt: int64Ptr(policyNow + 1)}, false},
{"только пометка", base(), dto.PeerUpdateDto{Remark: strPtr("ноутбук")}, false},
{"только имя", base(), dto.PeerUpdateDto{Name: strPtr("alpha2")}, false},
{"пустой запрос", base(), dto.PeerUpdateDto{}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := updateRequiresReconcile(tc.peer, tc.dto, policyNow); got != tc.want {
t.Fatalf("updateRequiresReconcile = %v, ожидалось %v", got, tc.want)
}
})
}
}