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) } }) } }