Files
HY2XS_flamy/apps/service/peer_import_tx_test.go
T
founder 6d1686b2be 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: через этот
код отчитываются восемь операций, а для удалённого пира прежняя фраза «новые
подключения пира запрещены» просто бессмысленна.
2026-09-01 20:46:21 +05:00

283 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"path/filepath"
"strings"
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/entity"
)
// Тесты транзакционности импорта работают на настоящей SQLite: UNIQUE-конфликт,
// ради которого транзакция и нужна, воспроизводится только в базе. Схема
// поднимается отдельно от InitSql — bootstrap-администратор и bootstrap-пир
// здесь не нужны и только мешали бы читать ожидания.
func newTestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
// Учёт выданных разрешений на устройства принадлежит ПРОЦЕССУ, а не базе,
// поэтому сам по себе он между тестами не обнуляется. Без сброса тест
// видел бы резервации предыдущего и проходил или падал по чужому
// состоянию — то есть доказывал бы не то, ради чего написан.
resetDeviceAdmissions()
t.Cleanup(resetDeviceAdmissions)
t.Cleanup(func() {
_ = dao.CloseSqliteDB()
})
}
func seedPeer(t *testing.T, name string, authID string) int64 {
t.Helper()
digest, err := PeerSecretDigest(name + "-secret")
if err != nil {
t.Fatalf("digest: %v", err)
}
cipher, err := EncryptPeerSecret(name + "-secret")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
remark := ""
quota := int64(-1)
zero := int64(0)
maxDevices := int64(3)
id, err := dao.SavePeer(entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &digest,
SecretEncrypted: &cipher,
QuotaBytes: &quota,
DownloadBytes: &zero,
UploadBytes: &zero,
ExpiresAt: &zero,
MaxDevices: &maxDevices,
Disabled: &zero,
BannedUntil: &zero,
LastConnectionAt: &zero,
})
if err != nil {
t.Fatalf("не удалось создать пира %s: %v", name, err)
}
return id
}
func snapshotPeers(t *testing.T) map[string]entity.Peer {
t.Helper()
peers, err := dao.ListPeer("1=1")
if err != nil {
t.Fatalf("не удалось прочитать пиров: %v", err)
}
out := make(map[string]entity.Peer, len(peers))
for _, peer := range peers {
out[*peer.Name] = peer
}
return out
}
func importItem(name string, authID string) bo.PeerExport {
return bo.PeerExport{
Name: name,
AuthId: authID,
QuotaBytes: -1,
MaxDevices: 3,
}
}
func TestUpsertPeerExportAppliesWholeBatch(t *testing.T) {
newTestDB(t)
items := []bo.PeerExport{
importItem("alpha1", ""),
importItem("bravo2", ""),
importItem("charl3", ""),
}
if err := UpsertPeerExport(items); err != nil {
t.Fatalf("валидный импорт отклонён: %v", err)
}
peers := snapshotPeers(t)
if len(peers) != 3 {
t.Fatalf("ожидалось 3 пира, получено %d", len(peers))
}
for _, name := range []string{"alpha1", "bravo2", "charl3"} {
if _, ok := peers[name]; !ok {
t.Errorf("пир %s не создан", name)
}
}
}
// Главная регрессия: cross-conflict, описанный в комментарии к UpsertPeerExport.
//
// В базе A(auth_id=aaa, name=alice1) и B(auth_id=bbb, name=bob123).
// Импорт несёт запись (auth_id=aaa, name=bob123): поиск найдёт A по auth_id и
// попытается переименовать её в bob123 — прямо в UNIQUE(name).
//
// До транзакции всё, что шло в файле ДО конфликтной записи, оставалось
// применённым.
func TestUpsertPeerExportRollsBackCrossConflict(t *testing.T) {
newTestDB(t)
seedPeer(t, "alice1", "aaa")
seedPeer(t, "bob123", "bbb")
before := snapshotPeers(t)
items := []bo.PeerExport{
importItem("first1", ""), // применилась бы
importItem("second", ""), // применилась бы
importItem("bob123", "aaa"), // UNIQUE(name) конфликт
importItem("fourth", ""), // до неё дело не дойдёт
}
err := UpsertPeerExport(items)
if err == nil {
t.Fatal("импорт с конфликтом UNIQUE должен быть отклонён")
}
// Отказ обязан прийти из базы, а не из валидации: иначе тест доказывал бы
// только то, что файл не прошёл проверку, и про откат не говорил бы ничего.
if strings.HasPrefix(err.Error(), "peer import:") {
t.Fatalf("ожидался отказ базы, получен отказ валидации: %v", err)
}
after := snapshotPeers(t)
if len(after) != len(before) {
t.Fatalf("состояние базы изменилось: было %d пиров, стало %d", len(before), len(after))
}
for name, peer := range before {
got, ok := after[name]
if !ok {
t.Errorf("пир %s пропал после отката", name)
continue
}
if *got.AuthId != *peer.AuthId {
t.Errorf("у пира %s изменился auth_id: %s -> %s", name, *peer.AuthId, *got.AuthId)
}
}
for _, name := range []string{"first1", "second", "fourth"} {
if _, ok := after[name]; ok {
t.Errorf("частично применённый импорт: пир %s остался в базе", name)
}
}
}
// Тот же инвариант на дубликате auth_id: конфликт возникает на вставке, а не
// на обновлении.
func TestUpsertPeerExportRollsBackDuplicateAuthId(t *testing.T) {
newTestDB(t)
// Успешный исход этого сценария — обновление существующего пира, а оно
// после commit разрывает его старую сессию. Без отвечающего Traffic Stats
// API импорт вернул бы частичный результат, и тест принял бы его за отказ
// транзакции.
startTrafficStats(t, nil)
seedPeer(t, "exist1", "shared-auth-id")
before := snapshotPeers(t)
items := []bo.PeerExport{
importItem("newone", ""),
// Имя новое, поэтому запись будет вставляться, а auth_id уже занят.
importItem("newtwo", "shared-auth-id"),
}
err := UpsertPeerExport(items)
if err == nil {
// Совпадение по auth_id приводит к обновлению существующего пира —
// это допустимая семантика upsert. Тогда проверяем, что первая запись
// применилась вместе со второй, а не вместо неё.
after := snapshotPeers(t)
if _, ok := after["newone"]; !ok {
t.Fatal("успешный импорт не применил первую запись")
}
return
}
after := snapshotPeers(t)
if len(after) != len(before) {
t.Fatalf("частичный импорт: было %d пиров, стало %d", len(before), len(after))
}
if _, ok := after["newone"]; ok {
t.Error("частично применённый импорт: пир newone остался в базе")
}
}
// Отказ подготовки (валидация партии) не должен доходить до базы вовсе.
func TestUpsertPeerExportRejectsInvalidBatchBeforeAnyWrite(t *testing.T) {
newTestDB(t)
items := []bo.PeerExport{
importItem("valid1", ""),
importItem("bad", ""), // короче 6 символов
}
if err := UpsertPeerExport(items); err == nil {
t.Fatal("партия с невалидной записью должна быть отклонена")
}
if peers := snapshotPeers(t); len(peers) != 0 {
t.Fatalf("отклонённая партия оставила записи в базе: %d", len(peers))
}
}
// Пир установщика защищён и внутри транзакции: отказ обязан откатить всё, что
// успело примениться до него.
func TestUpsertPeerExportRollsBackOnBootstrapPeer(t *testing.T) {
newTestDB(t)
seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName)
items := []bo.PeerExport{
importItem("first1", ""),
// Имя зарезервировано, поэтому валидация не пропустит его напрямую;
// совпадение достигается через auth_id установщика.
importItem("second", ReservedBootstrapPeerName),
}
if err := UpsertPeerExport(items); err == nil {
t.Fatal("импорт, задевающий пира установщика, должен быть отклонён")
}
after := snapshotPeers(t)
if _, ok := after["first1"]; ok {
t.Error("частично применённый импорт: пир first1 остался в базе")
}
}
// Обновление существующего пира без секрета в файле не должно трогать его
// секрет: иначе перенос настроек ломал бы работающие клиентские ссылки.
func TestUpsertPeerExportKeepsSecretWhenFileHasNone(t *testing.T) {
newTestDB(t)
// Обновление существующего пира разрывает его старую сессию после commit.
startTrafficStats(t, nil)
seedPeer(t, "keeper", "keeper-auth")
before := snapshotPeers(t)["keeper"]
item := importItem("keeper", "keeper-auth")
item.Remark = "updated remark"
if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil {
t.Fatalf("импорт отклонён: %v", err)
}
after := snapshotPeers(t)["keeper"]
if *after.SecretDigest != *before.SecretDigest {
t.Error("секрет пира перезаписан, хотя в файле его не было")
}
if *after.Remark != "updated remark" {
t.Errorf("remark не обновился: %q", *after.Remark)
}
}