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:
+224
-11
@@ -975,6 +975,39 @@ $piped_matcher"
|
||||
}
|
||||
' || fail "acceptance: утверждение о пройденных тестах обязано следовать за прогоном"
|
||||
|
||||
# Детектор гонок — часть прогона админки, а не пожелание.
|
||||
#
|
||||
# В продукте есть состояние, принадлежащее ПРОЦЕССУ: учёт выданных
|
||||
# разрешений на устройства и мьютекс цикла учёта. Оба существуют ровно затем,
|
||||
# чтобы вести себя правильно под параллельным доступом, и обычный `go test`
|
||||
# об их корректности не говорит ничего.
|
||||
#
|
||||
# Проверяется и то, что прогон не умеет молча пропуститься: сборка,
|
||||
# пропускающая проверку при недоступном компиляторе, выдала бы внешне
|
||||
# неотличимый production-артефакт — тот же класс, что и SKIP_TESTS.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("tools/build/lib/package.sh", "utf8");
|
||||
const admin = source.slice(source.indexOf("run_admin_tests()"));
|
||||
const adminBody = admin.slice(0, admin.search(/\n\}[\r\n]/));
|
||||
if (!adminBody.includes("run_admin_race_tests")) {
|
||||
throw new Error("прогон админки больше не включает детектор гонок");
|
||||
}
|
||||
const start = source.indexOf("run_admin_race_tests() {");
|
||||
if (start < 0) throw new Error("не найдена функция run_admin_race_tests");
|
||||
const rest = source.slice(start);
|
||||
const body = rest.slice(0, rest.search(/\n\}[\r\n]/));
|
||||
if (!body.includes("-race")) throw new Error("детектор гонок не включён");
|
||||
if (!body.includes("CGO_ENABLED=1")) {
|
||||
throw new Error("детектор гонок требует cgo, а production-сборка идёт с CGO_ENABLED=0");
|
||||
}
|
||||
if (!/command -v (cc|gcc)[\s\S]*?\|\| *fail/.test(body)) {
|
||||
throw new Error("отсутствие компилятора обязано ронять сборку, а не пропускать прогон");
|
||||
}
|
||||
for (const bypass of ["|| true", "SKIP_RACE", "continue"]) {
|
||||
if (body.includes(bypass)) throw new Error("детектор гонок можно обойти: " + bypass);
|
||||
}
|
||||
' || fail "acceptance: прогон детектора гонок обязан быть обязательным"
|
||||
|
||||
# У шага не должно быть обходов — ни объявленных, ни забытых.
|
||||
#
|
||||
# Раньше их было два: ALLOW_VULNERABLE_DEPENDENCIES=true писал в metadata
|
||||
@@ -1535,12 +1568,31 @@ run_access_revocation_acceptance() {
|
||||
# установленная QUIC-сессия сама по себе не рвётся. Официальная документация
|
||||
# описывает `/kick` и блокировку в auth backend как пару — по отдельности не
|
||||
# работает ни одна половина.
|
||||
code_has apps/service/hysteria2_api.go -F -- 'func DisconnectPeers' \
|
||||
code_has apps/service/hysteria2_api.go -F -- 'func disconnectAuthIDs' \
|
||||
|| fail "acceptance: the session disconnect primitive is missing"
|
||||
code_has apps/service/peer.go -F -- 'func disconnectAfterRevoke' \
|
||||
code_has apps/service/peer.go -F -- 'func reconcileLiveSessions' \
|
||||
|| fail "acceptance: revoking access must go through a single disconnect path"
|
||||
code_has apps/service/peer.go -F -- 'DisconnectPeers(' \
|
||||
|| fail "acceptance: revoking access never reaches the Traffic Stats /kick"
|
||||
|
||||
log_step "Acceptance: the Traffic Stats /kick has exactly one caller"
|
||||
# Пока обращений к `/kick` было два — в отзыве доступа и в cron, — они
|
||||
# расходились: у cron не было ни дедупликации, ни разбиения на части, зато
|
||||
# был POST с пустым массивом каждые 30 секунд. Один вход в Hysteria делает
|
||||
# такое расхождение невозможным.
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
const offenders = [];
|
||||
for (const file of ["apps/service/peer.go", "apps/service/cron.go", "apps/service/peer_import.go"]) {
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const source = fs.readFileSync(file, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
if (source.includes("KickUsers(")) offenders.push(file);
|
||||
}
|
||||
if (offenders.length) {
|
||||
throw new Error("the Traffic Stats /kick is reached outside disconnectAuthIDs: " + offenders.join(", "));
|
||||
}
|
||||
' || fail "acceptance: production /kick must go through disconnectAuthIDs only"
|
||||
|
||||
log_step "Acceptance: session disconnect does not write peer state"
|
||||
# Прежний Hysteria2Kick вместе с разрывом проставлял `banned_until`, поэтому
|
||||
@@ -1548,18 +1600,147 @@ run_access_revocation_acceptance() {
|
||||
# заодно временную блокировку — другой механизм с другим сроком жизни.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||
const start = source.indexOf("func DisconnectPeers");
|
||||
if (start < 0) throw new Error("DisconnectPeers is missing");
|
||||
const start = source.indexOf("func disconnectAuthIDs");
|
||||
if (start < 0) throw new Error("disconnectAuthIDs is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
for (const forbidden of ["banned_until", "disabled", "dao.UpdatePeer("]) {
|
||||
for (const forbidden of ["banned_until", "disabled", "dao.UpdatePeer(", "dao.GetPeer("]) {
|
||||
if (body.includes(forbidden)) {
|
||||
throw new Error("DisconnectPeers writes peer state: " + forbidden);
|
||||
throw new Error("disconnectAuthIDs touches peer state: " + forbidden);
|
||||
}
|
||||
}
|
||||
' || fail "acceptance: the disconnect primitive must not write peer state"
|
||||
|
||||
log_step "Acceptance: the access policy is declared once"
|
||||
# Правило доступа существовало в двух экземплярах — SQL-условием в
|
||||
# Hysteria2Auth и другим SQL-условием в cron, — и расходилось ровно на
|
||||
# границах: `quota == usage`, `quota == 0`, `now == expiresAt`,
|
||||
# `now == bannedUntil`. Пир с исчерпанной квотой не пускался заново, но его
|
||||
# живая сессия не разрывалась никогда.
|
||||
code_has apps/service/peer_access.go -F -- 'func peerAccessDenied' \
|
||||
|| fail "acceptance: the single access predicate is missing"
|
||||
"$BUN_BIN" -e '
|
||||
const fs = require("node:fs");
|
||||
const offenders = [];
|
||||
for (const file of ["apps/service/hysteria2_api.go", "apps/service/cron.go"]) {
|
||||
const source = fs.readFileSync(file, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
// Колонки политики доступа не имеют права появляться в запросах: решение
|
||||
// принимает peerAccessDenied, а не выборка.
|
||||
for (const column of ["quota_bytes", "expires_at", "banned_until"]) {
|
||||
if (source.includes(column)) offenders.push(file + ":" + column);
|
||||
}
|
||||
}
|
||||
if (offenders.length) {
|
||||
throw new Error("the access policy is back inside SQL: " + offenders.join(", "));
|
||||
}
|
||||
' || fail "acceptance: the access policy must live in peerAccessDenied, not in SQL"
|
||||
|
||||
log_step "Acceptance: the account cron job belongs to the scheduler"
|
||||
# `CronHandleAccount -> go func() -> go saveAccountTraffic; go kickAccount`
|
||||
# заканчивалась для планировщика почти мгновенно, поэтому StopCron не ждал
|
||||
# настоящей работы: releaseResource закрывал SQLite, а горутины продолжали
|
||||
# писать в закрытое соединение.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/cron.go", "utf8");
|
||||
const start = source.indexOf("func CronHandleAccount");
|
||||
if (start < 0) throw new Error("CronHandleAccount is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = (end < 0 ? rest : rest.slice(0, end))
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
if (/\bgo\s+(func|[A-Za-z_])/.test(body)) {
|
||||
throw new Error("the account cron job still detaches its work into goroutines");
|
||||
}
|
||||
' || fail "acceptance: the account cron job must run synchronously"
|
||||
|
||||
log_step "Acceptance: systemd opinion drives no cron or access decision"
|
||||
# util.Exec схлопывает «systemctl вернул 3» и «запустить systemctl не
|
||||
# удалось» в одну ошибку, поэтому на этом значении нельзя строить решения:
|
||||
# сломанный systemctl при живой Hysteria молча отключал учёт и enforcement.
|
||||
! code_has apps/service/cron.go -F -- 'Hysteria2IsRunning' \
|
||||
|| fail "acceptance: the account cron job must not gate on the systemd state"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/hysteria2_api.go", "utf8");
|
||||
const start = source.indexOf("func Hysteria2Auth");
|
||||
if (start < 0) throw new Error("Hysteria2Auth is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = (end < 0 ? rest : rest.slice(0, end))
|
||||
.split("\n")
|
||||
.filter((line) => !/^\s*\/\//.test(line))
|
||||
.join("\n");
|
||||
if (body.includes("Hysteria2IsRunning")) {
|
||||
throw new Error("auth gates on the systemd state");
|
||||
}
|
||||
' || fail "acceptance: auth must not gate on the systemd state"
|
||||
|
||||
log_step "Acceptance: concurrent auth cannot exceed the device limit"
|
||||
# Между чтением `/online` и ответом «allow» место ничем не удерживалось:
|
||||
# два одновременных запроса при `online = max-1` получали разрешение оба.
|
||||
code_has apps/service/peer_admission.go -F -- 'func reserveDeviceSlot' \
|
||||
|| fail "acceptance: the device admission tracker is missing"
|
||||
code_has apps/service/hysteria2_api.go -F -- 'reserveDeviceSlot(' \
|
||||
|| fail "acceptance: auth does not reserve a device slot"
|
||||
|
||||
log_step "Acceptance: deleting a peer revokes access before removing the row"
|
||||
# `return dao.DeletePeer(...)` убирал строку вместе с auth_id — то есть
|
||||
# вместе с единственным, чем можно было бы завершить живую сессию.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||||
const start = source.indexOf("func DeletePeer");
|
||||
if (start < 0) throw new Error("DeletePeer is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
const reconcile = body.indexOf("reconcileLiveSessions(");
|
||||
const remove = body.indexOf("dao.DeletePeer(");
|
||||
if (reconcile < 0) throw new Error("DeletePeer does not terminate live sessions");
|
||||
if (remove < 0) throw new Error("DeletePeer does not remove the row");
|
||||
if (reconcile > remove) throw new Error("DeletePeer removes the row before terminating the session");
|
||||
' || fail "acceptance: peer deletion must terminate the session before dropping the row"
|
||||
|
||||
log_step "Acceptance: peer import reconciles live sessions after commit"
|
||||
# Импорт переписывает credential- и access-состояние целиком, включая
|
||||
# auth_id. Старое значение существует только ДО commit, а разрывать сессию
|
||||
# можно только ПОСЛЕ него.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8");
|
||||
const start = source.indexOf("func UpsertPeerExport");
|
||||
if (start < 0) throw new Error("UpsertPeerExport is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
const tx = body.indexOf("dao.WithPeerTx(");
|
||||
const reconcile = body.indexOf("reconcileLiveSessions(");
|
||||
if (reconcile < 0) throw new Error("peer import never reconciles live sessions");
|
||||
if (tx < 0 || reconcile < tx) throw new Error("peer import reconciles before the transaction");
|
||||
' || fail "acceptance: peer import must reconcile live sessions after commit"
|
||||
|
||||
log_step "Acceptance: delete and import report structured service errors"
|
||||
# Обе операции умеют завершиться ЧАСТИЧНО — состояние применено, живую
|
||||
# сессию завершить не удалось. Через vo.Fail такой исход уезжал бы панели
|
||||
# неотличимо от полного отказа.
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("apps/controller/peer.go", "utf8");
|
||||
for (const [handler, call] of [["func DeletePeer", "service.DeletePeer("], ["func ImportPeer", "service.UpsertPeerExport("]]) {
|
||||
const start = source.indexOf(handler);
|
||||
if (start < 0) throw new Error(handler + " is missing");
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nfunc ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
if (!body.includes(call)) throw new Error(handler + " no longer calls " + call);
|
||||
if (!body.includes("failService(err, c)")) {
|
||||
throw new Error(handler + " reports a partial result as a plain failure");
|
||||
}
|
||||
}
|
||||
' || fail "acceptance: delete and import must use the structured service error path"
|
||||
|
||||
log_step "Acceptance: a partial revocation is reported by code, not by prose"
|
||||
# Долговременная запись к этому моменту уже применена и НЕ откатывается:
|
||||
# достигнутое безопасное состояние нельзя отменять из-за неудачи второго
|
||||
@@ -1603,9 +1784,41 @@ run_access_revocation_acceptance() {
|
||||
if (/[^a-z0-9_]Hysteria2Online\(\)/.test(code)) {
|
||||
throw new Error("auth takes the display-tolerant online path");
|
||||
}
|
||||
const failOpen = /err != nil \{[\s\S]*?return \*peer\.Id/;
|
||||
if (failOpen.test(code)) {
|
||||
throw new Error("auth still returns success from the /online failure branch");
|
||||
// Старая дыра имела конкретную форму: возврат УСПЕХА изнутри ветки
|
||||
// обработки ошибки.
|
||||
//
|
||||
// onlineUsers, err := Hysteria2Online()
|
||||
// if err != nil {
|
||||
// logrus.WithError(err).Warn(...)
|
||||
// return *peer.Id, *peer.AuthId, nil
|
||||
// }
|
||||
//
|
||||
// Здесь стояла регулярка `err != nil \{[\s\S]*?return \*peer\.Id`, и она
|
||||
// была неверна: ленивый `[\s\S]*?` свободно пересекает границы блоков,
|
||||
// поэтому она срабатывала на ЛЮБОЙ функции, где после какой-нибудь
|
||||
// проверки ошибки где-то ниже стоит успешный возврат, — то есть на
|
||||
// правильном коде тоже. Гейт, который падает на корректной реализации,
|
||||
// не проверяет ничего: его нельзя удовлетворить, не сломав продукт.
|
||||
//
|
||||
// Тело ветки выделяется по балансу фигурных скобок — тогда «внутри
|
||||
// ветки» действительно означает внутри ветки.
|
||||
const failureBranches = [];
|
||||
for (let at = code.indexOf("if err != nil {"); at >= 0; at = code.indexOf("if err != nil {", at + 1)) {
|
||||
let depth = 0;
|
||||
let end = at;
|
||||
for (let i = code.indexOf("{", at); i < code.length; i++) {
|
||||
if (code[i] === "{") depth++;
|
||||
else if (code[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) { end = i; break; }
|
||||
}
|
||||
}
|
||||
failureBranches.push(code.slice(at, end + 1));
|
||||
}
|
||||
for (const branch of failureBranches) {
|
||||
if (/return \*peer\.Id/.test(branch)) {
|
||||
throw new Error("auth still returns success from an error branch");
|
||||
}
|
||||
}
|
||||
' || fail "acceptance: the device limit must be fail-closed"
|
||||
|
||||
|
||||
@@ -96,10 +96,39 @@ run_admin_tests() {
|
||||
GOTOOLCHAIN=local "$GO_BIN" test ./...
|
||||
) || fail "HY2XS admin contract tests failed"
|
||||
|
||||
run_admin_race_tests
|
||||
|
||||
ADMIN_TESTS_PASSED="true"
|
||||
export ADMIN_TESTS_PASSED
|
||||
}
|
||||
|
||||
# Детектор гонок — отдельный прогон, и он ОБЯЗАТЕЛЕН.
|
||||
#
|
||||
# В продукте есть состояние, принадлежащее процессу, а не базе: учёт выданных
|
||||
# разрешений на устройства (peer_admission.go) и мьютекс цикла учёта
|
||||
# (cron.go). Оба существуют ровно затем, чтобы вести себя правильно под
|
||||
# параллельным доступом, — то есть их корректность не наблюдаема ни в обычном
|
||||
# прогоне тестов, ни в `go vet`.
|
||||
#
|
||||
# Пропуск при недоступном C-компиляторе здесь НЕ предусмотрен, и это осознанно.
|
||||
# «Тихо не проверили» — ровно тот класс, от которого лечится запрет SKIP_TESTS
|
||||
# в этом же файле: сборка, молча пропускающая проверку, выдаёт внешне
|
||||
# неотличимый production-артефакт. build-essential входит в обязательные
|
||||
# зависимости сборщика (см. tools/build/lib/deps.sh), поэтому попасть в ветку
|
||||
# отказа можно только на сломанном окружении — и об этом нужно узнать.
|
||||
#
|
||||
# CGO_ENABLED=1 задаётся явно: production-сборка админки идёт с CGO_ENABLED=0,
|
||||
# и унаследованное значение отключило бы детектор.
|
||||
run_admin_race_tests() {
|
||||
command -v cc >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 \
|
||||
|| fail "race detector requires a C toolchain (build-essential); refusing to ship untested concurrency"
|
||||
|
||||
(
|
||||
cd "${UI_SRC:-apps}"
|
||||
CGO_ENABLED=1 GOTOOLCHAIN=local "$GO_BIN" test -race ./service/...
|
||||
) || fail "HY2XS admin race detector found a data race"
|
||||
}
|
||||
|
||||
build_orchestrator() {
|
||||
local bun_compile_target="${BUN_COMPILE_TARGET:-bun-linux-x64}"
|
||||
|
||||
|
||||
@@ -401,6 +401,30 @@ describe("действия над пиром", () => {
|
||||
expect(source).toContain("ElMessage.warning");
|
||||
});
|
||||
|
||||
// Импорт — такая же операция с частичным исходом, как и действия строки:
|
||||
// партия применена целиком, а старые сессии обновлённых пиров завершить не
|
||||
// удалось.
|
||||
//
|
||||
// Раньше у него не было ни try, ни catch: отказ уходил необработанным
|
||||
// отклонением промиса, а обновление списка до выполнения не доходило —
|
||||
// оператор видел прежние данные при уже изменённой базе.
|
||||
test("импорт разбирает свой исход и обновляет список при любом", () => {
|
||||
const source = codeOf(peerList());
|
||||
|
||||
const start = source.indexOf("async function handleImport");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const rest = source.slice(start + 1);
|
||||
const end = rest.indexOf("\nasync function ");
|
||||
const body = end < 0 ? rest : rest.slice(0, end);
|
||||
|
||||
expect(body).toContain("reportPeerActionError");
|
||||
// Обновление списка стоит ПОСЛЕ разбора исхода, а не внутри успешной
|
||||
// ветки: при частичном результате состояние в базе уже изменилось.
|
||||
const handled = body.indexOf("reportPeerActionError");
|
||||
const refreshed = body.indexOf("handleQuery()");
|
||||
expect(refreshed).toBeGreaterThan(handled);
|
||||
});
|
||||
|
||||
// Срок временной блокировки называется оператору: раньше `Date.now() + час`
|
||||
// был зашит в обработчик и не сообщался ни до, ни после.
|
||||
test("временная блокировка подтверждается и называет срок", () => {
|
||||
@@ -423,6 +447,12 @@ describe("действия над пиром", () => {
|
||||
"kickPeerApi",
|
||||
"updatePeerApi",
|
||||
"savePeerApi",
|
||||
// Импорт применяет партию одной транзакцией, а старые сессии
|
||||
// обновлённых пиров завершает после её фиксации. Второй шаг умеет не
|
||||
// удаться отдельно от первого, и тогда общий перехватчик показал бы
|
||||
// частичный результат красной ошибкой — сообщив оператору обратное
|
||||
// тому, что произошло.
|
||||
"importPeerApi",
|
||||
];
|
||||
|
||||
for (const name of selfReporting) {
|
||||
|
||||
Reference in New Issue
Block a user