#!/usr/bin/env bash set -euo pipefail run_fix20_acceptance_subset() { local package_dir="$1" [ -d "$package_dir" ] || fail "acceptance: package dir not found: $package_dir" log_step "Acceptance: package layout sanity" [ -x "$package_dir/install.sh" ] || fail "acceptance: install.sh is missing or not executable" [ -x "$package_dir/orchestrator/hy2xs-orchestrator" ] || fail "acceptance: orchestrator artifact is missing" log_step "Acceptance: project license is shipped with the package" [ -f "$package_dir/LICENSE" ] || fail "acceptance: LICENSE is missing from the package" grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' "$package_dir/LICENSE" \ || fail "acceptance: packaged LICENSE must be AGPL-3.0-only" grep -q '^license=AGPL-3.0-only$' "$package_dir/metadata/package.env" \ || fail "acceptance: package metadata must declare license=AGPL-3.0-only" log_step "Acceptance: orchestrator CLI help path" "$package_dir/orchestrator/hy2xs-orchestrator" diagnostics collect --package-dir "$package_dir" >/dev/null 2>&1 || true log_step "Acceptance: firewall mode defaults in config" grep -q '^HY2XS_FIREWALL_MODE=' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FIREWALL_MODE missing in runtime config" log_step "Acceptance: baseline domain/public host/ssh defaults" grep -q '^HY2XS_DOMAIN=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_DOMAIN must default to fi.api.withen.pro" grep -q '^HY2XS_PUBLIC_HOST=fi.api.withen.pro$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_PUBLIC_HOST must default to fi.api.withen.pro" grep -q '^HY2XS_SSH_PORT=2323$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_SSH_PORT must default to 2323" log_step "Acceptance: force password change production default" grep -q '^HY2XS_FORCE_PASSWORD_CHANGE=false$' "$package_dir/config/hy2xs.env" || fail "acceptance: HY2XS_FORCE_PASSWORD_CHANGE must default to false" log_step "Acceptance: config schema version is declared" # Значение берётся из versions.env, а не пишется числом: захардкоженная # двойка означала бы, что при переходе на schema 3 нужно помнить ещё и про # эту строку. Источник истины у схемы ровно один. grep -q "^HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}\$" "$package_dir/config/hy2xs.env" \ || fail "acceptance: packaged baseline must declare HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}" log_step "Acceptance: public endpoint policy is declared and strict by default" grep -q '^HY2XS_PUBLIC_ENDPOINT_POLICY=strict$' "$package_dir/config/hy2xs.env" \ || fail "acceptance: packaged baseline must default to HY2XS_PUBLIC_ENDPOINT_POLICY=strict" log_step "Acceptance: fresh install defaults to Gecko obfuscation" grep -q '^HY2XS_HYSTERIA_OBFS_TYPE=gecko$' "$package_dir/config/hy2xs.env" \ || fail "acceptance: fresh installations must default to HY2XS_HYSTERIA_OBFS_TYPE=gecko" log_step "Acceptance: obfs type is not hardcoded in the Hysteria template" grep -q '{{OBFS_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: hysteria template must render the obfs block from the orchestrator" ! grep -Eq '^\s*type:\s*(gecko|salamander)\s*$' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: hysteria template must not hardcode an obfs type" log_step "Acceptance: modern server baseline is present in the template" grep -q 'disableLossCompensation: {{DISABLE_LOSS_COMPENSATION}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: bandwidth.disableLossCompensation missing from hysteria template" grep -q '{{CONGESTION_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: congestion block missing from hysteria template" grep -q '{{QUIC_BLOCK}}' "$package_dir/templates/hysteria/config.yaml.tpl" \ || fail "acceptance: quic block missing from hysteria template" log_step "Acceptance: post-install env derives obfs type from resolved context" grep -q '^HY2_OBFS_TYPE={{OBFS_TYPE}}$' "$package_dir/templates/env/post-install.env.tpl" \ || fail "acceptance: post-install env must render the resolved obfs type, not a second set of defaults" ! grep -Eq '^HY2_OBFS_TYPE=(gecko|salamander)$' "$package_dir/templates/env/post-install.env.tpl" \ || fail "acceptance: post-install env must not hardcode an obfs type" log_step "Acceptance: production defaults are declared in exactly one module" grep -q 'DEFAULT_HYSTERIA_OBFS_TYPE' orchestrator/src/config/profile.ts \ || fail "acceptance: the default obfs type must be declared in orchestrator/src/config/profile.ts" local default_declarations default_declarations="$(grep -rl 'DEFAULT_HYSTERIA_OBFS_TYPE\s*[:=]' orchestrator/src \ | grep -v 'orchestrator/src/config/profile.ts' || true)" [ -z "$default_declarations" ] \ || fail "acceptance: the default obfs type must not be re-declared in: $default_declarations" log_step "Acceptance: runtime env is derived from config, not literals" ! grep -Eq 'HY2XS_HYSTERIA_OBFS_TYPE=(gecko|salamander)' orchestrator/src/config/env.ts \ || fail "acceptance: renderRuntimeEnv must not print a hardcoded obfs type" grep -q 'HY2XS_HYSTERIA_OBFS_TYPE=\${config.hysteriaObfsType}' orchestrator/src/config/env.ts \ || fail "acceptance: renderRuntimeEnv must derive the obfs type from the parsed config" ! grep -Eq '\|\|\s*"(gecko|salamander)"' orchestrator/src/config/env.ts \ || fail "acceptance: env.ts must not carry its own obfs fallback default" log_step "Acceptance: package metadata records how the Hysteria version was chosen" grep -q '^hysteria_resolution=' "$package_dir/metadata/package.env" \ || fail "acceptance: metadata must record hysteria_resolution" grep -q '^hysteria_resolved_at=' "$package_dir/metadata/package.env" \ || fail "acceptance: metadata must record hysteria_resolved_at" grep -q '^hysteria_compat_gate=true$' "$package_dir/metadata/package.env" \ || fail "acceptance: release packages must be built with the Hysteria compatibility gate enabled" grep -Eq '^hysteria_artifact_url=https://github\.com/HyNetworks/hysteria/' "$package_dir/metadata/package.env" \ || fail "acceptance: Hysteria artifact must come from the canonical HyNetworks upstream" log_step "Acceptance: install-time never resolves a moving latest" ! grep -rq 'api.github.com' orchestrator/src/commands orchestrator/src/steps \ || fail "acceptance: install-time code must not query the upstream release API" ! grep -rq 'download.hysteria.network' orchestrator/src \ || fail "acceptance: install-time code must not use the moving latest download URL" log_step "Acceptance: smoke verifies config semantics, not substrings" grep -q 'assertHysteriaConfigMatchesProfile' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke must verify the effective config semantically" log_step "Acceptance: admin export preserves unknown upstream fields and strips secrets" grep -q 'ExportHysteria2ConfigYaml' apps/controller/config.go \ || fail "acceptance: hysteria config export must go through the sanitizing raw-YAML path" grep -q 'GetRawHysteria2Config' apps/service/hysteria2_export.go \ || fail "acceptance: export must read the raw YAML instead of the typed model" log_step "Acceptance: frontend ACME registry matches current upstream" # Ищем именно предлагаемое значение, а не упоминание в комментарии. ! grep -q '"namedotcom"' apps/frontend/src/views/hysteria/list/index.vue \ || fail "acceptance: namedotcom was removed upstream in Hysteria 2.11.0 and must not be offered" local provider for provider in cloudflare duckdns gandi godaddy namecheap njalla porkbun vultr; do grep -q "\"${provider}\"" apps/frontend/src/views/hysteria/list/index.vue \ || fail "acceptance: ACME DNS provider ${provider} is missing from the UI registry" done log_step "Acceptance: systemd unit production env" grep -q '^Environment=GIN_MODE=release$' "$package_dir/systemd/hy2xs-admin.service" || fail "acceptance: GIN_MODE=release missing" log_step "Acceptance: docs matrix markers" grep -q 'Fix20 production matrix' docs/11-testing-and-acceptance.md || fail "acceptance: fix20 matrix section missing" log_step "Acceptance: machine auth URL in templates" grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' "$package_dir/templates/hysteria/config.yaml.tpl" || fail "acceptance: machine token missing in hysteria auth URL template" grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' "$package_dir/templates/env/post-install.env.tpl" || fail "acceptance: machine token missing in post-install HY2_AUTH_URL" log_step "Acceptance: smoke auth checks are tokenized" grep -q 'unexpected auth status without machine token' orchestrator/src/steps/smoke.ts || fail "acceptance: missing 403 negative smoke for auth without machine token" # Проверяется контракт, а не литерал URL. # # Регрессия приёмки: здесь стоял grep по строке # `hysteria2/auth?access_token=${...}`. После переезда machine-auth на # /internal/hysteria/auth и централизации пути в профиле такой строки в # smoke.ts не существует — приёмка падала на корректном коде, причём в самом # конце сборки, внутри verify_archive. Единственный источник истины у пути # один, поэтому и проверять нужно обращение к нему. grep -q 'HYSTERIA_MACHINE_AUTH_PATH' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke must take the machine-auth path from the production profile" grep -q 'hysteriaMachineAuthUrl(' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke auth URL must be built by the production helper (tokenized)" ! grep -Eq 'access_token=' orchestrator/src/steps/smoke.ts \ || fail "acceptance: smoke must not assemble the machine token into a URL by hand" log_step "Acceptance: bootstrap peer can pass auth smoke" grep -q 'quota := int64(-1)' apps/dao/sqlite.go || fail "acceptance: bootstrap peer quota must be unlimited (-1), otherwise install auth smoke fails" log_step "Acceptance: frontend i18n does not touch Pinia at module import" ! grep -q 'useAppStore' apps/frontend/src/lang/index.ts || fail "acceptance: lang/index.ts must not import/use Pinia store" log_step "Acceptance: env rendering maps machine token and fails on unresolved placeholders" grep -q 'HYSTERIA_API_SECRET: context.config.hysteriaTrafficStatsSecret' orchestrator/src/steps/env.ts || fail "acceptance: writePostInstallEnv must pass HYSTERIA_API_SECRET" grep -q 'template render failed: unresolved placeholders' orchestrator/src/lib/fs.ts || fail "acceptance: renderTemplate must fail on unresolved placeholders" run_clean_install_acceptance "$package_dir" } # Приёмка политики clean-install-only и связанных с ней инвариантов. run_clean_install_acceptance() { local package_dir="$1" log_step "Acceptance: installer runs a read-only preflight before touching the host" grep -q 'preflight-install' "$package_dir/install.sh" \ || fail "acceptance: install.sh must run the read-only preflight before mutating the host" grep -q 'PHASE 0' "$package_dir/install.sh" \ || fail "acceptance: install.sh must document the read-only phase boundary" grep -q 'preflightInstall' orchestrator/src/cli.ts \ || fail "acceptance: orchestrator must expose the preflight-install command" log_step "Acceptance: the read-only phase is enforced by a guard, not by convention" grep -q 'enableReadOnlyGuard' orchestrator/src/commands/preflight-install.ts \ || fail "acceptance: preflight-install must enable the read-only guard" grep -q 'assertMutationAllowed' orchestrator/src/lib/fs.ts \ || fail "acceptance: fs writes must be guarded during the read-only phase" grep -q 'assertMutationAllowed' orchestrator/src/lib/process.ts \ || fail "acceptance: mutating runners must be guarded during the read-only phase" log_step "Acceptance: preflight passes before the first install-state write" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const preflight = source.indexOf("await preflight(context"); const state = source.indexOf("await advanceInstallState("); if (preflight < 0 || state < 0) { throw new Error("could not locate preflight/advanceInstallState in install.ts"); } if (preflight > state) { throw new Error("install writes install-state before preflight"); } ' || fail "acceptance: install must not write install-state before a successful preflight" log_step "Acceptance: rollback is ownership-aware, not message-driven" grep -q 'classifyFailure(ownership' orchestrator/src/commands/install.ts \ || fail "acceptance: failure classification must be driven by ownership, not by the error message" grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \ || fail "acceptance: rollback must never stop services it did not deploy" log_step "Acceptance: a written install-state already makes the failure post-apply" # Регрессия: classifyFailure не учитывал stateWritten, поэтому падение # apt-get объявлялось «на сервере ничего не изменено», rollback пропускался, # а install-state.json оставался на хосте и ломал следующую установку. grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \ || fail "acceptance: classifyFailure must account for a written install-state" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const body = source.slice(source.indexOf("export function classifyFailure")); const preApply = body.indexOf("return \"fatal_pre_apply\""); const stateWritten = body.indexOf("ownership.stateWritten"); if (preApply < 0 || stateWritten < 0) { throw new Error("could not locate classifyFailure branches"); } if (stateWritten > preApply) { throw new Error("stateWritten is checked after the fatal_pre_apply fallback"); } ' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was written" log_step "Acceptance: mutating ownership flags are raised before the step, not after" # Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить # систему и упасть. Каждый флаг обязан стоять ПЕРЕД своим await. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const steps = [ ["depsTouched", "await installDeps("], ["filesystemTouched", "await prepareFilesystem("], ["uiTouched", "await deployUi("], ["hysteriaTouched", "await installHysteria("], ["configTouched", "await generateConfig("], ["unitsTouched", "await deploySystemd("], ["firewallTouched", "await applyFirewall("], ["postInstallTouched", "await writePostInstallEnv("], ["bootstrapSecretTouched", "await ensureBootstrapAdminSecret("] ]; for (const [flag, call] of steps) { const flagAt = source.indexOf("ownership." + flag + " = true"); const callAt = source.indexOf(call); if (flagAt < 0) throw new Error("missing ownership flag: " + flag); if (callAt < 0) throw new Error("missing step call: " + call); if (flagAt > callAt) throw new Error(flag + " is raised after " + call); } ' || fail "acceptance: ownership flags must be raised before the mutating step they cover" log_step "Acceptance: the read-only phase has no universal runner to slip through" # Пока существовал один `run`, под которым жили и `ss -ltn`, и `useradd`, # guard держался на внимательности автора правки. grep -q 'export async function runReadOnly' orchestrator/src/lib/process.ts \ || fail "acceptance: process.ts must expose an explicit read-only runner" grep -q 'export async function runMutating' orchestrator/src/lib/process.ts \ || fail "acceptance: process.ts must expose an explicit mutating runner" ! grep -rEq '(^|[^A-Za-z0-9_])(run|runVisible|runHidden|runSecret|runRawVisible)`' orchestrator/src \ || fail "acceptance: the pre-split runner names must not come back" local unguarded_runner unguarded_runner="$(grep -c 'assertMutationAllowed' orchestrator/src/lib/process.ts || true)" [ "$unguarded_runner" -ge 4 ] \ || fail "acceptance: every mutating runner must ask the read-only guard for permission" log_step "Acceptance: the public endpoint invariant lives in preflight, not only in doctor" grep -q 'assertPublicEndpoint' orchestrator/src/steps/preflight.ts \ || fail "acceptance: preflight must verify that the public endpoint resolves to this server" [ -f orchestrator/src/steps/networkEndpoint.ts ] \ || fail "acceptance: the public endpoint invariant module is missing" ! grep -rqE 'ifconfig\.me|ipify|checkip\.amazonaws' orchestrator/src \ || fail "acceptance: the server address must be resolved locally, not via an external service" grep -q 'networkInterfaces' orchestrator/src/steps/networkEndpoint.ts \ || fail "acceptance: local public IPv4 set must come from the host interfaces" log_step "Acceptance: purge and clean-host describe the same boundary" local purged_path for purged_path in /var/lib/hysteria /usr/local/lib/hy2xs /usr/local/bin/hy2xs-orchestrator /var/log/hy2xs; do grep -qF "$purged_path" tools/legacy/purge-v0.sh \ || fail "acceptance: purge-v0.sh no longer removes $purged_path" done grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker" # Symlink оркестратора остаётся маркером, но путь объявлен в профиле: его # создаёт steps/bootstrap.ts, и две копии строки разошлись бы. grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must treat a leftover orchestrator symlink as a legacy marker" ! grep -q 'keep-hysteria-binary' tools/legacy/purge-v0.sh \ || fail "acceptance: --keep-hysteria-binary contradicts the installer clean-host contract" log_step "Acceptance: admin secrets never reach a persistent export file" # Экспорт формируется в памяти: os.Create в /var/lib/hy2xs-admin/export # оставлял на диске JSON с расшифрованными секретами пиров. [ ! -f apps/util/export.go ] \ || fail "acceptance: the file-based export helper came back" ! grep -rq 'ExportPathDir' apps/cmd apps/controller apps/service apps/dao apps/model apps/util \ || fail "acceptance: the persistent export directory came back" grep -q 'c.Data(200, "application/octet-stream", payload)' apps/controller/peer.go \ || fail "acceptance: peer export must be served from memory" log_step "Acceptance: peer import is validated as strictly as peer creation" grep -q 'ValidatePeerImportBatch' apps/service/peer.go \ || fail "acceptance: peer import must validate the whole batch before writing" grep -q 'DisallowUnknownFields' apps/controller/peer.go \ || fail "acceptance: peer import must reject unknown fields instead of silently defaulting" grep -q 'ReservedBootstrapPeerName' apps/service/peer_import.go \ || fail "acceptance: peer import must refuse to overwrite the installer-owned bootstrap peer" log_step "Acceptance: missing config schema marker is treated as legacy" grep -q 'HY2XS_CONFIG_SCHEMA_VERSION отсутствует' orchestrator/src/config/env.ts \ || fail "acceptance: a missing HY2XS_CONFIG_SCHEMA_VERSION must be rejected as legacy, not defaulted" log_step "Acceptance: reconfigure/repair verify the installation generation" grep -q 'assertCurrentGeneration' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure/repair must verify release line and config schema in install-state" grep -q 'release_line' orchestrator/src/lib/installState.ts \ || fail "acceptance: install-state must carry the product release line" log_step "Acceptance: diagnostics redaction is structural" grep -q 'Bun.YAML.parse' orchestrator/src/lib/redaction.ts \ || fail "acceptance: YAML redaction must walk the document, not match lines" # Регрессия: правило `.replace(/(auth:\s*).*/gi, ...)` подставляло маркер в # заголовок mapping'а и оставляло вложенный auth.http.url с machine token. ! grep -qF 'replace(/(auth:' orchestrator/src/lib/redaction.ts \ || fail "acceptance: the line-based auth redaction rule leaked nested auth.http.url" log_step "Acceptance: dead updater/config-write routes stay removed" # Ищется регистрация маршрута (имя в кавычках), а не любое упоминание: # комментарий, объясняющий, почему маршрута нет, должен быть разрешён. # # Тесты исключены по той же причине, и это не послабление: router_test.go # ПЕРЕЧИСЛЯЕТ удалённые имена, потому что проверяет их отсутствие в таблице # маршрутов. Пока `*_test.go` попадал под скан, приёмка падала на собственном # регрессионном тесте — то есть добавление теста, закрепляющего удаление # маршрута, ломало сборку. local dead_route for dead_route in hysteria2ChangeVersion listRelease updateHysteria2Config importHysteria2Config restartServer uploadCertFile hysteria2AcmePath exportConfig importConfig getConfig; do ! grep -rqF --include='*.go' --exclude='*_test.go' "${dead_route}\"" apps/router apps/controller \ || fail "acceptance: removed route ${dead_route} came back" ! grep -rqF "${dead_route}\"" apps/frontend/src/api \ || fail "acceptance: frontend still calls the removed route ${dead_route}" done log_step "Acceptance: e2e uses the production share URI generator" grep -q 'tools/share-uri' tools/test/e2e-hysteria.sh \ || fail "acceptance: e2e must build the share URI with production code" ! grep -q 'build_share_uri' tools/test/e2e-hysteria.sh \ || fail "acceptance: the bash share-URI implementation must not come back" log_step "Acceptance: upstream checksums are verified before the lock is written" grep -q 'resolve_expected_sha_from_upstream' tools/build/lib/hysteria.sh \ || fail "acceptance: the Hysteria artifact must be verified against upstream hashes.txt" log_step "Acceptance: toolchain checksums live in versions.env" grep -q '^BUN_LINUX_X64_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Bun x64 artifact" grep -q '^BUN_LINUX_X64_BASELINE_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Bun baseline artifact" grep -q '^GO_LINUX_AMD64_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Go toolchain archive" grep -q '^NODE_LINUX_X64_SHA256=' versions.env \ || fail "acceptance: versions.env must pin the Node.js archive" ! grep -q '^HYSTERIA_VERSION=' versions.env \ || fail "acceptance: versions.env declares the Hysteria policy, not a pinned version" log_step "Acceptance: admin version comes from the build contract" grep -q 'var Version = "dev"' apps/model/constant/system.go \ || fail "acceptance: admin version must be injected via ldflags, not hardcoded" grep -q 'constant.Version=v' tools/build/lib/package.sh \ || fail "acceptance: the build must inject the admin version from versions.env" log_step "Acceptance: legacy cleanup is a separate, explicit helper" [ -f tools/legacy/purge-v0.sh ] || fail "acceptance: legacy cleanup helper is missing" [ -f docs/14-legacy-cleanup.md ] || fail "acceptance: legacy cleanup runbook is missing" ! grep -q 'purge-v0' "$package_dir/install.sh" \ || fail "acceptance: the installer must never run destructive cleanup on its own" run_single_owner_acceptance "$package_dir" run_secret_channel_acceptance run_atomic_import_acceptance run_legacy_account_acceptance } # PHASE 1 принадлежит оркестратору целиком. run_single_owner_acceptance() { local package_dir="$1" log_step "Acceptance: install.sh does not mutate the host at all" local mutation_hits mutation_hits="$(grep -nE '^[[:space:]]*(install|ln|cp|mv|rm|mkdir|chown|chmod|systemctl|apt-get|useradd|groupadd|nft|tee)[[:space:]]' \ "$package_dir/install.sh" || true)" [ -z "$mutation_hits" ] \ || fail "acceptance: install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone: $mutation_hits" grep -q 'exec .*install --package-dir' "$package_dir/install.sh" \ || fail "acceptance: install.sh must hand the mutating phase over via exec" log_step "Acceptance: the orchestrator owns its own bootstrap" [ -f orchestrator/src/steps/bootstrap.ts ] \ || fail "acceptance: the bootstrap step module is missing" grep -q 'ownership.bootstrapTouched' orchestrator/src/commands/install.ts \ || fail "acceptance: bootstrap must be covered by an ownership flag" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const flagAt = source.indexOf("ownership.bootstrapTouched = true"); const callAt = source.indexOf("await bootstrapRuntime("); const depsAt = source.indexOf("await installDeps("); if (flagAt < 0 || callAt < 0) throw new Error("bootstrap step is not wired into install"); if (flagAt > callAt) throw new Error("bootstrapTouched is raised after bootstrapRuntime"); if (callAt > depsAt) throw new Error("bootstrap must run before installDeps"); ' || fail "acceptance: bootstrap must be the first owned mutating step" log_step "Acceptance: bootstrap paths are declared once and are clean-host markers" grep -q 'ORCHESTRATOR_SYMLINK_PATH' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must take bootstrap paths from the production profile" grep -qF '/usr/local/bin/hy2xs-orchestrator' orchestrator/src/config/profile.ts \ || fail "acceptance: the orchestrator symlink path must be declared in the profile" grep -qF '/var/lib/hysteria' orchestrator/src/steps/cleanHost.ts \ || fail "acceptance: clean-host must treat leftover Hysteria runtime state as a legacy marker" log_step "Acceptance: clean-host is checked once, before the first mutation" # Регрессия: preflight вызывался дважды и оба раза проверял clean-host. # Ко второму разу на диске лежал собственный install-state.json, и каждая # чистая установка падала сразу после apt-get. ! grep -rq 'cleanHostPhase' orchestrator/src \ || fail "acceptance: the two-tier clean-host phase hack must not come back" grep -q 'checkCleanHost' orchestrator/src/steps/preflight.ts \ || fail "acceptance: preflight must take clean-host as an explicit decision" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const enabled = source.split("checkCleanHost: true").length - 1; const disabled = source.split("checkCleanHost: false").length - 1; if (enabled !== 1) throw new Error("clean-host must be requested exactly once, got " + enabled); if (disabled !== 1) throw new Error("the capabilities pass must opt out explicitly"); const at = source.indexOf("checkCleanHost: true"); const state = source.indexOf("await advanceInstallState("); if (at > state) throw new Error("clean-host is checked after the first install-state write"); ' || fail "acceptance: clean-host must be an entry condition, checked exactly once" log_step "Acceptance: diagnostics never block rollback" local command_file for command_file in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do grep -q 'catch (diagnosticsError)' "$command_file" \ || fail "acceptance: diagnostics must be best effort in $command_file" done "$BUN_BIN" -e ' const fs = require("node:fs"); for (const file of [ ["orchestrator/src/commands/install.ts", "await rollbackFailedInstall("], ["orchestrator/src/commands/reconfigure.ts", "await rollbackCurrentState()"] ]) { const source = fs.readFileSync(file[0], "utf8"); const diagnostics = source.indexOf("await diagnosticsCollect(options)"); const guard = source.indexOf("catch (diagnosticsError)"); const rollback = source.indexOf(file[1]); if (diagnostics < 0 || guard < 0 || rollback < 0) throw new Error("missing markers in " + file[0]); if (!(diagnostics < guard && guard < rollback)) { throw new Error("diagnostics is not guarded before rollback in " + file[0]); } } ' || fail "acceptance: a diagnostics failure must never cancel the rollback" log_step "Acceptance: reconfigure classifies by ownership, not by message text" ! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure must not classify failures by matching the error text" grep -q 'classifyReconfigureFailure(ownership)' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure failure classification must be ownership-driven" } # Каналы утечки секретов: Config API и журналы. run_secret_channel_acceptance() { log_step "Acceptance: config API is an allowlist, not a denylist" grep -q 'IsPublicReadableConfigKey' apps/controller/config.go \ || fail "acceptance: config reads must go through the allowlist" grep -q 'IsPublicWritableConfigKey' apps/controller/config.go \ || fail "acceptance: config writes must go through the allowlist" # Ищется регистрация и вызов, а не имя: router_test.go обязан УПОМИНАТЬ # getConfig — он проверяет, что маршрут не вернулся. ! grep -rq 'controller.GetConfig' apps/router \ || fail "acceptance: the arbitrary-key getConfig route came back" ! grep -q 'func GetConfig(' apps/controller/config.go \ || fail "acceptance: the arbitrary-key getConfig handler came back" ! grep -rq 'export function getConfigApi' apps/frontend/src \ || fail "acceptance: the frontend client for getConfig came back" ! grep -rq 'getConfigApi(' apps/frontend/src \ || fail "acceptance: something still calls the removed getConfig client" local secret_key for secret_key in JwtSecret PeerSecretKey PeerSecretEncryptionKey Hysteria2TrafficStatsSecret; do grep -q "${secret_key}," apps/model/constant/config.go \ || fail "acceptance: ${secret_key} must be declared an internal config key" done # Секреты не имеют права оказаться в allowlist ни на чтение, ни на запись. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/model/constant/config.go", "utf8"); const start = source.indexOf("publicReadableConfigKeys"); const end = source.indexOf("func IsPublicReadableConfigKey"); const body = source.slice(start, end); for (const key of ["JwtSecret", "PeerSecretKey", "PeerSecretEncryptionKey", "Hysteria2TrafficStatsSecret", "Hysteria2Config"]) { if (body.includes(key + ":")) throw new Error(key + " is in the public allowlist"); } ' || fail "acceptance: no secret key may appear in the public config allowlist" log_step "Acceptance: request logging never carries query values" # Проверяется КОД, а не упоминание: комментарий, объясняющий, почему логгер # больше не пишет RequestURI, обязан быть разрешён. Поэтому строки # комментариев отбрасываются до поиска. local log_code server_code log_code="$(grep -v '^[[:space:]]*//' apps/middleware/log.go || true)" server_code="$(grep -v '^[[:space:]]*//' apps/cmd/server.go || true)" printf '%s\n' "$log_code" | grep -q 'c.Request.URL.Path' \ || fail "acceptance: the request logger must log the path, not RequestURI" ! printf '%s\n' "$log_code" | grep -q 'RequestURI' \ || fail "acceptance: RequestURI carries the machine token in its query string" ! grep -rq 'ReqUri' apps/model/vo apps/frontend/src/api \ || fail "acceptance: the reqUri log field came back" log_step "Acceptance: the admin has exactly one HTTP log channel" ! printf '%s\n' "$server_code" | grep -q 'gin.Default()' \ || fail "acceptance: gin.Default() logs the query string to stdout and then to journald" printf '%s\n' "$server_code" | grep -q 'gin.New()' \ || fail "acceptance: the admin engine must be built with gin.New()" printf '%s\n' "$server_code" | grep -q 'gin.Recovery()' \ || fail "acceptance: gin.New() must still install the recovery middleware" log_step "Acceptance: exported logs are sanitized on both sides" grep -q 'SanitizeLogText' apps/service/journal.go \ || fail "acceptance: exported Hysteria journal must be sanitized" grep -q 'redactLogText' orchestrator/src/commands/diagnostics.ts \ || fail "acceptance: diagnostics bundle must sanitize collected journals" grep -q 'journal-admin.log' orchestrator/src/commands/diagnostics.ts \ || fail "acceptance: the admin journal must be part of the sanitized set" log_step "Acceptance: machine token comparison is constant time" grep -q 'subtle.ConstantTimeCompare' apps/middleware/machine_auth.go \ || fail "acceptance: the machine token must be compared in constant time" } # Импорт пиров: одна транзакция и ровно один JSON-документ. run_atomic_import_acceptance() { log_step "Acceptance: peer import is a single database transaction" grep -q 'WithPeerTx' apps/service/peer.go \ || fail "acceptance: peer import must apply the whole batch in one transaction" grep -q 'func WithPeerTx' apps/dao/peer.go \ || fail "acceptance: the peer transaction boundary is missing from the dao layer" grep -q 'sqliteDB.Transaction' apps/dao/peer.go \ || fail "acceptance: WithPeerTx must open a real database transaction" # Применение обязано идти по tx, а не по глобальному соединению. "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("apps/service/peer.go", "utf8"); const start = source.indexOf("func applyPeerImportEntry"); if (start < 0) throw new Error("applyPeerImportEntry is missing"); const body = source.slice(start); for (const call of ["dao.GetPeer(", "dao.SavePeer(", "dao.UpdatePeer("]) { if (body.includes(call)) throw new Error("peer import still writes outside the transaction: " + call); } ' || fail "acceptance: peer import must not bypass the transaction" log_step "Acceptance: peer import accepts exactly one JSON document" grep -q 'io.EOF' apps/controller/peer.go \ || fail "acceptance: peer import must verify that nothing follows the JSON document" grep -q 'exactly one JSON document' apps/controller/peer.go \ || fail "acceptance: the trailing-document refusal must be explicit" log_step "Acceptance: peer export offers both modes in the UI" grep -q 'includeSecrets' apps/frontend/src/api/peer/index.ts \ || fail "acceptance: the UI must be able to request a secrets-bearing backup" grep -q 'handleExportBackup' apps/frontend/src/views/peer/list/index.vue \ || fail "acceptance: the backup export button is missing" grep -q 'exportBackupConfirm' apps/frontend/src/views/peer/list/index.vue \ || fail "acceptance: a secrets-bearing export must require explicit confirmation" local locale for locale in ru en; do grep -q 'exportBackupConfirm' "apps/frontend/src/lang/package/${locale}.ts" \ || fail "acceptance: the backup warning is missing from the ${locale} locale" done } # Compatibility-слой предыдущего поколения не должен пережить 1.0. run_legacy_account_acceptance() { log_step "Acceptance: the legacy account layer is gone from the runtime" [ ! -f apps/model/entity/account.go ] \ || fail "acceptance: the LegacyAccount entity came back" # Каталоги перечислены явно: рекурсия по apps захватила бы # apps/frontend/node_modules, который к этому шагу сборки уже существует. ! grep -rq 'LegacyAccount' apps/dao apps/model apps/service apps/controller apps/cmd \ || fail "acceptance: legacy account migration code came back" # Ищется запись в списке миграций (имя в кавычках), а не упоминание: # комментарий, объясняющий, почему миграции удалены, должен быть разрешён. local dead_migration for dead_migration in 002_migrate_legacy_accounts 003_archive_legacy_account; do ! grep -qF "\"${dead_migration}\"" apps/dao/sqlite.go \ || fail "acceptance: legacy migration ${dead_migration} came back" done # Номера оставшихся миграций не перенумеровываются: они уже записаны в # schema_migrations на установленных машинах. local kept_migration for kept_migration in 000_base_config 001_admin_peer_split 004_traffic_samples_and_aggregates 005_metric_sample; do grep -qF "\"${kept_migration}\"" apps/dao/sqlite.go \ || fail "acceptance: migration ${kept_migration} disappeared or was renumbered" done log_step "Acceptance: v1 docs carry no previous-generation vocabulary" # docs/14 — единственное место, где эти имена обозначают реальные объекты # для удаления. В обычных docs их быть не должно. local doc for doc in docs/*.md; do case "$doc" in docs/14-legacy-cleanup.md) continue ;; esac ! grep -q 'H_UI_' "$doc" \ || fail "acceptance: previous-generation config keys leaked into $doc" done }