This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 15:59:37 +02:00
1 parent 65e082b9d1
commit 76cb4acef0
72 files changed
+6134 -4709

No files matched your search

+39 -10
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.28.2"
assert manifest["package"]["version"] == "0.29.0"
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
h3dep = manifest["dependencies"]["h3"]
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"]
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
lock_path = root / "Cargo.lock"
if lock_path.exists():
lock = lock_path.read_text()
assert 'name = "rust-proxy"\nversion = "0.28.2"' in lock
assert 'name = "rust-proxy"\nversion = "0.29.0"' in lock
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
for release_doc, marker in {
"README.md": "# RustProxy 0.28.2",
"CHANGELOG.md": "## 0.28.2",
"ROADMAP.md": "## 0.28.2",
"AGENTS.md": "Current source version: 0.28.2.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.28.2",
"README.md": "# RustProxy 0.29.0",
"CHANGELOG.md": "## 0.29.0",
"ROADMAP.md": "## 0.29.0",
"AGENTS.md": "Current source version: 0.29.0.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.0",
}.items():
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
for marker in ['"version": "0.28.2"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
for marker in ['"version": "0.29.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
assert marker in openapi, marker
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
assert 'location.pathname === "/logging"' in settings_js_0241
assert '"RuntimeInformation"' in openapi and '"version": "0.28.2"' in openapi
assert '"RuntimeInformation"' in openapi and '"version": "0.29.0"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs")
@@ -1518,6 +1518,35 @@ assert 'listener-active-item' in runtime_web_0280 and 'listener-active-feature'
assert 'cache-via-enabled' in cache_web_0280 and 'via_header:' in cache_web_0280
assert 'location-header-direction location-header-editor' in vhost_core_0280
# 0.29.0 redirect/health/cache/gzip-log contracts.
vhost_model_0290 = rust_source(root, "src/vhost/model.rs")
vhost_validation_0290 = rust_source(root, "src/vhost/validation.rs")
config_model_0290 = rust_source(root, "src/config/model.rs")
config_routing_0290 = rust_source(root, "src/config/routing.rs")
proxy_redirect_0290 = rust_source(root, "src/proxy/redirect_helpers.rs")
route_cache_0290 = rust_source(root, "src/proxy/forward/route_cache.rs")
cache_entry_0290 = rust_source(root, "src/cache/model/entry.rs")
cache_request_0290 = rust_source(root, "src/cache/policy/request.rs")
file_logs_0290 = rust_source(root, "src/admin/file_logs.rs")
file_logs_io_0290 = rust_source(root, "src/admin/file_logs_io.rs")
for marker_0290 in ['www_redirect', 'redirect: Option<LocationRedirect>']:
assert marker_0290 in vhost_model_0290, marker_0290
for marker_0290 in ['routing_hosts', 'www_redirect_pair']:
assert marker_0290 in vhost_validation_0290, marker_0290
assert 'www_redirect: Option<(String, String)>' in config_model_0290
assert 'health_check.host.is_none()' in config_routing_0290 and 'HealthMode::Http' in config_routing_0290
assert 'canonical_host_redirect' in proxy_redirect_0290 and 'PERMANENT_REDIRECT' in proxy_redirect_0290
assert 'redirect_location' in rust_source(root, "src/config/snapshot.rs") and 'local_redirect' in route_cache_0290
assert 'pub struct UnsafeForce' in cache_entry_0290 and 'unsafe_force: UnsafeForce' in cache_entry_0290
assert 'forced_request_policy' in cache_request_0290 and 'cache_miss_forced' in rust_source(root, "src/proxy/mod.rs")
assert 'compressed: bool' in file_logs_0290 and 'GzDecoder' in file_logs_io_0290
for path_0290 in ['src/vhost/redirect.rs', 'docs/V0.29.0_IMPLEMENTATION.md']:
assert (root / path_0290).is_file(), path_0290
assert 'static_vhost_page_can_serve_html_with_503_status' in rust_source(root, "src/pages.rs")
assert 'upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators' in rust_source(root, "src/proxy/mod.rs")
assert '"www_redirect"' in openapi and '"unsafe_force"' in openapi
assert '"redirect"' in openapi and 'Backend-less client redirect for this location.' in openapi
# 0.28.2 external-certificate ACL/path hardening and least-privilege Certificate API.
tls_types_0282 = (root / "src/tls/types.rs").read_text()
tls_external_0282 = (root / "src/tls/external_paths.rs").read_text()
@@ -1578,5 +1607,5 @@ for marker_0282 in [
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text()
print("PASS: 0.28.2 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
print("PASS: 0.29.0 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
+6 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.28.2 release."""
"""Rust-independent documentation/Reader consistency checks for the 0.29.0 release."""
from pathlib import Path
import re
from rust_source import rust_source
@@ -14,6 +14,7 @@ required = [
DOCS / "README.md",
DOCS / "WEB_SERVER.md",
DOCS / "TODO.md",
DOCS / "V0.29.0_IMPLEMENTATION.md",
DOCS / "V0.28.2_IMPLEMENTATION.md",
DOCS / "V0.28.1_IMPLEMENTATION.md",
DOCS / "V0.27.3_IMPLEMENTATION.md",
@@ -62,16 +63,16 @@ assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
assert readme.startswith("# RustProxy 0.28.2")
assert readme.startswith("# RustProxy 0.29.0")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
assert roadmap.startswith("# RustProxy roadmap") and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
assert roadmap.startswith("# RustProxy roadmap") and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
assert changelog.startswith("# Changelog\n")
for release in ["0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
for release in ["0.29.0", "0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
assert f"## {release}" in changelog, release
assert changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert changelog.index("## 0.29.0") < changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog
for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]:
assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
+2 -2
View File
@@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/proxy/websocket.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.28.2 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.29.0 source tree: missing {path}")
text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.28.2 entry point; extract the complete package, not individual files.")
raise ValueError("src/lib.rs is not the 0.29.0 entry point; extract the complete package, not individual files.")
return messages
+5 -5
View File
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.28.2"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert 'version = "0.29.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
assert (target / "langs/site.json").is_file()
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert (target / "docker-agent/certificate.go").is_file()
assert (target / "docker-agent/server.go").is_file()
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
assert manifest["to_version"] == "0.28.2"
assert manifest["to_version"] == "0.29.0"
assert manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists()
assert not (backup / "legacy-data").exists()
@@ -619,7 +619,7 @@ def source_contract_smoke() -> None:
assert marker in admin, marker
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
assert '"/api/status"' not in anonymous_block
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.28.2"' in openapi_0241
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.0"' in openapi_0241
assert 'id="panel-logging"' not in html
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
assert marker in html, marker
@@ -1109,7 +1109,7 @@ def source_contract_smoke() -> None:
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
assert '"/api/config/history/restore"' in openapi
for marker in ['"version": "0.28.2"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
for marker in ['"version": "0.29.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
assert '"software_update"' in openapi and '"update_available"' in openapi
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
@@ -1314,7 +1314,7 @@ def main() -> None:
source_contract_smoke()
container_contract_smoke()
grafana_contract_smoke()
print("PASS: 0.28.2 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
print("PASS: 0.29.0 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
if __name__ == "__main__":
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Exercise the real vhost WWW-routing editor code with a small DOM stand-in.
// This is a browser-UI state test, not a Rust request-routing test.
import fs from 'node:fs';
import vm from 'node:vm';
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const first = editor.indexOf(' const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");');
const last = editor.indexOf(' field(g, "forward-client-ip",', first);
if (first === -1 || last === -1) throw new Error('WWW editor component missing');
const source = editor.slice(first, last);
if (editor.indexOf('readHosts = hostTagEditor(g, value.frontend?.hosts') < editor.indexOf('g = group("vhFrontends")')) {
throw new Error('Host chips must be in the same Frontends tab as WWW routing');
}
class ClassList {
constructor() { this.names = new Set(); }
add(name) { this.names.add(name); }
toggle(name, active) { if (active) this.names.add(name); else this.names.delete(name); }
contains(name) { return this.names.has(name); }
}
class Element {
constructor(tag = 'div', text = '', css = '') {
this.tag = tag;
this.textContent = text || '';
this.classList = new ClassList();
if (css) for (const name of css.split(' ')) this.classList.add(name);
this.children = [];
this.hidden = false;
this.checked = false;
this.value = '';
this.attrs = {};
this.listeners = new Map();
}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
prepend(...children) { for (const child of [...children].reverse()) { child.parent = this; this.children.unshift(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
closest(tag) { let node = this.parent; while (node && node.tag !== tag) node = node.parent; return node; }
setAttribute(key, value) { this.attrs[key] = String(value); }
addEventListener(event, listener) { this.listeners.set(event, listener); }
dispatch(event) { this.listeners.get(event)?.(); }
}
const el = (tag, content, css) => new Element(tag, content, css);
const t = key => key === 'vhWwwSharedCount' ? '{count} names / one vhost' : key;
const text = (tag, key, css) => el(tag, t(key), css);
const g = new Element();
const hosts = new Element('textarea');
hosts.value = 'suba.local\nsubb.local\n*.local';
const read = name => { if (name !== 'hosts') throw new Error(name); return hosts; };
const lines = raw => raw.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
let changes = 0;
const changed = () => { changes++; };
const value = { www_redirect: false, www_redirect_target: 'subb.local' };
const document = { createElement: tag => new Element(tag) };
const field = (parent, name, key, initial, type) => {
const label = new Element('label');
const input = new Element('input');
input.name = name;
input.type = type;
input.checked = Boolean(initial);
label.append(input, text('span', key));
parent.append(label);
return input;
};
let refreshCanonical = () => {};
const context = { el, text, field, g, t, value, read, lines, changed, document, refreshCanonical };
const component = vm.runInNewContext(`(() => { ${source}\nreturn { wwwPanel, wwwRedirect, wwwTarget, wwwChoices, wwwDetails, wwwInactive, wwwOther, wwwNoHosts, wwwHostCount, syncCanonical }; })()`, context);
const check = (condition, message) => { if (!condition) throw new Error(message); };
const choice = (baseIndex, directionIndex) => component.wwwChoices.children[baseIndex].children[1].children[directionIndex];
const route = (baseIndex, directionIndex) => choice(baseIndex, directionIndex).children[1].children[1].textContent;
check(!component.wwwDetails.hidden && !component.wwwInactive.hidden, 'Direction preview should be visible even when redirect is disabled');
check(component.wwwTarget.value === 'subb.local', 'Saved canonical domain was lost');
check(component.wwwChoices.children.length === 2, 'Wildcard must not become a canonical pair');
check(component.wwwHostCount.textContent === '3 names / one vhost', 'Shared host count not updated');
check(route(0, 0) === 'www.suba.local → suba.local', 'Incorrect www -> non-www preview');
check(route(0, 1) === 'suba.local → www.suba.local', 'Incorrect non-www -> www preview');
check(route(1, 0) === 'www.subb.local → subb.local', 'Incorrect second domain preview');
check(choice(1, 0).classList.contains('is-selected'), 'Saved target not visibly selected');
component.wwwRedirect.checked = true;
component.wwwRedirect.dispatch('change');
check(!component.wwwDetails.hidden && component.wwwInactive.hidden, 'Switch did not reveal WWW routing choices');
check(component.wwwRedirect.attrs['aria-expanded'] === 'true', 'Toggle accessibility state not updated');
check(component.wwwOther.children.some(child => child.textContent === 'suba.local'), 'Non-canonical alias must remain available');
check(component.wwwOther.children.some(child => child.textContent === '*.local'), 'Wildcard alias must remain available');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.suba.local', 'WWW destination selected without a separately declared www host was lost');
check(choice(0, 1).classList.contains('is-selected'), 'Selected direction not styled');
check(changes === 2, 'Toggle and radio change did not mark editor dirty');
hosts.value = 'subb.local\nwww.subb.local\n*.local';
context.refreshCanonical();
check(component.wwwTarget.value === 'subb.local', 'Deleting selected domain did not choose a valid fallback');
check(component.wwwChoices.children.length === 1, 'Duplicate base and www domains should be one pair');
check(route(0, 1) === 'subb.local → www.subb.local', 'Wrong preview for canonical www host');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.subb.local', 'Explicit WWW target not serialized');
check(component.wwwOther.children.every(child => child.textContent !== 'www.subb.local'), 'Redirected companion must not appear as a non-redirected alias');
hosts.value = '*.local';
context.refreshCanonical();
check(component.wwwNoHosts.hidden === false && component.wwwTarget.value === '', 'Wildcard-only configurations need a useful empty state');
check(component.wwwRedirect.disabled && !component.wwwRedirect.checked, 'Wildcard-only configuration must disable redirect');
console.log('PASS: WWW-routing UI two-way choices, implicit www target, shared aliases, dynamic host edits, wildcard exclusion and empty state.');
+1 -1
View File
@@ -96,7 +96,7 @@ need(web,
'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing",
"RustProxy <b>0.28.2</b>",
"RustProxy <b>0.29.0</b>",
"traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
// Exercise the actual domain-choice sync function from the vhost editor with
// DOM stand-ins. This does not replace browser or Rust runtime acceptance.
const source = fs.readFileSync("src/admin/web/vhosts/editor.js", "utf8");
const start = source.indexOf(" const syncCanonical = () => {");
const end = source.indexOf(" refreshCanonical = syncCanonical;", start);
assert.ok(start >= 0 && end > start, "WWW selection logic must remain discoverable");
const selectedLogic = source.slice(start, end);
class Element {
constructor(tag = "", body = "", className = "") {
this.tag = tag;
this.textContent = body ?? "";
this.className = className;
this.children = [];
this.listeners = {};
this.checked = false;
this.disabled = false;
this.hidden = false;
this.value = "";
this.attrs = new Map();
this.classes = new Set();
this.classList = {
add: name => this.classes.add(name),
toggle: (name, active) => active ? this.classes.add(name) : this.classes.delete(name),
};
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; }
setAttribute(key, value) { this.attrs.set(key, String(value)); }
addEventListener(event, callback) { this.listeners[event] = callback; }
fire(event) { this.listeners[event]?.(); }
}
function editor(hosts, previous = "", checked = false) {
const dom = Object.fromEntries([
"wwwTarget", "wwwChoices", "wwwOther", "wwwNoHosts", "wwwDetails", "wwwInactive", "wwwRedirect", "wwwPanel", "wwwHostCount"
].map(id => [id, new Element()]));
dom.wwwRedirect.checked = checked;
let changes = 0;
const bindings = {
...dom,
document: { createElement: tag => new Element(tag) },
el: (tag, content, className) => new Element(tag, content, className),
text: (_tag, key) => new Element("span", key),
t: key => key === "vhWwwSharedCount" ? "{count} names / one vhost" : key,
read: key => { assert.equal(key, "hosts"); return { value: hosts.join("\n") }; },
lines: value => value.split(/[\n,]+/).filter(Boolean),
changed: () => { changes++; },
};
const code = `let selectedCanonical = ${JSON.stringify(previous)};\n${selectedLogic}\nthis.run = () => syncCanonical();\nthis.getSelected = () => selectedCanonical;`;
const runtime = vm.createContext(bindings);
vm.runInContext(code, runtime, { filename: "editor-www-sync.js" });
runtime.run();
return { runtime, dom, countChanges: () => changes };
}
const result = editor(["suba.local", "subb.local"], "www.subb.local", false);
assert.equal(result.dom.wwwDetails.hidden, false, "destination cards must be visible while redirect is disabled");
assert.equal(result.dom.wwwTarget.value, "www.subb.local", "www target must survive editor re-opening");
assert.equal(result.dom.wwwChoices.children.length, 2, "unrelated domains must remain distinct groups");
assert.equal(result.dom.wwwHostCount.textContent, "2 names / one vhost");
const aChoices = result.dom.wwwChoices.children[0].children[1].children;
assert.equal(aChoices.length, 2, "each domain must offer both www and non-www targets");
assert.equal(aChoices[0].children[0].value, "suba.local");
assert.equal(aChoices[1].children[0].value, "www.suba.local");
assert.ok(aChoices[1].children[1].children[1].textContent.includes("suba.local"), "preview must show direction");
// Select www destination for a different host, without editing hostname chips.
aChoices[1].children[0].checked = true;
aChoices[1].children[0].fire("change");
assert.equal(result.dom.wwwTarget.value, "www.suba.local");
assert.equal(result.countChanges(), 1);
assert.ok(result.dom.wwwOther.children.some(child => child.textContent === "subb.local"), "other alias remains in vhost");
result.dom.wwwRedirect.checked = true;
result.runtime.run();
assert.equal(result.dom.wwwInactive.hidden, true);
assert.ok(result.dom.wwwPanel.classes.has("is-enabled"));
const empty = editor(["*.example.test"], "", true);
assert.equal(empty.dom.wwwRedirect.disabled, true, "wildcard-only vhost cannot enable WWW canonicalization");
assert.equal(empty.dom.wwwRedirect.checked, false);
const canonical = editor(["www.example.test"], "example.test", true);
assert.equal(canonical.dom.wwwTarget.value, "example.test", "bare target may be implicit when only www host is configured");
assert.equal(canonical.dom.wwwChoices.children.length, 1);
console.log("PASS: WWW destination cards, both directions, disabled preview, live update, aliases and wildcard guard.");