v0.32.8
This commit is contained in:
1 parent
c41d1e00a2
commit
6987b3ce33
98 files changed
+146
-10827
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
|
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
|
||||||
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
|
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
|
||||||
compatibility. Current source version: 0.32.7.
|
compatibility. Current source version: 0.32.8.
|
||||||
|
|
||||||
## Delivery
|
## Delivery
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
> Operator-facing release history. Historical engineering notes are kept in
|
> Operator-facing release history. Historical engineering notes are kept in
|
||||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||||
|
|
||||||
|
## 0.32.8
|
||||||
|
|
||||||
|
- Keep the Overview title on the left with actions aligned to the right, wrapping naturally on narrow screens instead of forcing three equally sized button columns.
|
||||||
|
- Use existing quiet, outline and primary button styles in the Overview actions and visual editor; remove redundant Overview-specific button classes and obsolete mobile button overrides without changing the shared presets.
|
||||||
|
- Remove the complete `tools/checks/` directory from the distributed source tree. Keep the source/upgrade preflight in `scripts/preflight.py` and run Rust tests directly from `dev.sh`.
|
||||||
|
- No changes to saved layout preferences, Overview JavaScript event handlers, proxy routing or monitoring behavior.
|
||||||
|
|
||||||
## 0.32.7 development follow-up (UI density and H2 diagnostics)
|
## 0.32.7 development follow-up (UI density and H2 diagnostics)
|
||||||
|
|
||||||
- Remove per-location button size overrides for Overview refresh/arrangement/unlock and Appearance visual editor/show-all; defer to shared Normal/Compact button sizing.
|
- Remove per-location button size overrides for Overview refresh/arrangement/unlock and Appearance visual editor/show-all; defer to shared Normal/Compact button sizing.
|
||||||
|
|||||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-proxy"
|
name = "rust-proxy"
|
||||||
version = "0.32.7"
|
version = "0.32.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rust-proxy"
|
name = "rust-proxy"
|
||||||
version = "0.32.7"
|
version = "0.32.8"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.99"
|
rust-version = "1.99"
|
||||||
description = "RustProxy - independent reverse proxy with local administration"
|
description = "RustProxy - independent reverse proxy with local administration"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustProxy 0.32.7
|
# RustProxy 0.32.8
|
||||||
|
|
||||||
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
||||||
|
|
||||||
@@ -7,6 +7,12 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos
|
|||||||
|
|
||||||
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
|
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
|
||||||
|
|
||||||
|
## 0.32.8: Overview action layout
|
||||||
|
|
||||||
|
- The Overview heading stays left-aligned while the refresh, arrangement and layout-lock actions flow to the right; small screens wrap buttons without squeezing them into three equal columns.
|
||||||
|
- The inline layout actions and visual editor keep their existing IDs and behavior while sharing the standard button styles (including Compact mode).
|
||||||
|
- The `tools/checks/` source-check directory is no longer shipped. `./dev.sh doctor` and `./dev.sh repair` use `scripts/preflight.py`; `./dev.sh test` runs Cargo checks and Rust tests.
|
||||||
|
|
||||||
## 0.32.7 development follow-up: Appearance and H2 diagnostics
|
## 0.32.7 development follow-up: Appearance and H2 diagnostics
|
||||||
|
|
||||||
- Overview **Refresh now**, **Arrange Overview**, **Unlock layout**, and Appearance **Visual Overview editor** / **Show all cards** use the common button sizing, including the global **Compact** preset on mobile.
|
- Overview **Refresh now**, **Arrange Overview**, **Unlock layout**, and Appearance **Visual Overview editor** / **Show all cards** use the common button sizing, including the global **Compact** preset on mobile.
|
||||||
@@ -735,7 +741,7 @@ well as the old source, not just the binary.
|
|||||||
`script` responsibilities are intentionally separated:
|
`script` responsibilities are intentionally separated:
|
||||||
|
|
||||||
- `scripts/runtime`, `scripts/build`, `scripts/deploy`, `scripts/migration` are coupled to application/build/deployment behavior;
|
- `scripts/runtime`, `scripts/build`, `scripts/deploy`, `scripts/migration` are coupled to application/build/deployment behavior;
|
||||||
- `tools/checks`, `tools/dev`, `tools/audit`, `tools/legacy` are auxiliary tools and are not needed by the running proxy;
|
- `tools/dev`, `tools/audit`, `tools/legacy` are optional operator/development helpers; the removed `tools/checks` suite is not shipped;
|
||||||
- `integrations/` contains external platform helpers such as OPNsense ACME and RouterOS OCI tooling.
|
- `integrations/` contains external platform helpers such as OPNsense ACME and RouterOS OCI tooling.
|
||||||
|
|
||||||
See `scripts/README.md`, `tools/README.md` and `integrations/README.md`.
|
See `scripts/README.md`, `tools/README.md` and `integrations/README.md`.
|
||||||
@@ -748,13 +754,13 @@ See `scripts/README.md`, `tools/README.md` and `integrations/README.md`.
|
|||||||
[roadmap](ROADMAP.md), [changelog](CHANGELOG.md), [verification](docs/TEST_REPORT.md).
|
[roadmap](ROADMAP.md), [changelog](CHANGELOG.md), [verification](docs/TEST_REPORT.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Rust-independent checks:
|
# Source preflight without Rust:
|
||||||
python3 tools/checks/check_source.py
|
./dev.sh doctor
|
||||||
python3 tools/checks/release_smoke.py
|
# Cargo validation and the Rust test suite (Rust required):
|
||||||
# Runs those checks first, then the Rust suite when Cargo is available:
|
./dev.sh check
|
||||||
bash dev.sh test
|
./dev.sh test
|
||||||
# Source-only package, with no Git checkout required:
|
# Source-only package, with no Git checkout required:
|
||||||
python3 make_zip.py /tmp/RustProxy-0.28.1.zip
|
python3 make_zip.py /tmp/RustProxy-0.32.8.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
## New management and test-origin guide
|
## New management and test-origin guide
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# RustProxy roadmap
|
# RustProxy roadmap
|
||||||
|
|
||||||
|
## 0.32.8 - Overview action alignment (runtime acceptance pending)
|
||||||
|
|
||||||
|
- [x] Replace mobile three-column Overview actions with natural wrapping: keep the heading at left, actions to the right, without shrinking buttons.
|
||||||
|
- [x] Remove redundant button-only classes and outdated small-screen overrides while preserving existing common button variants, layout controls and editor handlers.
|
||||||
|
- [x] Remove the complete `tools/checks/` directory and update affected scripts and docs; retain the upgrade-safety preflight.
|
||||||
|
- [ ] Build and exercise RustProxy against a live backend and real administration session in a Rust-enabled environment.
|
||||||
|
|
||||||
## 0.32.7 development follow-up: Appearance and H2-close diagnostics
|
## 0.32.7 development follow-up: Appearance and H2-close diagnostics
|
||||||
|
|
||||||
- [x] Use common button sizing for Overview quick actions and Appearance visual editor/show-all; preserve the normal and compact global button styles.
|
- [x] Use common button sizing for Overview quick actions and Appearance visual editor/show-all; preserve the normal and compact global button styles.
|
||||||
|
|||||||
+19
-96
@@ -1,18 +1,18 @@
|
|||||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||||
c38904ea10c12fea702f54dbbcad75096a4e89148532dea040a346ecc18c3b8c AGENTS.md
|
238101ec7b2518840b166ef6e778a3e6ed835dd493b3a36e200bcdc6e6aa22a7 AGENTS.md
|
||||||
56df1fa7123e95ca19e67ca11933ec34336216a0f46083121051bac7a1719dbc CHANGELOG.md
|
ef8f6cbd5adf52cbd5f0c58263b4ea36e9ef0212a9579f43f4ad45d2c478be2f CHANGELOG.md
|
||||||
5d8c6feb5585b8880f7d016dec83f2a8f6cdcbaa7eb5ccc840a6064385d22133 Cargo.lock
|
1aa14f02650d326337f2acb5953cd2caaae963ac463db2c9e0d0f8d0689b0ae2 Cargo.lock
|
||||||
931e433a8f2e2582bdd76180c60c6c40978a3bb29f66bbd5219a45587a052d95 Cargo.toml
|
daef03750129b52867850bd75bccef198ca45194c42b9d91d17e039e2e114a35 Cargo.toml
|
||||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||||
d4f497119527aba2681637caef3f4e1dd8615decc1f691d51e8857108e4cb00d README.md
|
0460e1ace69d57c8d1ec79634dfb55c8744e3ac01f158fadfcf6c0d9cb6d9f65 README.md
|
||||||
186faafa487edc7a23a1200c8ef8641b00ada263fe5bf994eff2aa76aa94984f ROADMAP.md
|
9297fa45ba5df2490c2ec38da8fb6e659571e2b16363f3c9933c08c08d76c6e2 ROADMAP.md
|
||||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||||
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
|
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
|
||||||
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
|
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
|
||||||
8397a12bd76b6abb251a9599f7b9be92e4e5a9addaa40ef35ef6942c3ba9994a dev.sh
|
c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh
|
||||||
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
||||||
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
||||||
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
||||||
@@ -29,7 +29,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
|
|||||||
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
|
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
|
||||||
627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md
|
627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md
|
||||||
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
|
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
|
||||||
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md
|
d30551a52ffd34c5963094d2826ba39795210a4e6a4e4d7333066dae7456fc50 docs/ARCHITECTURE.md
|
||||||
fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md
|
fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md
|
||||||
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
|
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
|
||||||
3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
|
3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
|
||||||
@@ -43,8 +43,8 @@ a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.m
|
|||||||
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
|
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
|
||||||
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
|
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
|
||||||
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
|
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
|
||||||
633ed36201bf8c0c72d21596183aac9f28113c0ad2d9154abd532a8cb7c91543 docs/MEMORY_STRESS.md
|
d8829cc68d982c933d04207cf7bc78cc918044c72e024e7ecad5220953bb3a79 docs/MEMORY_STRESS.md
|
||||||
ea42422dc6c0e2b49a1f0aad3ca6a7d7dd1faa6250b61109445b64210bef9e8a docs/MIGRATION.md
|
e36247774051aa9c83c1a2fda91c1f7857991dcaa4fa90320ca5b492e31dbcf8 docs/MIGRATION.md
|
||||||
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
|
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
|
||||||
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
|
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
|
||||||
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
|
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
|
||||||
@@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m
|
|||||||
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
|
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
|
||||||
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
||||||
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
||||||
3ab9fa2a537c00c13f294a09d0109e6a1c2e10f105902b84104bb9b613e61993 docs/SETTINGS.md
|
4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md
|
||||||
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
|
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/TEST_REPORT.md
|
81e8d3c8fe409a1a4fa01fc3eedda97b5d9288721c3b7189c29f865616389d2a docs/TEST_REPORT.md
|
||||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||||
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
||||||
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
|
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
|
||||||
@@ -94,7 +94,7 @@ c73b814a42bb4ff703441dbd177ee625055eeb115f9b5bb0c2150aede573a159 integrations/o
|
|||||||
cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/routeros-oci-build/README.md
|
cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/routeros-oci-build/README.md
|
||||||
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
||||||
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
||||||
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
|
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
|
||||||
a1f873bfe87d090bac46962598aeb90ddfa62ae257b3b76ec1af011c74197bac langs/en_US.json
|
a1f873bfe87d090bac46962598aeb90ddfa62ae257b3b76ec1af011c74197bac langs/en_US.json
|
||||||
ca95ffe6919c07699155d1c49fa711916f91f0fbe928e5a36be23f05519ffbbb langs/pl_pl.json
|
ca95ffe6919c07699155d1c49fa711916f91f0fbe928e5a36be23f05519ffbbb langs/pl_pl.json
|
||||||
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
||||||
@@ -122,7 +122,8 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
|
|||||||
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
|
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
|
||||||
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
|
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
|
||||||
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
|
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
|
||||||
f4bc093128ab0d8091f9e4e47d61bfaeacbebb0cf6e3b523cf7ddfdf5996aed7 scripts/resolve_dependencies_0312.sh
|
63ed91f4cd77450cfe04f4bdb6e5f866b7868afa10207f78041e6fcd86d96fb0 scripts/preflight.py
|
||||||
|
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
|
||||||
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
||||||
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
||||||
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
|
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
|
||||||
@@ -156,7 +157,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
|
|||||||
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
||||||
74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs
|
74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs
|
||||||
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
|
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
|
||||||
ba3fe90521efd976dfeed3c353fed05e335a35e1c38842a31e7429802a22ae23 src/admin/openapi.json
|
83490218da65fe4354052dcccce48b59e00869b16012c4cc7753d66f29426eba src/admin/openapi.json
|
||||||
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
||||||
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
||||||
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||||
@@ -213,7 +214,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
|||||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||||
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
|
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
|
||||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||||
1335cf75c5da63bdb2b9978aa79ec3ad37fb66e6c3cb38647375227c37c2442f src/admin/web/index.html
|
16c453dca88bb8ce2c722d6b9694c18d51ced22f8948ce99112781c644da1419 src/admin/web/index.html
|
||||||
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
|
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
|
||||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||||
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
||||||
@@ -223,7 +224,7 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/
|
|||||||
c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js
|
c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js
|
||||||
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
||||||
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
|
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
|
||||||
57081d31d7a7b7842e45725848fa4a4e6595e0cf769c7969797202ca99be439f src/admin/web/style.css
|
12670b09844212757ff6ed101906fa5251502f0b07de41da4994c5c0971eec1f src/admin/web/style.css
|
||||||
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
||||||
69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js
|
69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js
|
||||||
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
|
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
|
||||||
@@ -492,86 +493,8 @@ f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures
|
|||||||
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
|
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
|
||||||
eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs
|
eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs
|
||||||
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
|
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
|
||||||
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
|
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
|
||||||
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
|
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
|
||||||
1a7ff9b28ade57b99d054c703496b51949dd1a8e7ed67bc5630f94a7df258cb5 tools/checks/admin_panel_config_0314_ui_test.mjs
|
|
||||||
e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/admin_public_origin_0314_config_sim.mjs
|
|
||||||
0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs
|
|
||||||
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
|
|
||||||
c9d0bef4b8ed7a2f49b0fad3e116cace62d3e6e1ca31e02a3073d1fdde5157d8 tools/checks/admin_themes_0323_ui_test.mjs
|
|
||||||
e6462ce0fee8adb96ba22e275eb8cc1662c3bdf2e5ff53e7e0c8a55c8ce936ab tools/checks/appearance_density_buttons_0327.py
|
|
||||||
b305998e8314bf79fe087ccd37676dd6f7139b88559e7c1132acdb646cb3631a tools/checks/appearance_density_buttons_0327_browser.py
|
|
||||||
5b7d146a4558b2435fb4e8813db9e20e446988003fff884b3e132b28d926fa3c tools/checks/appearance_ux_0323_browser.py
|
|
||||||
8b6e1a355cae58901118b399fa2d165f7e3fd8465c89cac82776906b8b23f0a6 tools/checks/appearance_ux_0323_ui_test.mjs
|
|
||||||
f3dba206c60cd2e6fcc5f4a2c8d652ccf47362694448312cc1f8dc4112d93e7e tools/checks/backend_cache_0297_ui_test.mjs
|
|
||||||
e82765ae946461952e68369f0b16f0a99f01e625bd61d2551513504f7b2f4d46 tools/checks/backend_dashboard_0323_ui_test.mjs
|
|
||||||
45cd21192248ffe6f66c20bab03da1a5ce299f040883764d09a640859e19924d tools/checks/backend_health_0300_ui_test.mjs
|
|
||||||
a135c9456143975279f18233caab44b8c64f5025ef4ceeb1f2ad22090b588019 tools/checks/backend_protocol_0326_ui_test.mjs
|
|
||||||
72e20f15a029d1a3596c7d05979354eb78dacd1de979f6933aed0994cfe76179 tools/checks/certificate_api_0319.py
|
|
||||||
526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs
|
|
||||||
941eacd99d652916901ef3230e6e3c8489121a1676646413283e59093f8ad9c0 tools/checks/check_source.py
|
|
||||||
a1ac2ab24958c6e8527c7797f224f94e765abd7173d4e5085a64362b193112c1 tools/checks/dashboard_appearance_ribbon_0326_browser.py
|
|
||||||
3729e4e4332ebb1b943c502e432845d6ae852ca971cdd04bc204813c9c81e016 tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs
|
|
||||||
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
|
|
||||||
7508f232a9e48049002dd4c9f34f9737bc92d304c51219f50430578dbc721855 tools/checks/dashboard_inline_layout_0323_ui_test.mjs
|
|
||||||
dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/dashboard_layout_editor_0323_ui_test.mjs
|
|
||||||
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
|
|
||||||
4be5f99949f3fb51fcaadf04be36a75da1f94b8aa6e081a29fd9c0ba6c4682ea tools/checks/dependency_upgrade_0312.py
|
|
||||||
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
|
|
||||||
d56f40095c4f51c57ef7456dd15c63c134c46a79f6ab9835848a05422b484eb3 tools/checks/docs_check.py
|
|
||||||
da9e0128adfb45d4f18bc9f691ebc42c4c2f449a5b3f6728bb83f65d7d3c5fd8 tools/checks/dynamic_acl_0325.py
|
|
||||||
9c77764561b6ebfeecbc228f43ea29a0dd9f44121ad773c2e4299b92758e45c2 tools/checks/dynamic_acl_startup_0325_ui_test.mjs
|
|
||||||
5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs
|
|
||||||
72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs
|
|
||||||
5159c1a2ee8610f9646e425e5f1a5d093842fe58859aa87fe94e5c2def9c5042 tools/checks/h3_navigation_0316.py
|
|
||||||
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
|
|
||||||
fb8619444edf81afa604194f750dc76335394a73c74324eda59e392874f29ee8 tools/checks/ios_scroll_0326_browser.py
|
|
||||||
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
|
||||||
c56a7763250f8cf1ac011feb0c90a06309bd680aeb6da6e1d1f825fbb2a573aa tools/checks/live_diagnostics_0326_browser.py
|
|
||||||
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
|
|
||||||
ce4fb1793eeb76ccb888af48bf92312638ea7b056250d3a9328f47919a0958e7 tools/checks/mobile_protocol_0326.py
|
|
||||||
db40b3940989dd6e32459574b4a5ba3219ebec8e2e68dea1f71ec4a9e53e78b0 tools/checks/mobile_protocol_0326_browser.py
|
|
||||||
af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
|
|
||||||
0387a9e29244b58cf37a6673a88ae2b7fea3aa965c032423f3783a84eab4ba05 tools/checks/overview_0321_layout_regression.mjs
|
|
||||||
7fda61ae66a6e3f1ada1304440c09e1f43d0e577b3a5eac8331afe86fc5eebcb tools/checks/overview_extra_metrics_0323_ui_test.mjs
|
|
||||||
48dd1e13b83dc897c735028c93e17200cf4ed9ce175475dc5641ec3236569c38 tools/checks/overview_grid_spacing_0323_browser.py
|
|
||||||
0575423efc24465d276226f45e3b9ef4d2dd1ae0bfbb58e257a10b2184a17d2a tools/checks/overview_gutters_0324_browser.py
|
|
||||||
b565aef4594265350240c24067a554f91fd605d0e920c03eef0b5b62c3934aad tools/checks/overview_registry_0323_ui_test.mjs
|
|
||||||
f450e97dccf75c24e228e696f21ed4ae826a823ea4edde2429894a6b6b36eaab tools/checks/overview_uniform_grid_0323_ui_test.mjs
|
|
||||||
644ca805fd7072a3087cfb8c4a41fc53eefe0e902e5d1524d8f892657970493f tools/checks/overview_wide_masonry_0323_ui_test.mjs
|
|
||||||
1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs
|
|
||||||
8ff6d76ba2465794135b532358ec6fe448928293adcc64fffa3cd917f5c11662 tools/checks/passive_h2_0327.py
|
|
||||||
eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py
|
|
||||||
964253df6a6143a0a6bfeeba05bbb57f8eaedf7746125c9e230e265403ac1983 tools/checks/preserve_host_0324.py
|
|
||||||
3352941648add6d8a8044c0b5cad6bf264546d38c884a5beed64cd95addd850c tools/checks/preserve_host_0324_ui_test.mjs
|
|
||||||
64984e55d93617ac61c83f19b91a2f70402057a47a1962dcbcfd0b7d904f6098 tools/checks/rate_pause_0326_ui_test.mjs
|
|
||||||
599e0fa12fe95c7087a25801a26a590bbb473f24f038e0be157449daecec5593 tools/checks/refresh_0310_ui_test.mjs
|
|
||||||
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
|
|
||||||
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
|
|
||||||
333d4974a08c8d86155522a24037bdb9a21e578b32770cf0fcff2f3192209775 tools/checks/release_0318.py
|
|
||||||
e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs
|
|
||||||
ebfd4ced216bd0eac26bcb47fade5590759520b821af928df196c06ac1d2c7f3 tools/checks/release_smoke.py
|
|
||||||
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py
|
|
||||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
|
||||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
|
||||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
|
||||||
b7ac88b96fae3b897576554e1a330b38cf3dbdcae54d4d239a207a240fd19eb4 tools/checks/security_logs_0315_ui_test.mjs
|
|
||||||
011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py
|
|
||||||
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
|
|
||||||
be78df1e438d76be83e15398b310c9843ae5965b0ec6b41279b3a9650c313d8a tools/checks/stream_0320_ui_test.mjs
|
|
||||||
6c7d20fa2abb55de997c19abd07ec1afa8d5121c91e07f231976bcdfbfbba778 tools/checks/stream_0320_visibility_regression.mjs
|
|
||||||
8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs
|
|
||||||
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
|
|
||||||
9aa7bc1018291613209f529fbf1a303481efa1df68abb77c6deee31c84a36ce0 tools/checks/upstream_protocols_0323.py
|
|
||||||
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
|
|
||||||
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
|
||||||
bdc20ccb7ee8c67eab6ef22b2011a99079d69fea49d03ab634da7de1b1e7b909 tools/checks/vhost_protocol_badge_group_0326_browser.py
|
|
||||||
ea98e8ce2bdc841814e486ea4c13f94e284b2c50a5887598b7a288f75a14deaf tools/checks/vhost_www_ui_test.mjs
|
|
||||||
2df48af5b438eb811a5babd6401db830c6fa77ed5bb03bf80f689da8440c93e1 tools/checks/web_assets.py
|
|
||||||
8d2e1837f94302d09a704aca452cc367e6892f289aca3dc2d82c086a2e7de26d tools/checks/web_cache_revalidation_0322.py
|
|
||||||
8ec055a16198701dd4fa35eac42a71b666d2b9bbe90edb1c797c7ed759615487 tools/checks/web_server_contracts.py
|
|
||||||
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
|
|
||||||
4c541640a7edcdb69319770d6359e7715617df8f5443f82b29f867a5e820a08e tools/checks/www_redirect_ui_test.mjs
|
|
||||||
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
|
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
|
||||||
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
||||||
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
|
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ RustProxy ${RUSTPROXY_VERSION}
|
|||||||
Start a LOOPBACK-ONLY fake Tinyauth peer (never production)
|
Start a LOOPBACK-ONLY fake Tinyauth peer (never production)
|
||||||
./dev.sh acme --doctor Check local Certbot availability without network access
|
./dev.sh acme --doctor Check local Certbot availability without network access
|
||||||
./dev.sh acme --cert-dir DIR Read provider request JSON from stdin; stage PEM files
|
./dev.sh acme --cert-dir DIR Read provider request JSON from stdin; stage PEM files
|
||||||
./dev.sh test Run offline release checks and all Rust tests
|
./dev.sh test Check all Rust targets and run Rust tests
|
||||||
./dev.sh build Build the development binary only
|
./dev.sh build Build the development binary only
|
||||||
./dev.sh release Build an optimized binary (target/release/rust-proxy)
|
./dev.sh release Build an optimized binary (target/release/rust-proxy)
|
||||||
./dev.sh fmt Format Rust sources with rustfmt
|
./dev.sh fmt Format Rust sources with rustfmt
|
||||||
@@ -75,7 +75,7 @@ if ! command -v python3 >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python 3.10+ is required.")'
|
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python 3.10+ is required.")'
|
||||||
if [[ "$mode" == "doctor" ]]; then
|
if [[ "$mode" == "doctor" ]]; then
|
||||||
python3 tools/checks/preflight.py
|
python3 scripts/preflight.py
|
||||||
printf 'Source layout: OK\n'
|
printf 'Source layout: OK\n'
|
||||||
for tool in cargo rustc cc python3; do
|
for tool in cargo rustc cc python3; do
|
||||||
printf '%s: ' "$tool"
|
printf '%s: ' "$tool"
|
||||||
@@ -86,79 +86,8 @@ fi
|
|||||||
if [[ "$mode" == "acme" ]]; then exec python3 scripts/runtime/acme_provider.py "$@"; fi
|
if [[ "$mode" == "acme" ]]; then exec python3 scripts/runtime/acme_provider.py "$@"; fi
|
||||||
if [[ "$mode" == "mock-auth" ]]; then exec python3 tools/dev/mock_auth.py "$@"; fi
|
if [[ "$mode" == "mock-auth" ]]; then exec python3 tools/dev/mock_auth.py "$@"; fi
|
||||||
if [[ "$mode" == "mock" ]]; then exec python3 tools/dev/mock_server.py "$@"; fi
|
if [[ "$mode" == "mock" ]]; then exec python3 tools/dev/mock_server.py "$@"; fi
|
||||||
python3 tools/checks/preflight.py --repair-known
|
python3 scripts/preflight.py --repair-known
|
||||||
if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi
|
if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi
|
||||||
if [[ "$mode" == "test" ]]; then
|
|
||||||
python3 tools/checks/rust_module_contracts.py
|
|
||||||
python3 tools/checks/h3_navigation_0316.py
|
|
||||||
python3 tools/checks/release_0317.py
|
|
||||||
python3 tools/checks/release_0318.py
|
|
||||||
python3 tools/checks/certificate_api_0319.py
|
|
||||||
python3 tools/checks/source_pruning_0317.py
|
|
||||||
python3 tools/checks/system_info_no_subprocess_contracts.py
|
|
||||||
python3 tools/checks/source_module_contracts.py
|
|
||||||
python3 tools/checks/version_contracts.py
|
|
||||||
python3 tools/checks/upstream_protocols_0323.py
|
|
||||||
python3 tools/checks/preserve_host_0324.py
|
|
||||||
python3 tools/checks/dynamic_acl_0325.py
|
|
||||||
python3 tools/checks/appearance_density_buttons_0327.py
|
|
||||||
python3 tools/checks/mobile_protocol_0326.py
|
|
||||||
python3 tools/checks/check_source.py
|
|
||||||
python3 tools/checks/release_smoke.py
|
|
||||||
python3 tools/checks/memory_contracts.py
|
|
||||||
python3 tools/checks/high_traffic_memory_model.py
|
|
||||||
python3 tools/checks/web_server_contracts.py
|
|
||||||
python3 tools/checks/web_cache_revalidation_0322.py
|
|
||||||
python3 tools/checks/security_contracts.py
|
|
||||||
python3 tools/checks/admin_surface_audit_0310.py
|
|
||||||
python3 tools/checks/security_dependency_contracts.py
|
|
||||||
python3 tools/checks/dependency_upgrade_0312.py
|
|
||||||
if command -v node >/dev/null 2>&1; then
|
|
||||||
node tools/checks/web_service_ui_test.mjs
|
|
||||||
node tools/checks/backend_cache_0297_ui_test.mjs
|
|
||||||
node tools/checks/backend_health_0300_ui_test.mjs
|
|
||||||
node tools/checks/system_info_0301_ui_test.mjs
|
|
||||||
node tools/checks/refresh_0310_ui_test.mjs
|
|
||||||
node tools/checks/stream_0320_ui_test.mjs
|
|
||||||
node tools/checks/stream_0320_visibility_regression.mjs
|
|
||||||
node tools/checks/overview_registry_0323_ui_test.mjs
|
|
||||||
node tools/checks/dynamic_acl_startup_0325_ui_test.mjs
|
|
||||||
node tools/checks/preserve_host_0324_ui_test.mjs
|
|
||||||
node tools/checks/backend_dashboard_0323_ui_test.mjs
|
|
||||||
node tools/checks/dashboard_layout_editor_0323_ui_test.mjs
|
|
||||||
node tools/checks/dashboard_inline_layout_0323_ui_test.mjs
|
|
||||||
node tools/checks/admin_themes_0323_ui_test.mjs
|
|
||||||
node tools/checks/appearance_ux_0323_ui_test.mjs
|
|
||||||
node tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs
|
|
||||||
node tools/checks/backend_protocol_0326_ui_test.mjs
|
|
||||||
node tools/checks/overview_extra_metrics_0323_ui_test.mjs
|
|
||||||
node tools/checks/rate_pause_0326_ui_test.mjs
|
|
||||||
node tools/checks/overview_wide_masonry_0323_ui_test.mjs
|
|
||||||
node tools/checks/overview_uniform_grid_0323_ui_test.mjs
|
|
||||||
node tools/checks/overview_0321_layout_regression.mjs
|
|
||||||
node tools/checks/dashboard_startup_0310_ui_test.mjs
|
|
||||||
node tools/checks/dashboard_boot_0311_ui_test.mjs
|
|
||||||
node tools/checks/origin_resume_0313_ui_test.mjs
|
|
||||||
node tools/checks/admin_public_origin_0314_sim.mjs
|
|
||||||
node tools/checks/admin_public_origin_0314_config_sim.mjs
|
|
||||||
node tools/checks/admin_panel_config_0314_ui_test.mjs
|
|
||||||
node tools/checks/security_logs_0315_ui_test.mjs
|
|
||||||
node tools/checks/file_logs_tabs_0315_ui_test.mjs
|
|
||||||
node tools/checks/release_0317_ui_test.mjs
|
|
||||||
node tools/checks/release_0318_ui_test.mjs
|
|
||||||
node tools/checks/docker_startup_0318_ui_test.mjs
|
|
||||||
node tools/checks/certificate_api_0319_ui_test.mjs
|
|
||||||
node tools/checks/h3_cache_mobile_0315_test.mjs
|
|
||||||
node tools/checks/panel_cache_http_0310_ui_test.mjs
|
|
||||||
node tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
|
||||||
node tools/checks/www_redirect_ui_test.mjs
|
|
||||||
node tools/checks/vhost_www_ui_test.mjs
|
|
||||||
else
|
|
||||||
printf 'Node.js not found; skipping Web Service UI behavior test.\n' >&2
|
|
||||||
fi
|
|
||||||
python3 tools/checks/docs_check.py
|
|
||||||
python3 tools/checks/langs_check.py
|
|
||||||
fi
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
cat >&2 <<'MISSING'
|
cat >&2 <<'MISSING'
|
||||||
Rust/Cargo is not installed or not in PATH.
|
Rust/Cargo is not installed or not in PATH.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ committed document at startup; this is not one cross-memory/SQLite transaction.
|
|||||||
### Rust source organization
|
### Rust source organization
|
||||||
|
|
||||||
Production Rust modules are kept as small responsibility-oriented files, with a
|
Production Rust modules are kept as small responsibility-oriented files, with a
|
||||||
**500-line limit** enforced by `tools/checks/rust_module_contracts.py`. Unit and
|
**500-line engineering limit** for production modules. Unit and
|
||||||
integration test files are explicitly exempt: related tests may live together in
|
integration test files are explicitly exempt: related tests may live together in
|
||||||
large `tests.rs` files rather than arbitrary numbered fragments. Large logical modules use a thin facade (`config.rs`, `docker.rs`,
|
large `tests.rs` files rather than arbitrary numbered fragments. Large logical modules use a thin facade (`config.rs`, `docker.rs`,
|
||||||
`server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a
|
`server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a
|
||||||
@@ -52,9 +52,9 @@ submodules for configuration, filesystem confinement, responses, routing, FastCG
|
|||||||
|
|
||||||
Unit-test implementations are not embedded in production source files. They live in
|
Unit-test implementations are not embedded in production source files. They live in
|
||||||
`tests.rs` or `tests/` files and are included or declared only behind `#[cfg(test)]`.
|
`tests.rs` or `tests/` files and are included or declared only behind `#[cfg(test)]`.
|
||||||
Source-level CI also checks `include!` targets, balanced split fragments and dangling
|
Keep `include!` targets, split fragments and attributes/doc comments alongside
|
||||||
attributes/doc comments. This prevents a file boundary from separating `#[derive]`,
|
the items they describe. After removal of the old source-check scripts, review
|
||||||
`#[test]` or documentation from the Rust item it belongs to.
|
these invariants in code review and verify compilability with `./dev.sh check`.
|
||||||
|
|
||||||
Docker discovery is an optional provider boundary implemented through the standalone
|
Docker discovery is an optional provider boundary implemented through the standalone
|
||||||
`docker-agent/`; the RustProxy process never opens the Docker socket. Cloudflare Tunnel
|
`docker-agent/`; the RustProxy process never opens the Docker socket. Cloudflare Tunnel
|
||||||
|
|||||||
+5
-10
@@ -28,20 +28,15 @@ with real-runtime measurements rather than assuming either explanation.
|
|||||||
## Source/synthetic verification
|
## Source/synthetic verification
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 tools/checks/memory_contracts.py
|
|
||||||
python3 tools/checks/high_traffic_memory_model.py --http-requests 10000
|
|
||||||
./dev.sh check
|
./dev.sh check
|
||||||
./dev.sh test
|
./dev.sh test
|
||||||
|
# Perform separate real-runtime load and RSS measurements on staging.
|
||||||
```
|
```
|
||||||
|
|
||||||
The offline test replays 300,000 distinct PURGE identities over six periods.
|
The old Python cardinality model and protocol-peer load driver were removed
|
||||||
It verifies the old cumulative model retains 50k/100k/150k/200k/250k/300k
|
with `tools/checks/`. They were synthetic tests, not proof of RustProxy memory
|
||||||
metadata keys whereas the revised epoch-based model holds one key after each
|
bounds. `./dev.sh test` now runs only the Rust suite; runtime memory behavior
|
||||||
operation. This is a **cardinality model**, not a Rust heap measurement. It also
|
must still be verified by sustained staging traffic and RSS tracking.
|
||||||
sends 10,000 HTTP GETs across 24 concurrent persistent connections and 1,000
|
|
||||||
paths to a **Python test peer** to validate the load driver (the default quick
|
|
||||||
`./dev.sh test` run sends 2,000 of those requests). The Rust regression
|
|
||||||
for 512 distinct purges is executed only if `cargo test` runs successfully.
|
|
||||||
|
|
||||||
## Real RustProxy staging traffic and RSS
|
## Real RustProxy staging traffic and RSS
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -414,7 +414,7 @@ Managed paths now include build.rs, langs, deploy and make_zip.py. Extra *.json
|
|||||||
language packs from the installed langs directory are copied into the new tree
|
language packs from the installed langs directory are copied into the new tree
|
||||||
when the new distribution does not contain the same filename. Shipped packs are
|
when the new distribution does not contain the same filename. Shipped packs are
|
||||||
updated, with old bytes preserved in the source backup. Invalid custom packs can
|
updated, with old bytes preserved in the source backup. Invalid custom packs can
|
||||||
correctly block a build; review them with tools/checks/langs_check.py. No symlinked
|
correctly block a build; review their JSON syntax and metadata before building. No symlinked
|
||||||
language pack is followed. The updater never copies a database from the package.
|
language pack is followed. The updater never copies a database from the package.
|
||||||
|
|
||||||
The old lockfile is preserved in the managed source backup and a new dependency
|
The old lockfile is preserved in the managed source backup and a new dependency
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ change does not alter server polling, proxy routing or backend metrics.
|
|||||||
|
|
||||||
**Refresh now**, **Arrange Overview**, **Unlock layout**, **Visual Overview editor** and **Show all cards** inherit the same Normal/Compact button size rules as other buttons. Responsive placement still adapts to narrow screens, but no independent font-size, padding or height override applies. Dashboard density more clearly adjusts numeric tile sizing, inline chart height and the padding/title size of non-table Overview panels; the common tile gutters are unchanged. Button style and dashboard density remain independent per-account preferences.
|
**Refresh now**, **Arrange Overview**, **Unlock layout**, **Visual Overview editor** and **Show all cards** inherit the same Normal/Compact button size rules as other buttons. Responsive placement still adapts to narrow screens, but no independent font-size, padding or height override applies. Dashboard density more clearly adjusts numeric tile sizing, inline chart height and the padding/title size of non-table Overview panels; the common tile gutters are unchanged. Button style and dashboard density remain independent per-account preferences.
|
||||||
|
|
||||||
|
### Overview header actions (0.32.8)
|
||||||
|
|
||||||
|
On the Overview page, **Traffic, in focus.** remains on the left and actions
|
||||||
|
align to the right on the same row when space permits. Buttons wrap to the right
|
||||||
|
on very narrow displays instead of being compressed into equal-width columns.
|
||||||
|
The existing Normal/Compact button preferences, editor confirmation actions,
|
||||||
|
layout locking and account-specific saved card layout are unchanged.
|
||||||
|
|
||||||
## Refresh preferences (0.31.0)
|
## Refresh preferences (0.31.0)
|
||||||
|
|
||||||
**Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values:
|
**Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values:
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# RustProxy 0.32.8 - verification report
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
UI-only Overview header/editor button alignment, deletion of the complete
|
||||||
|
`tools/checks/` source directory, retained source-upgrade preflight and the
|
||||||
|
0.32.8 version bump. No proxy request path, backend behavior, preferences
|
||||||
|
schema or dashboard JavaScript event handlers were changed.
|
||||||
|
|
||||||
|
## Checks performed
|
||||||
|
|
||||||
|
- **PASS** - Chromium headless checked the actual Overview HTML and CSS at
|
||||||
|
viewport widths 320, 360, 375, 390, 430, 480, 600, 700, 768, 1024 and
|
||||||
|
1440 px, with Normal and Compact button styles and four visibility/editing
|
||||||
|
scenarios: 88 combinations. Title stays left, available actions align right;
|
||||||
|
no overlapping buttons or page/header horizontal overflow was observed.
|
||||||
|
This is a layout fixture, not a running API/backend acceptance test.
|
||||||
|
- **PASS** - All Overview and editor controls use the original semantic
|
||||||
|
`quiet`, `outline` and `primary` classes. No CSS class names were added.
|
||||||
|
Removed obsolete dedicated button selectors. The Overview/core/refresh/layout
|
||||||
|
JavaScript files are byte-identical to 0.32.7, so existing event handling,
|
||||||
|
save/cancel and editor locking implementations were not changed.
|
||||||
|
- **PASS** - Before removing the old QA directory, the 0.32.7 UI simulation
|
||||||
|
suites `dashboard_layout_editor_0323_ui_test.mjs` and
|
||||||
|
`dashboard_inline_layout_0323_ui_test.mjs` passed. The original
|
||||||
|
`appearance_density_buttons_0327.py` source check also passed. These tests
|
||||||
|
exercised simulated UI behavior, not a live RustProxy server.
|
||||||
|
- **PASS** - `python3 scripts/preflight.py`, `./dev.sh doctor`,
|
||||||
|
`./dev.sh repair`, shell syntax of `dev.sh` and the dependency-resolution
|
||||||
|
script, all bundled language JSON files, version consistency and source
|
||||||
|
class/handler comparisons.
|
||||||
|
- **PASS** - Source-upgrade rehearsal from the supplied 0.32.7 release tree
|
||||||
|
to 0.32.8 removed `tools/checks/` from the installed working tree, retained
|
||||||
|
the previous sources under `.upgrade-backup/`, preserved `.env` and left
|
||||||
|
`./dev.sh doctor` working. No live server/database was used in this rehearsal.
|
||||||
|
- **PASS** - All eight bundled SQL migration scripts applied twice in an
|
||||||
|
in-memory SQLite database; `PRAGMA integrity_check` returned `ok`.
|
||||||
|
|
||||||
|
## Limits and outstanding acceptance
|
||||||
|
|
||||||
|
- Rust/Cargo was **not available** in the verification environment, so
|
||||||
|
`./dev.sh check`, `./dev.sh test`, binary build and live proxy/UI regression
|
||||||
|
checks were not run. These remain required before production acceptance.
|
||||||
|
- An unrelated legacy `dashboard_startup_0310_ui_test.mjs` assertion was already
|
||||||
|
failing on the supplied 0.32.7 tree due to its expected
|
||||||
|
`prefers-reduced-motion: reduce` CSS rule being absent. That test was removed
|
||||||
|
with `tools/checks/`, not claimed as passing or fixed by this patch.
|
||||||
|
- The intentionally removed `tools/checks/` suites are no longer executed by
|
||||||
|
`dev.sh`; `./dev.sh test` now runs Cargo validation and the Rust test suite.
|
||||||
|
Independent API, stress, browser and real-runtime acceptance tests must be
|
||||||
|
run externally when required.
|
||||||
+4
-2
@@ -34,7 +34,9 @@ triggers a Cargo rebuild. Duplicate locales, invalid metadata, non-string values
|
|||||||
and oversized packs cause a build error. Limits: 128 packs, 256 KiB per pack,
|
and oversized packs cause a build error. Limits: 128 packs, 256 KiB per pack,
|
||||||
4096 keys per pack, 8 MiB total bundle.
|
4096 keys per pack, 8 MiB total bundle.
|
||||||
|
|
||||||
Run `python3 tools/checks/langs_check.py` before a build. The checker also validates
|
Validate pack JSON locally (`python3 -m json.tool langs/en_US.json > /dev/null`,
|
||||||
that every static UI translation key has an English fallback. Extra JSON packs survive the managed updater when the new release does not contain
|
repeating for custom packs). The Rust build validates translation metadata and
|
||||||
|
bundle limits. Ensure static UI keys have an English fallback. Extra JSON packs
|
||||||
|
survive the managed updater when the new release does not contain
|
||||||
the same filename. Keep a separate backup too; shipped filenames are replaced and
|
the same filename. Keep a separate backup too; shipped filenames are replaced and
|
||||||
the previous bytes are retained in the source backup.
|
the previous bytes are retained in the source backup.
|
||||||
@@ -74,7 +74,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
|||||||
flat = module.parent.with_suffix(".rs")
|
flat = module.parent.with_suffix(".rs")
|
||||||
if flat.is_file():
|
if flat.is_file():
|
||||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
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/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.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/vhost/error_fallback.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/rates.js", "src/admin/web/vhosts/health.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/session-resume.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/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"]:
|
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.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/vhost/error_fallback.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/rates.js", "src/admin/web/vhosts/health.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/session-resume.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/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "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():
|
if not (root / path).is_file():
|
||||||
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
|
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
|
||||||
# The Reader uses compile-time include_str! for its bundled Markdown. A
|
# The Reader uses compile-time include_str! for its bundled Markdown. A
|
||||||
@@ -97,7 +97,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
parser.add_argument("--repair-known", action="store_true")
|
parser.add_argument("--repair-known", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
@@ -7,14 +7,13 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the
|
|||||||
printf 'Rust 1.99+ and Cargo are required; no lockfile was regenerated.\n' >&2
|
printf 'Rust 1.99+ and Cargo are required; no lockfile was regenerated.\n' >&2
|
||||||
exit 127
|
exit 127
|
||||||
fi
|
fi
|
||||||
python3 tools/checks/preflight.py
|
python3 scripts/preflight.py
|
||||||
if [[ "$(python3 scripts/version.py)" != '0.31.8' ]]; then
|
if [[ "$(python3 scripts/version.py)" != '0.31.8' ]]; then
|
||||||
printf 'Expected RustProxy 0.31.8 sources.\n' >&2
|
printf 'Expected RustProxy 0.31.8 sources.\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
printf 'Toolchain: '; rustc --version
|
printf 'Toolchain: '; rustc --version
|
||||||
cargo update
|
cargo update
|
||||||
python3 tools/checks/dependency_upgrade_0312.py --require-lock
|
|
||||||
cargo metadata --locked --format-version 1 --no-deps >/dev/null
|
cargo metadata --locked --format-version 1 --no-deps >/dev/null
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo clippy --locked --all-targets -- -D warnings
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "RustProxy Administration API",
|
"title": "RustProxy Administration API",
|
||||||
"version": "0.32.7",
|
"version": "0.32.8",
|
||||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
|
|||||||
@@ -117,10 +117,10 @@
|
|||||||
LOCAL CORE</span></div>
|
LOCAL CORE</span></div>
|
||||||
<div class="page-intro-title-row">
|
<div class="page-intro-title-row">
|
||||||
<h1 id="page-title">Traffic, in focus.</h1>
|
<h1 id="page-title">Traffic, in focus.</h1>
|
||||||
<button class="quiet overview-refresh" data-i18n="refreshNow" id="overview-refresh"
|
<button class="quiet" data-i18n="refreshNow" id="overview-refresh" type="button"
|
||||||
type="button" hidden>Refresh now</button>
|
hidden>Refresh now</button>
|
||||||
<button class="quiet overview-layout-open" data-i18n="overviewLayoutEditorOpen"
|
<button class="quiet" data-i18n="overviewLayoutEditorOpen" id="overview-layout-open"
|
||||||
id="overview-layout-open" type="button" hidden>Arrange cards visually</button>
|
type="button" hidden>Arrange cards visually</button>
|
||||||
<button class="outline" id="overview-layout-unlock" type="button" hidden
|
<button class="outline" id="overview-layout-unlock" type="button" hidden
|
||||||
data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button>
|
data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button>
|
||||||
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
|
<div class="overview-inline-actions" id="overview-inline-actions" hidden>
|
||||||
@@ -2496,7 +2496,7 @@
|
|||||||
the backend table.</small>
|
the backend table.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-widget-settings-controls">
|
<div class="dashboard-widget-settings-controls">
|
||||||
<button class="outline dashboard-layout-open-settings" type="button"
|
<button class="outline" type="button"
|
||||||
id="overview-layout-open-settings"
|
id="overview-layout-open-settings"
|
||||||
data-i18n="overviewLayoutEditorOpenSettings">Visual layout
|
data-i18n="overviewLayoutEditorOpenSettings">Visual layout
|
||||||
editor</button>
|
editor</button>
|
||||||
|
|||||||
+9
-55
@@ -1290,10 +1290,6 @@ button.is-saving {
|
|||||||
padding-top: 25px
|
padding-top: 25px
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro .outline {
|
|
||||||
margin-top: 19px
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 1.2px
|
letter-spacing: 1.2px
|
||||||
@@ -2201,13 +2197,6 @@ td {
|
|||||||
font-size: 11px
|
font-size: 11px
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro .outline {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 7px;
|
|
||||||
white-space: normal;
|
|
||||||
max-width: 95px
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro .eyebrow {
|
.page-intro .eyebrow {
|
||||||
letter-spacing: .5px;
|
letter-spacing: .5px;
|
||||||
font-size: 8px
|
font-size: 8px
|
||||||
@@ -9518,55 +9507,27 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
.page-intro-copy,
|
.page-intro-copy,
|
||||||
.page-intro-title-row {
|
.page-intro-title-row {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro-title-row {
|
.page-intro-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro-title-row h1 {
|
.page-intro-title-row h1 {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto
|
flex: 1 1 auto;
|
||||||
}
|
margin-right: auto;
|
||||||
|
|
||||||
.page-intro-title-row .overview-refresh {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-left: auto;
|
|
||||||
white-space: nowrap
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro-title-row>:is(.quiet, .outline) {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width:700px) {
|
@media(max-width:700px) {
|
||||||
.page-intro-title-row {
|
.page-intro-title-row {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-intro-title-row .overview-inline-actions,
|
|
||||||
.page-intro-title-row h1 {
|
|
||||||
grid-column: 1/-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro-title-row .overview-refresh {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro-title-row>:is(.quiet, .outline) {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: normal;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(max-width:480px) {
|
@media(max-width:480px) {
|
||||||
@@ -10398,11 +10359,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-layout-open-settings {
|
|
||||||
margin: 5px 0;
|
|
||||||
justify-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media(max-width:850px) {
|
@media(max-width:850px) {
|
||||||
|
|
||||||
.overview-layout-grid,
|
.overview-layout-grid,
|
||||||
@@ -10594,7 +10550,10 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-inline-actions[hidden],
|
.overview-inline-actions[hidden],
|
||||||
@@ -11137,11 +11096,6 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
|
|||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-widget-settings-controls .dashboard-layout-open-settings {
|
|
||||||
margin: 0;
|
|
||||||
justify-self: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-appearance-grid .dashboard-widget-list {
|
.dashboard-appearance-grid .dashboard-widget-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ impl BackendState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn observe_passive(
|
fn observe_passive(
|
||||||
&self,
|
&self,
|
||||||
active: &HealthCheck,
|
active: &HealthCheck,
|
||||||
|
|||||||
+1
-2
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
These files are not required by the running RustProxy proxy process.
|
These files are not required by the running RustProxy proxy process.
|
||||||
|
|
||||||
- `checks/` - compiler-independent source/release/documentation contracts and UI checks.
|
|
||||||
- `dev/` - local mock services and diagnostic traffic/compression helpers.
|
- `dev/` - local mock services and diagnostic traffic/compression helpers.
|
||||||
- `audit/` - explicit operator audit/probe tools.
|
- `audit/` - explicit operator audit/probe tools.
|
||||||
- `legacy/` - retained compatibility/release-test helpers; not the supported production workflow.
|
- `legacy/` - retained compatibility/release-test helpers; not the supported production workflow.
|
||||||
|
|
||||||
Production packages do not need this tree unless a packaging/test workflow intentionally ships it.
|
The removed `checks/` tree is not shipped. Use `./dev.sh check` and `./dev.sh test` for Rust validation; source/upgrade preflight lives in `scripts/preflight.py`.
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// Test the real admin settings normalization helpers and visible form wiring;
|
|
||||||
// source-level UI test only, not a Rust or browser integration test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const js = fs.readFileSync('src/admin/web/settings.js','utf8');
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html','utf8');
|
|
||||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json','utf8'));
|
|
||||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json','utf8'));
|
|
||||||
const api = JSON.parse(fs.readFileSync('src/admin/openapi.json','utf8'));
|
|
||||||
const begin = js.indexOf(' function pinnedAdminHostname(value) {');
|
|
||||||
const end = js.indexOf(' const normalizedRequestedTab', begin);
|
|
||||||
assert(begin >= 0 && end > begin, 'real admin form normalization helpers must exist');
|
|
||||||
const sandbox = vm.createContext({URL});
|
|
||||||
vm.runInContext(js.slice(begin,end),sandbox);
|
|
||||||
const normalize = (hosts, pin) => Array.from(vm.runInContext(
|
|
||||||
`additionalAdminHosts(${JSON.stringify(hosts)},${JSON.stringify(pin)})`,sandbox));
|
|
||||||
|
|
||||||
const cases = [
|
|
||||||
{name:'empty form',hosts:[],pin:null,expected:[]},
|
|
||||||
{name:'simple single origin',hosts:[],pin:'https://panel.example.test',expected:[]},
|
|
||||||
{name:'0.31.4 repeated host',hosts:['panel.example.test'],pin:'https://panel.example.test',expected:[]},
|
|
||||||
{name:'case and trailing dot',hosts:['PANEL.Example.Test.',' other.example.test '],pin:'https://panel.example.test:8443',expected:['other.example.test']},
|
|
||||||
{name:'multiple additional names',hosts:['foo.example.test','panel.example.test','bar.example.test'],pin:'https://panel.example.test',expected:['foo.example.test','bar.example.test']},
|
|
||||||
{name:'pin changed',hosts:['other.example.test'],pin:'https://new.example.test',expected:['other.example.test']},
|
|
||||||
{name:'no pin means independent allowlist',hosts:['panel.example.test'],pin:'',expected:['panel.example.test']},
|
|
||||||
{name:'invalid URL delegates to server validation',hosts:['other.example.test'],pin:'not a url',expected:['other.example.test']},
|
|
||||||
{name:'blank host lines removed',hosts:['',' panel.example.test ',' '],pin:null,expected:['panel.example.test']},
|
|
||||||
];
|
|
||||||
for (const item of cases) {
|
|
||||||
assert.deepEqual(normalize(item.hosts,item.pin),item.expected,item.name);
|
|
||||||
}
|
|
||||||
const admin = html.split('id="settings-page-administration"')[1].split('id="settings-page-system-info"')[0];
|
|
||||||
for (const id of ['admin-public-origin','admin-extra-hosts-details','admin-hosts'])
|
|
||||||
assert(admin.includes(`id="${id}"`),`missing ${id}`);
|
|
||||||
assert(admin.indexOf('id="admin-public-origin"') < admin.indexOf('id="admin-extra-hosts-details"'));
|
|
||||||
assert(admin.includes('<details id="admin-extra-hosts-details">'));
|
|
||||||
assert(js.includes('$("admin-extra-hosts-details").open = extraHosts.length > 0'));
|
|
||||||
assert(js.includes('additionalAdminHosts(admin.allowed_hosts || [], admin.public_origin)'));
|
|
||||||
assert(js.includes('allowed_hosts: additionalAdminHosts($("admin-hosts").value.split('));
|
|
||||||
assert(!js.includes('allowed_hosts: $("admin-hosts").value.split('));
|
|
||||||
for (const key of ['administrationHosts','administrationHostsAdvanced','administrationHostsHelp','administrationPublicOriginHelp']) {
|
|
||||||
assert.equal(typeof en.translations[key],'string'); assert.equal(typeof pl.translations[key],'string');
|
|
||||||
assert(en.translations[key].length > 15 && pl.translations[key].length > 15,key);
|
|
||||||
}
|
|
||||||
// Config contract remains compatible; the pin allows a host implicitly.
|
|
||||||
const spec = api.components?.schemas || api.definitions;
|
|
||||||
const specText=JSON.stringify(spec);
|
|
||||||
assert(specText.includes('implicitly allowed'));
|
|
||||||
assert(specText.includes('automatically allowed'));
|
|
||||||
console.log(`PASS: ${cases.length} real settings-helper scenarios, HTML/wiring, locale parity and OpenAPI descriptions`);
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
|
|
||||||
// Real-source anchors guard against accidental model/source drift. This is NOT
|
|
||||||
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import http from 'node:http';
|
|
||||||
import {fileURLToPath} from 'node:url';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
||||||
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
|
|
||||||
const source = {
|
|
||||||
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
|
|
||||||
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
|
|
||||||
};
|
|
||||||
for (const [part, tokens] of Object.entries({
|
|
||||||
settings: ['pub public_origin: Option<String>', 'fn normalize_public_admin_origin(public_origin: &str)', 'fn include_public_origin_host(&mut self)', 'fn explicitly_allowed_panel_host(&self, host: &str)', 'self.public_origin != active.public_origin'],
|
|
||||||
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
|
|
||||||
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
|
|
||||||
forwarding: ['self.trust_proto && self.trusted(ip)'],
|
|
||||||
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
|
|
||||||
|
|
||||||
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
|
|
||||||
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
|
|
||||||
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
|
|
||||||
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
|
|
||||||
automation:false};
|
|
||||||
const normal = x => x.toLowerCase();
|
|
||||||
function authority(value) {
|
|
||||||
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
|
|
||||||
try {
|
|
||||||
const parsed = new URL(`https://${value}/`);
|
|
||||||
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
|
|
||||||
const host = parsed.hostname.toLowerCase();
|
|
||||||
return {host, port:parsed.port ? Number(parsed.port) : null};
|
|
||||||
} catch {return null;}
|
|
||||||
}
|
|
||||||
function sameAuthority(a,b,scheme) {
|
|
||||||
const left=authority(a), right=authority(b);
|
|
||||||
if (!left || !right) return false;
|
|
||||||
const defaultPort = scheme === 'https' ? 443 : 80;
|
|
||||||
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
|
|
||||||
}
|
|
||||||
function hostAllowed(value, tls, cfg) {
|
|
||||||
const actual=authority(value);
|
|
||||||
if (!actual) return false;
|
|
||||||
const port=actual.port ?? (tls ? 443 : 80);
|
|
||||||
// Effective runtime/desired allowlist also includes the public origin host;
|
|
||||||
// only extra names are persisted in allowed_hosts.
|
|
||||||
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
|
|
||||||
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|
|
||||||
|| cfg.allowedHosts.includes(actual.host)
|
|
||||||
|| actual.host===pinnedHost;
|
|
||||||
}
|
|
||||||
const map = raw => {
|
|
||||||
const headers=new Map();
|
|
||||||
for (let i=0;i<raw.length;i+=2) {
|
|
||||||
const name=normal(raw[i]);
|
|
||||||
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
const all = (headers,name) => headers.get(name)||[];
|
|
||||||
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
|
|
||||||
function protoCompatible(headers) {
|
|
||||||
let hops=0;
|
|
||||||
for (const value of all(headers,'x-forwarded-proto')) {
|
|
||||||
for (const part of value.split(',')) {
|
|
||||||
hops++;
|
|
||||||
const scheme=part.trim();
|
|
||||||
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function originMatches(value,scheme,external) {
|
|
||||||
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
|
|
||||||
try {
|
|
||||||
const uri=new URL(value);
|
|
||||||
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
|
|
||||||
return sameAuthority(uri.host, external, scheme);
|
|
||||||
} catch {return false;}
|
|
||||||
}
|
|
||||||
function guard(headers,cfg) {
|
|
||||||
const rawHost=single(headers,'host');
|
|
||||||
if (!rawHost) return {status:403,reason:'invalid_host'};
|
|
||||||
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
|
|
||||||
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
|
|
||||||
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
|
|
||||||
let pinned;
|
|
||||||
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
|
|
||||||
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
|
|
||||||
return {status:403,reason:'pinned_origin_mismatch'};
|
|
||||||
const xfh=all(headers,'x-forwarded-host');
|
|
||||||
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
|
|
||||||
return {status:403,reason:'pinned_origin_mismatch'};
|
|
||||||
scheme='https'; external=pinned.host;
|
|
||||||
} else {
|
|
||||||
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
|
|
||||||
const proto=single(headers,'x-forwarded-proto');
|
|
||||||
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
|
|
||||||
scheme=proto;
|
|
||||||
}
|
|
||||||
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
|
|
||||||
external=single(headers,'x-forwarded-host');
|
|
||||||
if (!hostAllowed(external,scheme==='https',cfg))
|
|
||||||
return {status:403,reason:'invalid_host'};
|
|
||||||
}
|
|
||||||
const originValues=all(headers,'origin');
|
|
||||||
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
|
|
||||||
return {status:403,reason:'origin_mismatch'};
|
|
||||||
if (all(headers,'sec-fetch-site').includes('cross-site'))
|
|
||||||
return {status:403,reason:'cross_site_fetch'};
|
|
||||||
return {status:200,reason:'pass',scheme};
|
|
||||||
}
|
|
||||||
const cases=[];
|
|
||||||
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
|
|
||||||
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
|
|
||||||
add('legacy normal same-origin GET', {},{},'pass');
|
|
||||||
add('no pin and no DNS allowlist rejects proxied host', {},{allowedHosts:[]},'invalid_host');
|
|
||||||
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
|
|
||||||
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
|
|
||||||
add('legacy explicit DNS host without pin', {'host':'other.example.test','x-forwarded-host':'other.example.test'},
|
|
||||||
{allowedHosts:['other.example.test']}, 'pass');
|
|
||||||
add('legacy extra host without pin still denies unknown', {'host':'evil.example.test','x-forwarded-host':'evil.example.test'},
|
|
||||||
{allowedHosts:['other.example.test']}, 'invalid_host');
|
|
||||||
add('pin: normal GET, persisted allowed_hosts empty', {},PIN,'pass');
|
|
||||||
add('pin: public Host implicitly allowed', {'host':'panel.example.test'},PIN,'pass');
|
|
||||||
add('pin: unknown Host still rejected', {'host':'unknown.example.test'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: old 0.31.4 explicit hostname remains compatible', {}, {...PIN,allowedHosts:['panel.example.test']},'pass');
|
|
||||||
add('pin: additional DNS host accepted only with matching forwarded public Host',
|
|
||||||
{'host':'other.example.test'}, {...PIN,allowedHosts:['other.example.test']},'pass');
|
|
||||||
add('pin: changing public origin does not allow previous hostname',
|
|
||||||
{'host':'panel.example.test','x-forwarded-host':'new.example.test'},
|
|
||||||
{...PIN,publicOrigin:'https://new.example.test'},'pinned_origin_mismatch');
|
|
||||||
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
|
|
||||||
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
|
|
||||||
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
|
|
||||||
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
|
|
||||||
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
|
|
||||||
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
|
|
||||||
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
|
|
||||||
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
|
|
||||||
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
|
|
||||||
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
|
|
||||||
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
|
|
||||||
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
|
|
||||||
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
|
|
||||||
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
|
|
||||||
|
|
||||||
function encodedHeaders(patch) {
|
|
||||||
const merged={...defaults,...patch};
|
|
||||||
const result={};
|
|
||||||
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const pending=[];
|
|
||||||
const server=http.createServer((req,res)=>{
|
|
||||||
const item=pending.shift();
|
|
||||||
if (!item) return res.writeHead(500).end('no scenario');
|
|
||||||
const result=guard(map(req.rawHeaders),{...context,...item.opts});
|
|
||||||
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
|
|
||||||
});
|
|
||||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
||||||
async function request(item) {
|
|
||||||
pending.push(item);
|
|
||||||
return await new Promise((resolve,reject)=>{
|
|
||||||
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
|
|
||||||
agent:false,headers:encodedHeaders(item.patch)},res=>{
|
|
||||||
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
|
|
||||||
res.on('end',()=>resolve(JSON.parse(data)));
|
|
||||||
});
|
|
||||||
req.on('error',reject);req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
for (const item of cases) {
|
|
||||||
const result=await request(item);
|
|
||||||
assert.equal(result.reason,item.expected,item.name);
|
|
||||||
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
|
|
||||||
}
|
|
||||||
const repeats=[];
|
|
||||||
for (const configured of [false,true]) {
|
|
||||||
for (let attempt=1;attempt<=12;attempt++) {
|
|
||||||
const malformed=attempt%4===0;
|
|
||||||
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
|
|
||||||
opts:configured? PIN : {}});
|
|
||||||
assert.equal(result.status,configured?200:(malformed?403:200));
|
|
||||||
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
|
|
||||||
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
|
|
||||||
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
|
|
||||||
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
|
|
||||||
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
|
|
||||||
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
|
|
||||||
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
|
|
||||||
} finally {server.close();}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
|
|
||||||
// Real-source anchors guard against accidental model/source drift. This is NOT
|
|
||||||
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import http from 'node:http';
|
|
||||||
import {fileURLToPath} from 'node:url';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
||||||
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
|
|
||||||
const source = {
|
|
||||||
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
|
|
||||||
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
|
|
||||||
};
|
|
||||||
for (const [part, tokens] of Object.entries({
|
|
||||||
settings: ['pub public_origin: Option<String>', 'fn include_public_origin_host(&mut self)', 'self.public_origin != active.public_origin'],
|
|
||||||
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
|
|
||||||
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
|
|
||||||
forwarding: ['self.trust_proto && self.trusted(ip)'],
|
|
||||||
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
|
|
||||||
|
|
||||||
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
|
|
||||||
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
|
|
||||||
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
|
|
||||||
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
|
|
||||||
automation:false};
|
|
||||||
const normal = x => x.toLowerCase();
|
|
||||||
function authority(value) {
|
|
||||||
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
|
|
||||||
try {
|
|
||||||
const parsed = new URL(`https://${value}/`);
|
|
||||||
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
|
|
||||||
const host = parsed.hostname.toLowerCase();
|
|
||||||
return {host, port:parsed.port ? Number(parsed.port) : null};
|
|
||||||
} catch {return null;}
|
|
||||||
}
|
|
||||||
function sameAuthority(a,b,scheme) {
|
|
||||||
const left=authority(a), right=authority(b);
|
|
||||||
if (!left || !right) return false;
|
|
||||||
const defaultPort = scheme === 'https' ? 443 : 80;
|
|
||||||
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
|
|
||||||
}
|
|
||||||
function hostAllowed(value, tls, cfg) {
|
|
||||||
const actual=authority(value);
|
|
||||||
if (!actual) return false;
|
|
||||||
const port=actual.port ?? (tls ? 443 : 80);
|
|
||||||
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
|
|
||||||
// The validated public panel hostname is derived at runtime.
|
|
||||||
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|
|
||||||
|| cfg.allowedHosts.includes(actual.host)
|
|
||||||
|| actual.host===pinnedHost;
|
|
||||||
}
|
|
||||||
const map = raw => {
|
|
||||||
const headers=new Map();
|
|
||||||
for (let i=0;i<raw.length;i+=2) {
|
|
||||||
const name=normal(raw[i]);
|
|
||||||
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
const all = (headers,name) => headers.get(name)||[];
|
|
||||||
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
|
|
||||||
function protoCompatible(headers) {
|
|
||||||
let hops=0;
|
|
||||||
for (const value of all(headers,'x-forwarded-proto')) {
|
|
||||||
for (const part of value.split(',')) {
|
|
||||||
hops++;
|
|
||||||
const scheme=part.trim();
|
|
||||||
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function originMatches(value,scheme,external) {
|
|
||||||
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
|
|
||||||
try {
|
|
||||||
const uri=new URL(value);
|
|
||||||
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
|
|
||||||
return sameAuthority(uri.host, external, scheme);
|
|
||||||
} catch {return false;}
|
|
||||||
}
|
|
||||||
function guard(headers,cfg) {
|
|
||||||
const rawHost=single(headers,'host');
|
|
||||||
if (!rawHost) return {status:403,reason:'invalid_host'};
|
|
||||||
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
|
|
||||||
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
|
|
||||||
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
|
|
||||||
let pinned;
|
|
||||||
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
|
|
||||||
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
|
|
||||||
return {status:403,reason:'pinned_origin_mismatch'};
|
|
||||||
const xfh=all(headers,'x-forwarded-host');
|
|
||||||
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
|
|
||||||
return {status:403,reason:'pinned_origin_mismatch'};
|
|
||||||
scheme='https'; external=pinned.host;
|
|
||||||
} else {
|
|
||||||
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
|
|
||||||
const proto=single(headers,'x-forwarded-proto');
|
|
||||||
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
|
|
||||||
scheme=proto;
|
|
||||||
}
|
|
||||||
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
|
|
||||||
external=single(headers,'x-forwarded-host');
|
|
||||||
if (!hostAllowed(external,scheme==='https',cfg))
|
|
||||||
return {status:403,reason:'invalid_host'};
|
|
||||||
}
|
|
||||||
const originValues=all(headers,'origin');
|
|
||||||
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
|
|
||||||
return {status:403,reason:'origin_mismatch'};
|
|
||||||
if (all(headers,'sec-fetch-site').includes('cross-site'))
|
|
||||||
return {status:403,reason:'cross_site_fetch'};
|
|
||||||
return {status:200,reason:'pass',scheme};
|
|
||||||
}
|
|
||||||
const cases=[];
|
|
||||||
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
|
|
||||||
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
|
|
||||||
add('legacy normal same-origin GET', {},{},'pass');
|
|
||||||
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
|
|
||||||
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
|
|
||||||
add('pin: normal GET', {},PIN,'pass');
|
|
||||||
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
|
|
||||||
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
|
|
||||||
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
|
|
||||||
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
|
|
||||||
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
|
|
||||||
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
|
|
||||||
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
|
|
||||||
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
|
|
||||||
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
|
|
||||||
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
|
|
||||||
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
|
|
||||||
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
|
|
||||||
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
|
|
||||||
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
|
|
||||||
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
|
|
||||||
|
|
||||||
function encodedHeaders(patch) {
|
|
||||||
const merged={...defaults,...patch};
|
|
||||||
const result={};
|
|
||||||
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const pending=[];
|
|
||||||
const server=http.createServer((req,res)=>{
|
|
||||||
const item=pending.shift();
|
|
||||||
if (!item) return res.writeHead(500).end('no scenario');
|
|
||||||
const result=guard(map(req.rawHeaders),{...context,...item.opts});
|
|
||||||
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
|
|
||||||
});
|
|
||||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
||||||
async function request(item) {
|
|
||||||
pending.push(item);
|
|
||||||
return await new Promise((resolve,reject)=>{
|
|
||||||
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
|
|
||||||
agent:false,headers:encodedHeaders(item.patch)},res=>{
|
|
||||||
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
|
|
||||||
res.on('end',()=>resolve(JSON.parse(data)));
|
|
||||||
});
|
|
||||||
req.on('error',reject);req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
for (const item of cases) {
|
|
||||||
const result=await request(item);
|
|
||||||
assert.equal(result.reason,item.expected,item.name);
|
|
||||||
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
|
|
||||||
}
|
|
||||||
const repeats=[];
|
|
||||||
for (const configured of [false,true]) {
|
|
||||||
for (let attempt=1;attempt<=12;attempt++) {
|
|
||||||
const malformed=attempt%4===0;
|
|
||||||
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
|
|
||||||
opts:configured? PIN : {}});
|
|
||||||
assert.equal(result.status,configured?200:(malformed?403:200));
|
|
||||||
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
|
|
||||||
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
|
|
||||||
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
|
|
||||||
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
|
|
||||||
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
|
|
||||||
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
|
|
||||||
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
|
|
||||||
} finally {server.close();}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail-closed source regression checks. Not a replacement for running Rust tests."""
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[2]
|
|
||||||
def read(name):
|
|
||||||
return (root / name).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def must(source, *text):
|
|
||||||
for item in text:
|
|
||||||
if item not in source:
|
|
||||||
raise AssertionError(f"missing security invariant: {item}")
|
|
||||||
|
|
||||||
router = read('src/admin/router.rs')
|
|
||||||
access = read('src/admin/access.rs')
|
|
||||||
session = read('src/admin/session.rs')
|
|
||||||
proxy = read('src/proxy/forward_entry.rs')
|
|
||||||
maintenance = read('src/proxy/cache_http_invalidation.rs')
|
|
||||||
operations = read('src/admin/operations.rs')
|
|
||||||
cache_policy = read('src/cache/model/http_invalidation.rs')
|
|
||||||
app = read('src/runtime/types.rs') + read('src/runtime/constructors.rs')
|
|
||||||
|
|
||||||
must(router,
|
|
||||||
'fn anonymous_allowed', 'panel_document_path(path)',
|
|
||||||
'/api/auth/status', '/api/auth/setup', '/api/auth/login',
|
|
||||||
'.layer(middleware::from_fn_with_state(state.clone(), guard))',
|
|
||||||
'"content-security-policy"', '"x-frame-options"',
|
|
||||||
'"x-content-type-options"', '"no-referrer"',
|
|
||||||
'"/api/system-info", get(system_info)',
|
|
||||||
'"/api/events", get(events)',
|
|
||||||
'"/api/cache/invalidate"',
|
|
||||||
'"/api/config", get(configuration).merge(put(update))')
|
|
||||||
must(session,
|
|
||||||
'if !public && session.is_none()',
|
|
||||||
'if unsafe_method && !external_certificate_api',
|
|
||||||
'single_header(headers, "origin")',
|
|
||||||
'single_header(headers, "x-rustproxy-request") != Some("1")',
|
|
||||||
'single_header(headers, "x-csrf-token")',
|
|
||||||
'auth::equal_secret',
|
|
||||||
'session_at_with_browser_lifetime',
|
|
||||||
'secure(next.run(request).await)',
|
|
||||||
'h == "cross-site"')
|
|
||||||
must(access, 'admin_request_authority', 'admin_origin_matches',
|
|
||||||
'trusted_admin_proxy', 'single_header(headers, "x-forwarded-host")',
|
|
||||||
'certificate_api_credentials', 'valid_certificate_api_token')
|
|
||||||
must(session, 'verify_certificate_api_credential')
|
|
||||||
|
|
||||||
# Only the intended bootstrap/discovery APIs may appear inside the anonymous allowlist.
|
|
||||||
allowlist = router.split('fn anonymous_allowed(', 1)[1].split('\n}', 1)[0]
|
|
||||||
allowed_api_paths = set(re.findall(r'"(/api/[^" ]+)"', allowlist))
|
|
||||||
assert allowed_api_paths == {'/api/auth/status', '/api/auth/setup', '/api/auth/login'}, allowed_api_paths
|
|
||||||
|
|
||||||
must(maintenance,
|
|
||||||
'permits_http_invalidation(client_ip)',
|
|
||||||
'snapshot.forwarding.trusted(peer_ip)',
|
|
||||||
'return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity',
|
|
||||||
'cache_http_invalidation_request(request.method()',
|
|
||||||
'duplicate BAN/PURGE control header',
|
|
||||||
'request.body().is_end_stream()',
|
|
||||||
'get_all(http::header::CONTENT_LENGTH)',
|
|
||||||
'http::header::TRANSFER_ENCODING',
|
|
||||||
'X-RustProxy-Confirm-Wide: 1',
|
|
||||||
'"{scheme}://{authority}{target}"',
|
|
||||||
'cache_invalidation_slots.clone().try_acquire_owned()',
|
|
||||||
'state.store.audit(',
|
|
||||||
'tokio::task::spawn_blocking',
|
|
||||||
'let _permit = permit;',
|
|
||||||
'StatusCode::UNPROCESSABLE_ENTITY',
|
|
||||||
'StatusCode::TOO_MANY_REQUESTS')
|
|
||||||
# Axum 0.8 requires the `forward` fallback future to be Send. Its Body is
|
|
||||||
# not Sync, so an async helper must own Request<Body> rather than retain
|
|
||||||
# &Request<Body> across the audit/cache await points.
|
|
||||||
async_maintenance = maintenance.split('async fn cache_http_invalidation(', 1)[1].split(') -> Response {', 1)[0]
|
|
||||||
assert 'request: Request,' in async_maintenance, 'cache maintenance must own Request<Body> in its async future'
|
|
||||||
assert 'request: &Request' not in async_maintenance, 'borrowed Request<Body> makes fallback handler non-Send'
|
|
||||||
must(maintenance, 'let method = request.method().clone();', 'drop(request);')
|
|
||||||
assert maintenance.index('drop(request);') < maintenance.index('state.store.audit('), 'drop body before asynchronous audit'
|
|
||||||
assert 'frontend, &host, request, context.client_ip' in proxy, 'forward must transfer request ownership'
|
|
||||||
|
|
||||||
must(proxy, 'snapshot.host_allowed(&host, context.client_ip)',
|
|
||||||
'crate::acl::allows_with_country(',
|
|
||||||
'context.client_ip, peer.0.ip(), context.scheme',
|
|
||||||
'return cache_http_invalidation(')
|
|
||||||
assert proxy.find('snapshot.host_allowed(&host, context.client_ip)') < proxy.find('return cache_http_invalidation(')
|
|
||||||
assert proxy.find('if cache_http_invalidation_method(request.method())') < proxy.find('return cache_http_invalidation(')
|
|
||||||
must(cache_policy, 'http_invalidation_allowed_cidrs.len() <= 32',
|
|
||||||
'net.prefix_len() > 0', 'pub fn permits_http_invalidation')
|
|
||||||
must(app, 'cache_invalidation_slots: Arc<Semaphore>',
|
|
||||||
'cache_invalidation_slots: Arc::new(Semaphore::new(2))')
|
|
||||||
must(operations, 'cache_invalidation_slots.clone().try_acquire_owned()',
|
|
||||||
'tokio::task::spawn_blocking(move ||',
|
|
||||||
'"cache.invalidate"', '"cache.purge"',
|
|
||||||
'"cache.ban.remove"')
|
|
||||||
start = operations.index('async fn cache_ban_delete(')
|
|
||||||
end = operations.index('async fn active_tunnels(', start)
|
|
||||||
assert operations[start:end].index('.audit(') < operations[start:end].index('remove_ban(id)'), 'BAN deletion must audit before mutation'
|
|
||||||
|
|
||||||
# Frontend: no HTML-string DOM injection or dynamic code execution in first-party scripts.
|
|
||||||
js_files = list((root/'src/admin/web').rglob('*.js'))
|
|
||||||
for path in js_files:
|
|
||||||
text = path.read_text(encoding='utf-8')
|
|
||||||
dangerous = (r'\.innerHTML\s*=', r'\.outerHTML\s*=', r'\.insertAdjacentHTML\s*\(',
|
|
||||||
r'\beval\s*\(', r'\bnew\s+Function\s*\(')
|
|
||||||
for pattern in dangerous:
|
|
||||||
if re.search(pattern, text):
|
|
||||||
raise AssertionError(f'unsafe DOM/code sink: {path.relative_to(root)} matches {pattern}')
|
|
||||||
|
|
||||||
html = read('src/admin/web/index.html')
|
|
||||||
for fragment in ['class="metric-info cache-maintenance-info"',
|
|
||||||
'cacheHttpInvalidationCurlLabel',
|
|
||||||
'curl -i -X PURGE', 'curl -i -X BAN']:
|
|
||||||
must(html, fragment)
|
|
||||||
print(f'PASS: admin/HTTP maintenance security source contracts; scanned {len(js_files)} first-party JS files. Actual Rust/wire behavior not tested.')
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// Offline UI/theme contract: selectors, cached appearance, and CSS readability.
|
|
||||||
// Does not substitute for a browser session or Rust API integration test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const base = 'src/admin/web/';
|
|
||||||
const css = fs.readFileSync(`${base}style.css`, 'utf8');
|
|
||||||
const html = fs.readFileSync(`${base}index.html`, 'utf8');
|
|
||||||
const login = fs.readFileSync(`${base}login.js`, 'utf8');
|
|
||||||
const app = fs.readFileSync(`${base}app/preferences.js`, 'utf8');
|
|
||||||
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
|
|
||||||
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
|
|
||||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
|
||||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
|
||||||
const themes = ['dark', 'ultra_dark', 'light', 'cream'];
|
|
||||||
const declaration = app.match(/^const ADMIN_THEMES = .*;$/m)?.[0];
|
|
||||||
const normalize = app.match(/^function normalizeAdminTheme\(value\) \{[^\n]*\}$/m)?.[0];
|
|
||||||
const apply = app.split('function applyAdminTheme(value, cache = true) {')[1]
|
|
||||||
?.split('function applyAdminLanguage(')[0];
|
|
||||||
assert(declaration && normalize && apply);
|
|
||||||
const stored = new Map();
|
|
||||||
const select = { value: '' };
|
|
||||||
const root = { dataset: {} };
|
|
||||||
const ctx = {
|
|
||||||
document: { documentElement: root },
|
|
||||||
$: id => id === 'ui-theme' ? select : null,
|
|
||||||
localStorage: { setItem: (k, v) => stored.set(k, v), getItem: k => stored.get(k) },
|
|
||||||
};
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(`${declaration}\n${normalize}\nfunction applyAdminTheme(value, cache = true) {${apply}`, ctx);
|
|
||||||
for (const theme of themes) {
|
|
||||||
assert(html.includes(`value="${theme}"`), `option ${theme}`);
|
|
||||||
assert(login.includes('"ultra_dark", "light", "cream"'), 'cached theme also applies at login');
|
|
||||||
assert(rust.includes('"dark" | "ultra_dark" | "light" | "cream"'));
|
|
||||||
assert.equal(vm.runInContext(`normalizeAdminTheme("${theme}")`, ctx), theme);
|
|
||||||
assert.equal(vm.runInContext(`applyAdminTheme("${theme}")`, ctx), theme);
|
|
||||||
assert.equal(root.dataset.theme, theme);
|
|
||||||
assert.equal(select.value, theme);
|
|
||||||
assert.equal(stored.get('rp-theme'), theme);
|
|
||||||
}
|
|
||||||
assert.equal(vm.runInContext('normalizeAdminTheme("unknown")', ctx), 'dark');
|
|
||||||
vm.runInContext('applyAdminTheme("ultra_dark", false)', ctx);
|
|
||||||
assert.equal(stored.get('rp-theme'), 'cream', 'preview without caching preserves selected preference');
|
|
||||||
assert(en.themeUltraDark && en.themeCream && pl.themeUltraDark && pl.themeCream);
|
|
||||||
assert.equal(schema.components.schemas.AdminPreferences.properties.theme.enum.length, 4);
|
|
||||||
|
|
||||||
function block(selector) {
|
|
||||||
const start = css.indexOf(`${selector} {`);
|
|
||||||
assert(start >= 0, selector);
|
|
||||||
return css.slice(start, css.indexOf('\n}', start) + 2);
|
|
||||||
}
|
|
||||||
function token(section, key) {
|
|
||||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const match = section.match(new RegExp(`--${escaped}:\\s*(#[0-9a-fA-F]{6});`));
|
|
||||||
assert(match, `missing ${key}`);
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
function luminance(hex) {
|
|
||||||
const rgb = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
||||||
const [r, g, b] = rgb.map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
}
|
|
||||||
function contrast(a, b) {
|
|
||||||
const x = luminance(a), y = luminance(b);
|
|
||||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|
||||||
}
|
|
||||||
for (const theme of ['ultra_dark', 'cream']) {
|
|
||||||
const section = block(`html[data-theme="${theme}"]`);
|
|
||||||
const bg = token(section, 'bg'), panel = token(section, 'panel'), panel2 = token(section, 'panel2');
|
|
||||||
const text = token(section, 'text'), muted = token(section, 'muted'), accent = token(section, 'accent');
|
|
||||||
for (const [name, foreground, surface] of [
|
|
||||||
['text on background', text, bg], ['text on panel', text, panel],
|
|
||||||
['muted on card', muted, panel2], ['accent on background', accent, bg],
|
|
||||||
]) assert(contrast(foreground, surface) >= 4.5, `${theme}: ${name}`);
|
|
||||||
}
|
|
||||||
console.log('PASS: four account themes, persisted and cached selection, server API contract, and accessible black/cream palette contrast');
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Static regression for 0.32.7 follow-up; not a browser or Rust runtime test."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
css = (ROOT / 'src/admin/web/style.css').read_text()
|
|
||||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
|
||||||
response = (ROOT / 'src/proxy/forward/response.rs').read_text()
|
|
||||||
helper = (ROOT / 'src/proxy/h2_graceful.rs').read_text()
|
|
||||||
for button_id in ['overview-refresh', 'overview-layout-open', 'overview-layout-unlock',
|
|
||||||
'overview-layout-open-settings', 'dashboard-widget-expand']:
|
|
||||||
assert f'id="{button_id}"' in html
|
|
||||||
assert '.page-intro-title-row>:is(.quiet, .outline) {\n min-height:' not in css
|
|
||||||
assert 'dashboard-widget-settings-controls>button {\n min-height:' not in css
|
|
||||||
assert 'html[data-button-style=compact] body' in css
|
|
||||||
for mode in ['normal', 'compact', 'ultra']:
|
|
||||||
assert f'html[data-dashboard-density={mode}] #panel-overview .dashboard-overview-grid>.card:not(.pool-card)' in css
|
|
||||||
assert 'upstream_health_error_class(&cause, connect_failure, &active_backend)' in response
|
|
||||||
assert 'let graceful_h2_close = matches!(' in response
|
|
||||||
assert 'tracing::trace!(' in response
|
|
||||||
assert 'StatusCode::BAD_GATEWAY' in response
|
|
||||||
assert 'Some(detail.clone())' in response
|
|
||||||
assert 'recently_saw_graceful_goaway()' in helper
|
|
||||||
print('PASS: shared button CSS, density variants, guarded graceful-H2 logging (source only)')
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Optional Chromium regression: shared button sizing and Dashboard density.
|
|
||||||
|
|
||||||
Uses the production HTML button markup and stylesheet without a running API.
|
|
||||||
Run: python3 tools/checks/appearance_density_buttons_0327_browser.py
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
index = (ROOT / 'src/admin/web/index.html').read_text()
|
|
||||||
css = (ROOT / 'src/admin/web/style.css').read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def button(button_id):
|
|
||||||
match = re.search(r'<button\b(?=[^>]*\bid="' + re.escape(button_id) + r'")[\s\S]*?</button>', index)
|
|
||||||
assert match, button_id
|
|
||||||
return match.group(0).replace(' hidden', '')
|
|
||||||
|
|
||||||
|
|
||||||
html = f'''<!doctype html><html data-theme="dark"><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>{css}</style></head>
|
|
||||||
<body><main style="max-width:100%;padding:10px;box-sizing:border-box">
|
|
||||||
<div class="page-intro-title-row"><h1>Overview</h1>
|
|
||||||
{button('overview-refresh')}{button('overview-layout-open')}{button('overview-layout-unlock')}</div>
|
|
||||||
<div class="appearance-settings-grid dashboard-appearance-grid"><div class="dashboard-widget-settings"><div class="dashboard-widget-settings-controls">
|
|
||||||
{button('overview-layout-open-settings')}{button('dashboard-widget-expand')}
|
|
||||||
</div></div></div>
|
|
||||||
<div>{'<button class="quiet" id="reference-quiet">Reference</button><button class="outline" id="reference-outline">Reference</button>'}</div>
|
|
||||||
<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned">
|
|
||||||
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span>Requests</span><strong>100</strong><small>Rate</small><svg viewBox="0 0 100 25"></svg></article>
|
|
||||||
<article class="card" data-dashboard-widget="diagnostics"><div class="card-heading"><h2>Diagnostics</h2></div><p>Metrics</p></article>
|
|
||||||
</div></section></main></body></html>'''
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(executable_path=shutil.which('chromium') or shutil.which('chromium-browser'), args=['--no-sandbox'])
|
|
||||||
for width in [320, 393, 1280]:
|
|
||||||
page = browser.new_page(viewport={'width': width, 'height': 900})
|
|
||||||
page.set_content(html)
|
|
||||||
for style in ['normal', 'compact']:
|
|
||||||
page.evaluate('(value) => document.documentElement.dataset.buttonStyle = value', style)
|
|
||||||
computed = page.evaluate('''() => {
|
|
||||||
const values = id => {
|
|
||||||
const s = getComputedStyle(document.getElementById(id));
|
|
||||||
return [s.fontSize, s.minHeight, s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft];
|
|
||||||
};
|
|
||||||
return Object.fromEntries(['overview-refresh', 'overview-layout-open', 'overview-layout-unlock',
|
|
||||||
'overview-layout-open-settings', 'dashboard-widget-expand', 'reference-quiet', 'reference-outline']
|
|
||||||
.map(id => [id, values(id)]));
|
|
||||||
}''')
|
|
||||||
for target, reference in [
|
|
||||||
('overview-refresh', 'reference-quiet'),
|
|
||||||
('overview-layout-open', 'reference-quiet'),
|
|
||||||
('overview-layout-unlock', 'reference-outline'),
|
|
||||||
('overview-layout-open-settings', 'reference-outline'),
|
|
||||||
('dashboard-widget-expand', 'reference-quiet'),
|
|
||||||
]:
|
|
||||||
assert computed[target] == computed[reference], (width, style, target, computed[target], computed[reference])
|
|
||||||
print('PASS buttons', width, style, computed['overview-refresh'])
|
|
||||||
measures = {}
|
|
||||||
for density in ['normal', 'compact', 'ultra']:
|
|
||||||
page.evaluate('(value) => document.documentElement.dataset.dashboardDensity = value', density)
|
|
||||||
measures[density] = page.evaluate('''() => {
|
|
||||||
const stat = document.querySelector('#panel-overview .stat');
|
|
||||||
const value = stat.querySelector('strong');
|
|
||||||
const svg = stat.querySelector('svg');
|
|
||||||
const card = document.querySelector('#panel-overview .card');
|
|
||||||
const title = card.querySelector('h2');
|
|
||||||
return {statPadding: parseFloat(getComputedStyle(stat).paddingTop),
|
|
||||||
valueSize: parseFloat(getComputedStyle(value).fontSize),
|
|
||||||
chartHeight: parseFloat(getComputedStyle(svg).height),
|
|
||||||
cardPadding: parseFloat(getComputedStyle(card).paddingTop),
|
|
||||||
titleSize: parseFloat(getComputedStyle(title).fontSize),
|
|
||||||
overflow: document.documentElement.scrollWidth > window.innerWidth};
|
|
||||||
}''')
|
|
||||||
assert not measures[density]['overflow'], (width, density)
|
|
||||||
for key in ['statPadding', 'valueSize', 'chartHeight', 'cardPadding', 'titleSize']:
|
|
||||||
values = [measures[d][key] for d in ['normal', 'compact', 'ultra']]
|
|
||||||
assert values[0] > values[1] > values[2], (width, key, values)
|
|
||||||
print('PASS densities', width, measures)
|
|
||||||
page.close()
|
|
||||||
browser.close()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Optional Chromium checks for Appearance controls and global corner geometry.
|
|
||||||
|
|
||||||
Uses shipped CSS and the real Appearance list-rendering function with mocked
|
|
||||||
preferences/translation state. Not a test of the authenticated Rust service.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
WEB = ROOT / 'src/admin/web'
|
|
||||||
css = (WEB / 'style.css').read_text()
|
|
||||||
prefs = (WEB / 'app/preferences.js').read_text()
|
|
||||||
widgets = json.loads((WEB / 'dashboard-widgets.json').read_text())
|
|
||||||
translations = json.loads((ROOT / 'langs/en_US.json').read_text())['translations']
|
|
||||||
functions = prefs[:prefs.index('function browserLanguage()')] + '\n' + prefs[prefs.index('function dashboardWidgetLabel'):prefs.index('function refreshChartStylePalette')]
|
|
||||||
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="default"><head><style>{css}</style></head><body>'
|
|
||||||
'<div id="settings-page-appearance" class="settings-page" style="max-width:100%;padding:12px">'
|
|
||||||
'<div class="appearance-section"><div class="dashboard-widget-list" id="dashboard-widget-list"></div></div>'
|
|
||||||
'<div style="display:flex;gap:10px;margin-top:12px">'
|
|
||||||
'<div class="card" id="test-card">Card</div><div class="stat" id="test-stat">Stat</div>'
|
|
||||||
'<button class="outline" id="test-action">Action</button><input id="test-input" value="value"></div>'
|
|
||||||
'<a class="brand" href="#"><span class="brand-mark" id="test-logo">r<span>/</span>p</span>RustProxy</a></div>'
|
|
||||||
'<div class="overview-widget-type" id="editor-type-badge">Chart</div></body></html>')
|
|
||||||
js = f'''const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({json.dumps(widgets)});
|
|
||||||
const translations = {json.dumps(translations)};
|
|
||||||
function t(key) {{ return translations[key] || key; }}
|
|
||||||
function $(id) {{ return document.getElementById(id); }}
|
|
||||||
function el(tag, text, klass) {{ const node = document.createElement(tag); if (text != null) node.textContent = text; if (klass) node.className = klass; return node; }}
|
|
||||||
{functions}
|
|
||||||
renderDashboardWidgetSettings();'''
|
|
||||||
|
|
||||||
with sync_playwright() as playwright:
|
|
||||||
binary = shutil.which('chromium') or shutil.which('chromium-browser')
|
|
||||||
browser = playwright.chromium.launch(headless=True, executable_path=binary, args=['--no-sandbox'])
|
|
||||||
page = browser.new_page(viewport={'width': 1320, 'height': 950})
|
|
||||||
page.set_content(html, wait_until='domcontentloaded')
|
|
||||||
page.add_script_tag(content=js)
|
|
||||||
assert page.locator('#dashboard-widget-list .dashboard-widget-setting').count() == 62
|
|
||||||
assert page.locator('#settings-page-appearance .overview-widget-type').count() == 0
|
|
||||||
assert page.locator('#editor-type-badge').is_visible()
|
|
||||||
for width in (1320, 790, 450, 345):
|
|
||||||
page.set_viewport_size({'width': width, 'height': 950})
|
|
||||||
results = page.locator('.dashboard-widget-setting').evaluate_all('''rows => rows.map(row => {
|
|
||||||
const label = row.querySelector('.dashboard-widget-toggle').getBoundingClientRect();
|
|
||||||
const actions = row.querySelector('.dashboard-widget-setting-actions').getBoundingClientRect();
|
|
||||||
const box = row.getBoundingClientRect();
|
|
||||||
return { labelRight: label.right, actionsLeft: actions.left, actionsRight: actions.right, right: box.right };
|
|
||||||
})''')
|
|
||||||
for i, result in enumerate(results):
|
|
||||||
assert result['actionsLeft'] >= result['labelRight'] - 1, (width, i, result)
|
|
||||||
assert result['actionsRight'] <= result['right'] + 1, (width, i, result)
|
|
||||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 2'), width
|
|
||||||
for theme in ('dark', 'light', 'ultra_dark', 'cream'):
|
|
||||||
page.evaluate('theme => { document.documentElement.dataset.theme = theme; document.documentElement.dataset.cornerStyle = "default"; }', theme)
|
|
||||||
stat = page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
|
|
||||||
card = page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
|
|
||||||
assert stat == card == '0px', (theme, stat, card)
|
|
||||||
for style, expected_surface, expected_control in (('square', '0px', '0px'), ('subtle','5px','4px'), ('rounded','12px','8px'),('extra','18px','12px')):
|
|
||||||
page.evaluate('style => document.documentElement.dataset.cornerStyle = style', style)
|
|
||||||
assert page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'stat')
|
|
||||||
assert page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'card')
|
|
||||||
assert page.locator('#test-action').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'button')
|
|
||||||
assert page.locator('#test-input').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'input')
|
|
||||||
page.evaluate('document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.cornerStyle = "default"')
|
|
||||||
# The last glyph must have room before the outlined logo's right edge,
|
|
||||||
# including the 34px mobile variant; the slash-to-p spacing is unchanged.
|
|
||||||
for width in (1320, 790, 450, 345):
|
|
||||||
page.set_viewport_size({'width': width, 'height': 950})
|
|
||||||
inset = page.locator('#test-logo').evaluate('''mark => {
|
|
||||||
const p = [...mark.childNodes].find(node => node.nodeType === 3 && node.textContent.trim() === 'p');
|
|
||||||
const range = document.createRange();
|
|
||||||
range.selectNodeContents(p);
|
|
||||||
return mark.getBoundingClientRect().right - range.getBoundingClientRect().right;
|
|
||||||
}''')
|
|
||||||
assert inset >= 4, (width, inset)
|
|
||||||
page.set_viewport_size({'width': 1320, 'height': 950})
|
|
||||||
original_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
|
|
||||||
page.evaluate('document.documentElement.dataset.theme = "ultra_dark"')
|
|
||||||
black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
|
|
||||||
assert black_logo == original_logo == 'rgb(246, 155, 116)', (black_logo, original_logo)
|
|
||||||
out = ROOT.parent / 'appearance_ux_0323_preview.png'
|
|
||||||
page.screenshot(path=str(out))
|
|
||||||
browser.close()
|
|
||||||
print('PASS: 62 badge-free Appearance rows, right-pinned buttons at 4 widths, theme-independent corner geometry, original black-theme logo accent')
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// Regression contract: Appearance list is badge-free, corners are account-scoped,
|
|
||||||
// and Settings opens Appearance/last active tab unless a valid URL tab wins.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const prefs = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
|
||||||
const settings = fs.readFileSync('src/admin/web/settings.js', 'utf8');
|
|
||||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
const login = fs.readFileSync('src/admin/web/login.js', 'utf8');
|
|
||||||
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
|
|
||||||
const tests = fs.readFileSync('src/admin/preferences_tests.rs', 'utf8');
|
|
||||||
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
|
|
||||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
|
||||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
|
||||||
const styles = ['default', 'square', 'subtle', 'rounded', 'extra'];
|
|
||||||
|
|
||||||
const appearance = prefs.slice(prefs.indexOf('function renderDashboardWidgetSettings()'), prefs.indexOf('function refreshChartStylePalette'));
|
|
||||||
assert(appearance.includes('dashboard-widget-setting-actions'));
|
|
||||||
assert(!appearance.includes('dashboardWidgetTypeBadge('), 'Appearance rows must not render type badges');
|
|
||||||
assert(prefs.includes('function dashboardWidgetTypeBadge('), 'visual editors retain type labels');
|
|
||||||
assert(html.includes('id="ui-corner-style"'));
|
|
||||||
assert(html.includes('id="dashboard-widget-list"'));
|
|
||||||
const contract = schema.components.schemas.AdminPreferences.properties.corner_style;
|
|
||||||
assert.deepEqual(contract.enum, styles);
|
|
||||||
assert.equal(contract.default, 'default');
|
|
||||||
assert(rust.includes('default_admin_corner_style'));
|
|
||||||
assert(rust.includes('"default" | "square" | "subtle" | "rounded" | "extra"'));
|
|
||||||
assert(tests.includes('fn corner_style_defaults_and_rejects_unknown_presets()'));
|
|
||||||
for (const key of ['cornerStyle', 'cornerStyleHelp', 'cornerStyleDefault', 'cornerStyleSquare', 'cornerStyleSubtle', 'cornerStyleRounded', 'cornerStyleExtra']) {
|
|
||||||
assert(en[key] && pl[key], `missing translation ${key}`);
|
|
||||||
}
|
|
||||||
assert(!/html:is\(\[data-theme="ultra_dark"\], \[data-theme="cream"\]\) :is\(\.card, \.stat, dialog\) \{/.test(css), 'theme may not override corner geometry');
|
|
||||||
assert(css.includes('--logo-accent: #f69b74;'));
|
|
||||||
assert(css.includes('--logo-secondary: #cfe0ac;'));
|
|
||||||
assert(css.includes('var(--logo-accent, var(--accent))'));
|
|
||||||
assert(login.includes('rp-corner-style'), 'cached corners must work on login too');
|
|
||||||
|
|
||||||
const decl = prefs.match(/^const ADMIN_CORNER_STYLES = .*;$/m)?.[0];
|
|
||||||
const normalize = prefs.match(/^function normalizeAdminCornerStyle\(value\) \{[^\n]*\}$/m)?.[0];
|
|
||||||
const body = prefs.split('function applyAdminCornerStyle(value, cache = true) {')[1]?.split('function applyAdminTableDensity(')[0];
|
|
||||||
assert(decl && normalize && body);
|
|
||||||
const storage = new Map();
|
|
||||||
const root = { dataset: {} }, input = { value: '' };
|
|
||||||
const ctx = { document: { documentElement: root }, $: id => id === 'ui-corner-style' ? input : null,
|
|
||||||
localStorage: { setItem: (key, value) => storage.set(key, value) } };
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(`${decl}\n${normalize}\nfunction applyAdminCornerStyle(value, cache = true) {${body}`, ctx);
|
|
||||||
for (const style of styles) {
|
|
||||||
assert.equal(vm.runInContext(`applyAdminCornerStyle('${style}')`, ctx), style);
|
|
||||||
assert.equal(root.dataset.cornerStyle, style);
|
|
||||||
assert.equal(storage.get('rp-corner-style'), style);
|
|
||||||
assert.equal(input.value, style);
|
|
||||||
}
|
|
||||||
vm.runInContext("applyAdminCornerStyle('invalid')", ctx);
|
|
||||||
assert.equal(root.dataset.cornerStyle, 'default');
|
|
||||||
assert(prefs.includes('corner_style: "default"'), 'old account defaults preserve original geometry');
|
|
||||||
assert(prefs.includes('corner_style: next'), 'choice persisted via API');
|
|
||||||
assert(prefs.includes('applyAdminCornerStyle(previous)'), 'failed update rolls back');
|
|
||||||
|
|
||||||
const setup = settings.slice(settings.indexOf(' const buttons ='), settings.indexOf(' const policyTabs ='));
|
|
||||||
assert(setup.length > 1000);
|
|
||||||
function boot(path, cached, query = '') {
|
|
||||||
const cache = new Map(cached ? [['rp-settings-last-tab', cached]] : []);
|
|
||||||
const names = ['appearance', 'listeners', 'cache', 'security', 'manual-config', 'logging'];
|
|
||||||
const buttons = names.map(name => ({ dataset: { settingsTab: name }, hidden: false,
|
|
||||||
textContent: name, addEventListener() {}, setAttribute() {}, focus() {} }));
|
|
||||||
const pages = names.map(name => ({ dataset: { settingsPage: name }, hidden: true }));
|
|
||||||
const url = new URL(`https://dashboard.example${path}${query}`);
|
|
||||||
const location = { href: url.href, pathname: url.pathname, search: url.search, hash: '', protocol: url.protocol };
|
|
||||||
const context = { location, URL, URLSearchParams, CSS: { escape: value => value },
|
|
||||||
localStorage: { getItem: key => cache.get(key) || null, setItem: (key, value) => cache.set(key, value) },
|
|
||||||
document: { querySelectorAll: selector => selector === '[data-settings-tab]' ? buttons : pages,
|
|
||||||
querySelector: () => null }, $: () => null,
|
|
||||||
history: { state: {}, replaceState(_state, _title, href) { location.href = 'https://dashboard.example' + href; } },
|
|
||||||
window: { rustProxyRefresh: { setSettingsTab() {} } }, t: key => key,
|
|
||||||
};
|
|
||||||
const vmctx = vm.createContext(context);
|
|
||||||
const result = vm.runInContext(`(() => { ${setup}\nreturn { current: () => active, select }; })()`, vmctx);
|
|
||||||
return { ...result, cache };
|
|
||||||
}
|
|
||||||
assert.equal(boot('/settings').current(), 'appearance', 'first Settings visit opens Appearance');
|
|
||||||
assert.equal(boot('/settings', 'cache').current(), 'cache', 'last Settings tab is restored');
|
|
||||||
assert.equal(boot('/settings', 'unknown').current(), 'appearance', 'invalid cached tab is ignored');
|
|
||||||
assert.equal(boot('/settings', 'cache', '?tab=security').current(), 'security', 'URL tab takes precedence');
|
|
||||||
assert.equal(boot('/configuration', 'cache').current(), 'manual-config', 'legacy /configuration keeps intent');
|
|
||||||
assert.equal(boot('/logging', 'cache').current(), 'logging', 'legacy /logging keeps intent');
|
|
||||||
const active = boot('/settings');
|
|
||||||
active.select('cache');
|
|
||||||
assert.equal(active.cache.get('rp-settings-last-tab'), 'cache');
|
|
||||||
active.select('security');
|
|
||||||
assert.equal(active.cache.get('rp-settings-last-tab'), 'security');
|
|
||||||
active.select('appearance', false, false);
|
|
||||||
assert.equal(active.cache.get('rp-settings-last-tab'), 'security', 'temporary selection does not override remembered tab');
|
|
||||||
console.log('PASS: badge-free Appearance, 5 global corner presets, logo colors, account/API defaults, and last Settings tab navigation');
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Exercise the shipped 0.29.7 backend monitor editor with a minimal DOM double.
|
|
||||||
// This checks browser form behavior, not health probing or compiled Rust code.
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const core = fs.readFileSync(`${root}/src/admin/web/vhosts/core.js`, 'utf8');
|
|
||||||
const protocol = fs.readFileSync(`${root}/src/admin/web/vhosts/protocol.js`, 'utf8');
|
|
||||||
const editor = fs.readFileSync(`${root}/src/admin/web/vhosts/editor.js`, 'utf8');
|
|
||||||
const settings = fs.readFileSync(`${root}/src/admin/web/app/settings.js`, 'utf8');
|
|
||||||
const html = fs.readFileSync(`${root}/src/admin/web/index.html`, 'utf8');
|
|
||||||
const start = core.indexOf('function memberRow(');
|
|
||||||
const end = core.indexOf('function countryRateEditor(', start);
|
|
||||||
if (start < 0 || end <= start) throw new Error('Backend editor code not found');
|
|
||||||
const check = (condition, reason) => { if (!condition) throw new Error(reason); };
|
|
||||||
|
|
||||||
class Element {
|
|
||||||
constructor(tag = 'div', value = '', css = '') {
|
|
||||||
this.tag = tag;
|
|
||||||
this.value = value == null ? '' : String(value);
|
|
||||||
this.checked = false;
|
|
||||||
this.dataset = {};
|
|
||||||
this.hidden = false;
|
|
||||||
this.disabled = false;
|
|
||||||
this.name = '';
|
|
||||||
this.children = [];
|
|
||||||
this.listeners = new Map();
|
|
||||||
this.classes = new Set(css.split(/\s+/).filter(Boolean));
|
|
||||||
this.textContent = this.value;
|
|
||||||
}
|
|
||||||
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
|
|
||||||
addEventListener(type, handler) { this.listeners.set(type, handler); }
|
|
||||||
dispatch(type) { this.listeners.get(type)?.(); }
|
|
||||||
querySelectorAll() {
|
|
||||||
const found = [];
|
|
||||||
const walk = node => { for (const child of node.children) {
|
|
||||||
if (child.tag === 'input' || child.tag === 'select') found.push(child);
|
|
||||||
walk(child);
|
|
||||||
}};
|
|
||||||
walk(this);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const el = (tag, value, css) => new Element(tag, value, css);
|
|
||||||
const text = (tag, label, css) => el(tag, label, css);
|
|
||||||
const field = (parent, name, label, value, type = 'text') => {
|
|
||||||
const wrapper = el('label');
|
|
||||||
const input = el(type === 'select' ? 'select' : 'input', value);
|
|
||||||
input.name = name;
|
|
||||||
input.checked = !!value;
|
|
||||||
wrapper.append(input);
|
|
||||||
parent.append(wrapper);
|
|
||||||
return input;
|
|
||||||
};
|
|
||||||
const button = (label, handler) => { const node = el('button', label); node.addEventListener('click', handler); return node; };
|
|
||||||
const note = (parent, label) => { const node = text('span', label); parent.append(node); return node; };
|
|
||||||
const parent = el('div');
|
|
||||||
const value = {
|
|
||||||
id: 'error_404', url: 'http://127.0.0.1:8282', weight: 1, enabled: true,
|
|
||||||
pool_eligible: false, health_check_enabled: false, preserve_host: true,
|
|
||||||
health_check: { mode: 'http', path: '/ready', host: 'errors.example.test',
|
|
||||||
interval_ms: 10000, timeout_ms: 1000, slow_response_ms: 500, fall: 2,
|
|
||||||
rise: 1, expected_status_min: 200, expected_status_max: 399 }
|
|
||||||
};
|
|
||||||
vm.runInNewContext(`${protocol}\n${core.slice(start, end)}\nmemberRow(parent, value);`, {
|
|
||||||
parent, value, el, text, field, button, note,
|
|
||||||
t: key => key, toast: () => {}, changed: () => {},
|
|
||||||
appConfirm: async () => true, adminListenerBackendCandidate: () => false, backendHealthEditorControl: null
|
|
||||||
});
|
|
||||||
const row = parent.children[0];
|
|
||||||
const input = name => (name.startsWith('member-health-') && name !== 'member-health-enabled' ? row.memberHealthPanel : row).querySelectorAll().find(item => item.name === name);
|
|
||||||
const fields = [];
|
|
||||||
const visit = node => { if (node.classes.has('vh-backend-health-fields')) fields.push(node); for (const child of node.children) visit(child); };
|
|
||||||
visit(row.memberHealthPanel);
|
|
||||||
check(fields.length === 1, 'Custom backend health panel not created');
|
|
||||||
check(input('member-pool').checked === false, 'Internal 404 backend accidentally joined balanced pool');
|
|
||||||
check(input('member-enabled').checked === true, 'Backend was disabled when monitoring was disabled');
|
|
||||||
check(input('member-health-enabled').checked === false, 'Explicit monitoring off not restored');
|
|
||||||
check(input('member-health-custom').checked === true, 'Custom health not restored');
|
|
||||||
check(input('member-health-mode').value === 'http', 'Member health mode lost');
|
|
||||||
check(input('member-health-path').value === '/ready', 'Member health path lost');
|
|
||||||
check(input('member-health-interval').value === '10000', 'Member check interval lost');
|
|
||||||
check(fields[0].hidden === false && input('member-health-path').disabled, 'Unmonitored backend settings should be retained but disabled');
|
|
||||||
input('member-health-custom').checked = false;
|
|
||||||
input('member-health-custom').dispatch('change');
|
|
||||||
check(fields[0].hidden && input('member-health-path').disabled, 'Inherit switch did not hide/disable overrides');
|
|
||||||
input('member-health-custom').checked = true;
|
|
||||||
input('member-health-custom').dispatch('change');
|
|
||||||
check(!fields[0].hidden && input('member-health-path').disabled, 'Unmonitored backend unexpectedly became editable');
|
|
||||||
input('member-health-enabled').checked = true;
|
|
||||||
input('member-health-enabled').dispatch('change');
|
|
||||||
check(!input('member-health-path').disabled, 'Monitored custom fields failed to re-enable');
|
|
||||||
check(input('member-pool').checked === false, 'Monitoring switch changed balanced-pool eligibility');
|
|
||||||
|
|
||||||
for (const part of [
|
|
||||||
'health_check_enabled: yes("member-health-enabled", row)',
|
|
||||||
'health_check: yes("member-health-custom", row.memberHealthPanel)',
|
|
||||||
'expose_only_hits: tri("vh-cache-only-hits")',
|
|
||||||
]) check((editor + core).includes(part), `Missing saved field: ${part}`);
|
|
||||||
check(editor.includes('tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits)'), 'Vhost cache override does not roundtrip');
|
|
||||||
check(html.includes('id="cache-only-hits"') && settings.includes('expose_only_hits: $("cache-only-hits").checked'), 'Global cache checkbox does not save');
|
|
||||||
check(settings.includes('$("cache-only-hits").checked = !!c.expose_only_hits'), 'Global cache checkbox does not load');
|
|
||||||
console.log('PASS: 0.29.7 backend monitor and custom policy form state, independent pool status, global/vhost cache diagnostics toggles.');
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
// HTTP backend Overview widgets: offline simulated status feed, no Rust execution.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const names = ['backend_health', 'backend_activity', 'backend_errors', 'backend_latency', 'backend_traffic', 'backend_protocols', 'backend_requests', 'backend_capacity', 'backend_http5xx', 'backend_timeouts', 'backend_network', 'backend_retries', 'backend_queue', 'backend_rotations'];
|
|
||||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
|
||||||
for (const name of names) assert(registry.some(widget => widget.id === name && widget.default_hidden && widget.data_source === 'backend_status'));
|
|
||||||
const source = fs.readFileSync('src/admin/web/app/backend-dashboard.js', 'utf8');
|
|
||||||
assert(!/\b(fetch|api)\s*\(/.test(source), 'Overview must not fetch per-widget metrics');
|
|
||||||
const cards = new Map(names.map(name => [name, { hidden: true }]));
|
|
||||||
const nodes = new Map();
|
|
||||||
const get = id => {
|
|
||||||
if (!nodes.has(id)) nodes.set(id, { textContent: '-', style: {}, attrs: {}, children: [], setAttribute(k,v) { this.attrs[k] = v; } });
|
|
||||||
return nodes.get(id);
|
|
||||||
};
|
|
||||||
for (const type of ['h1', 'auto', 'compat', 'h2']) {
|
|
||||||
const node = get('backend-mode-' + type);
|
|
||||||
node.parentElement = get('backend-mode-bar');
|
|
||||||
node.parentElement.children.push(node);
|
|
||||||
}
|
|
||||||
let now = 1000;
|
|
||||||
const window = { rustProxyPreferences: { current: () => null } };
|
|
||||||
const ctx = {
|
|
||||||
window,
|
|
||||||
ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: names },
|
|
||||||
document: { querySelector(selector) { return cards.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null; } },
|
|
||||||
$: get, t: key => key, fmt: (value, precision = 0) => Number(value).toFixed(precision),
|
|
||||||
bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now },
|
|
||||||
};
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx);
|
|
||||||
vm.runInContext(source, ctx);
|
|
||||||
const module = window.rustProxyBackendDashboard;
|
|
||||||
const data = {
|
|
||||||
revision: 1,
|
|
||||||
backend_pools: [{ id: 'pool1', members: [
|
|
||||||
{ id: 'alpha', url: 'https://example.invalid', protocol: 'auto', status: 'up', active: 2, max_concurrent_requests: 4, capacity_rejections_total: 1, requests: 10, errors: 1, bytes_in: 1000, bytes_out: 2000, health_mode: 'tcp', last_probe_ms: 1234, latency_ms: 10, http_5xx_total: 2, timeouts_total: 1, connect_errors_total: 1, dns_errors_total: 1, tls_errors_total: 1, io_errors_total: 1, retry_attempts_total: 2, retry_successes_total: 1, queue_timeouts_total: 1, upstream_client_rotations_total: 2, upstream_client_generation: 3, upstream_client_generation_age_seconds: 500 },
|
|
||||||
{ id: 'beta', url: 'http://example.invalid', protocol: 'http2', status: 'down', active: 0, requests: 5, errors: 2, bytes_in: 100, bytes_out: 400, health_mode: 'http', last_probe_ms: 0, latency_ms: 0 },
|
|
||||||
{ id: 'gamma', url: 'http://example.invalid', protocol: 'http1', status: 'checking', active: 1, max_concurrent_requests: 2, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'tcp', last_probe_ms: 0, latency_ms: 0 },
|
|
||||||
{ id: 'delta', url: 'http://example.invalid', status: 'draining', active: 0, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'off', last_probe_ms: 0, latency_ms: 0 },
|
|
||||||
] }],
|
|
||||||
};
|
|
||||||
const text = id => get(`backend-dashboard-${id}`).textContent;
|
|
||||||
|
|
||||||
module.render(data); // Hidden by default: should not start collecting samples.
|
|
||||||
assert(names.every(name => cards.get(name).hidden));
|
|
||||||
assert.equal(text('health'), '-');
|
|
||||||
module.render(data, []);
|
|
||||||
assert(names.every(name => !cards.get(name).hidden));
|
|
||||||
assert.equal(text('health'), '1 / 3');
|
|
||||||
assert.match(text('health-detail'), /backendWidgetDown: 1/);
|
|
||||||
assert.equal(text('activity'), '3');
|
|
||||||
assert.equal(text('errors'), '3');
|
|
||||||
assert.equal(text('latency'), '10.0 ms');
|
|
||||||
assert.equal(text('traffic'), '0 B/s');
|
|
||||||
assert.equal(text('protocols'), '4');
|
|
||||||
assert.match(text('protocols-detail'), /H1 1.*Auto 1.*backendWidgetModeCompat 1.*backendWidgetModeStrict 0.*backendWidgetH2c: 1.*backendWidgetModeUnknown: 1/);
|
|
||||||
assert.equal(get('backend-mode-compat').style.flexGrow, '1');
|
|
||||||
assert.equal(get('backend-mode-h2').style.flexGrow, '0');
|
|
||||||
assert.equal(text('requests'), '0.0/s');
|
|
||||||
assert.match(text('requests-detail'), /backendWidgetRequestsTotal: 15/);
|
|
||||||
assert.equal(text('capacity'), '50.0%');
|
|
||||||
assert.match(text('capacity-detail'), /3\/6.*backendWidgetCapacityRejects: 1/);
|
|
||||||
assert.equal(text('http5xx'), '2');
|
|
||||||
assert.equal(text('timeouts'), '1');
|
|
||||||
assert.equal(text('network'), '4');
|
|
||||||
assert.equal(text('retries'), '2');
|
|
||||||
assert.equal(text('queue'), '2');
|
|
||||||
assert.equal(text('rotations'), '2');
|
|
||||||
assert.match(text('rotations-detail'), /backendWidgetPoolGeneration: 3.*backendWidgetPoolAge: 500s/);
|
|
||||||
|
|
||||||
now = 3000;
|
|
||||||
const next = structuredClone(data);
|
|
||||||
next.backend_pools[0].members[0].requests = 30;
|
|
||||||
next.backend_pools[0].members[0].errors = 3;
|
|
||||||
next.backend_pools[0].members[0].bytes_in = 3000;
|
|
||||||
next.backend_pools[0].members[0].bytes_out = 3000;
|
|
||||||
next.backend_pools[0].members[0].active = 5;
|
|
||||||
next.backend_pools[0].members[0].latency_ms = 22;
|
|
||||||
next.backend_pools[0].members[0].http_5xx_total = 4;
|
|
||||||
next.backend_pools[0].members[0].timeouts_total = 5;
|
|
||||||
next.backend_pools[0].members[0].connect_errors_total = 3;
|
|
||||||
next.backend_pools[0].members[0].retry_attempts_total = 4;
|
|
||||||
next.backend_pools[0].members[0].retry_successes_total = 2;
|
|
||||||
next.backend_pools[0].members[0].queue_timeouts_total = 3;
|
|
||||||
// New member starts with huge absolute counters: must NOT appear as a spike.
|
|
||||||
next.backend_pools[0].members.push({ id: 'new', url:'https://new.invalid', protocol: 'http1', status:'up', requests: 100000, errors: 1000, bytes_in: 10000000, bytes_out: 99999999, active: 0, health_mode: 'off', last_probe_ms: 0 });
|
|
||||||
module.render(next, []);
|
|
||||||
assert.equal(text('errors'), '1005');
|
|
||||||
assert.match(text('errors-detail'), /1\.0\/s/);
|
|
||||||
assert.match(text('errors-detail'), /10\.0%/);
|
|
||||||
assert.equal(text('traffic'), '1500 B/s');
|
|
||||||
assert.equal(get('backend-dashboard-in-rate').textContent, '1000 B/s');
|
|
||||||
assert.equal(get('backend-dashboard-out-rate').textContent, '500 B/s');
|
|
||||||
assert.equal(text('activity'), '6');
|
|
||||||
assert.equal(text('requests'), '10.0/s');
|
|
||||||
assert.equal(text('capacity'), '100.0%');
|
|
||||||
assert.equal(text('latency'), '22.0 ms');
|
|
||||||
assert.match(text('http5xx-detail'), /1\.0\/s/);
|
|
||||||
assert.match(text('timeouts-detail'), /2\.0\/s/);
|
|
||||||
assert.match(text('network-detail'), /1\.0\/s/);
|
|
||||||
assert.match(text('retries-detail'), /1\.0\/s.*backendWidgetRetriesSuccess: 2/);
|
|
||||||
assert.match(text('queue-detail'), /1\.0\/s.*backendWidgetQueueRejected: 1/);
|
|
||||||
|
|
||||||
// Repaint on toggles/translation must not produce additional rate samples.
|
|
||||||
now = 3500;
|
|
||||||
module.render(next, [], false);
|
|
||||||
assert.match(text('errors-detail'), /1\.0\/s/);
|
|
||||||
// Reset of a member's counters should never be treated as an error burst.
|
|
||||||
now = 5000;
|
|
||||||
const restarted = structuredClone(next);
|
|
||||||
restarted.backend_pools[0].members[0].requests = 2;
|
|
||||||
restarted.backend_pools[0].members[0].errors = 0;
|
|
||||||
restarted.backend_pools[0].members[0].bytes_in = 999999;
|
|
||||||
module.render(restarted, []);
|
|
||||||
assert.match(text('errors-detail'), /0\.0\/s/);
|
|
||||||
assert.equal(text('traffic'), '0 B/s');
|
|
||||||
assert.equal(text('requests'), '0.0/s');
|
|
||||||
// Reconfiguration invalidates prior generations, first sample is a baseline.
|
|
||||||
now = 8000;
|
|
||||||
restarted.revision = 2;
|
|
||||||
module.render(restarted, []);
|
|
||||||
assert.match(text('errors-detail'), /0\.0\/s/);
|
|
||||||
// Missing origin data must show neutral state and not previously cached values.
|
|
||||||
module.render({ revision: 3, backend_pools: [] }, []);
|
|
||||||
assert.equal(text('health'), '-');
|
|
||||||
assert.equal(text('traffic'), '-');
|
|
||||||
assert.equal(text('protocols-detail'), 'backendWidgetUnavailable');
|
|
||||||
assert.equal(text('requests'), '-');
|
|
||||||
assert.equal(text('capacity'), '-');
|
|
||||||
for (const name of ['http5xx', 'timeouts', 'network', 'retries', 'queue', 'rotations'])
|
|
||||||
assert.equal(text(name), '-', `neutral empty state for ${name}`);
|
|
||||||
module.render(data, [...names]);
|
|
||||||
assert(names.every(name => cards.get(name).hidden));
|
|
||||||
|
|
||||||
// Origin protocol editor must preserve legacy HTTPS fallback, expose strict
|
|
||||||
// H2 as a separate new option, and distinguish cleartext h2c.
|
|
||||||
const protocolHelp = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
|
|
||||||
const element = () => ({ dataset: {}, textContent: '', hidden: false });
|
|
||||||
const originCtx = { el: element, t: key => key };
|
|
||||||
vm.createContext(originCtx);
|
|
||||||
vm.runInContext(protocolHelp, originCtx);
|
|
||||||
const mounted = [];
|
|
||||||
const panel = { append(...items) { mounted.push(...items); } };
|
|
||||||
const select = { value: 'http2', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
|
|
||||||
const url = { value: 'https://origin.example', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
|
|
||||||
originCtx.attachOriginProtocolDetails(panel, select, url);
|
|
||||||
assert.equal(mounted.length, 2);
|
|
||||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpsDetails');
|
|
||||||
assert.equal(mounted[1].hidden, false, 'Saved HTTPS H2 needs a compatibility warning');
|
|
||||||
select.value = 'auto'; select.events.change();
|
|
||||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpsDetails');
|
|
||||||
assert.equal(mounted[1].hidden, true);
|
|
||||||
url.value = 'http://origin.example'; url.events.input();
|
|
||||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpDetails');
|
|
||||||
select.value = 'http2'; select.events.change();
|
|
||||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpDetails');
|
|
||||||
assert.equal(mounted[1].hidden, true);
|
|
||||||
url.value = 'https://origin.example'; url.events.input();
|
|
||||||
assert.equal(mounted[1].hidden, false);
|
|
||||||
select.value = 'h2'; select.events.change();
|
|
||||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpsDetails');
|
|
||||||
assert.equal(mounted[1].hidden, true);
|
|
||||||
url.value = 'http://origin.example'; url.events.input();
|
|
||||||
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpDetails');
|
|
||||||
// Strict H2 is tracked independently from the backwards-compatible value.
|
|
||||||
const strict = structuredClone(data);
|
|
||||||
strict.backend_pools[0].members[2].protocol = 'h2';
|
|
||||||
module.render(strict, [], false);
|
|
||||||
assert.match(text('protocols-detail'), /backendWidgetModeCompat 1.*backendWidgetModeStrict 1.*backendWidgetH2c: 2/);
|
|
||||||
assert.equal(get('backend-mode-h2').style.flexGrow, '1');
|
|
||||||
console.log('PASS: 14 opt-in HTTP backend cards, ALPN mode distinction, capacity, request rates, fallback UI');
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Simulated vhost DOM: selector eligibility, independent edits and lifecycle.
|
|
||||||
// This is not a browser rendering test or a Rust health-probe test.
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
|
|
||||||
const protocol = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
|
|
||||||
const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8');
|
|
||||||
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
|
||||||
const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
|
|
||||||
const first = core.indexOf('function memberRow(');
|
|
||||||
const last = core.indexOf('function countryRateEditor(', first);
|
|
||||||
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
|
||||||
assert(first >= 0 && last > first, 'Vhost member editor missing');
|
|
||||||
class Element {
|
|
||||||
constructor(tag = 'div', value = '', css = '') {
|
|
||||||
this.tag = tag; this.value = String(value ?? ''); this.textContent = this.value;
|
|
||||||
this.children = []; this.parent = null; this.name = ''; this.checked = false; this.dataset = {};
|
|
||||||
this.disabled = false; this.hidden = false; this.listeners = new Map();
|
|
||||||
this.classes = new Set(css.split(/\s+/).filter(Boolean));
|
|
||||||
}
|
|
||||||
append(...children) {
|
|
||||||
for (const child of children) {
|
|
||||||
if (child.parent) child.remove();
|
|
||||||
this.children.push(child); child.parent = this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
replaceChildren(...children) { for (const child of [...this.children]) child.remove(); this.append(...children); }
|
|
||||||
remove() {
|
|
||||||
if (!this.parent) return;
|
|
||||||
this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null;
|
|
||||||
}
|
|
||||||
addEventListener(type, callback) { this.listeners.set(type, callback); }
|
|
||||||
dispatch(type) { this.listeners.get(type)?.(); }
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
const result = [];
|
|
||||||
const walk = parent => { for (const child of parent.children) {
|
|
||||||
if ((selector === 'input,select' && ['input', 'select'].includes(child.tag)) ||
|
|
||||||
(selector === '*' || selector === undefined)) result.push(child);
|
|
||||||
walk(child);
|
|
||||||
}};
|
|
||||||
walk(this); return result;
|
|
||||||
}
|
|
||||||
querySelector(selector) {
|
|
||||||
const match = /^\[name="([^"]+)"\]$/.exec(selector);
|
|
||||||
return match ? this.querySelectorAll('*').find(node => node.name === match[1]) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const el = (tag, value, css) => new Element(tag, value, css);
|
|
||||||
const text = (tag, key, css) => el(tag, key, css);
|
|
||||||
const field = (parent, name, key, value, type = 'text') => {
|
|
||||||
const wrapper = el('label');
|
|
||||||
const input = el(type === 'select' ? 'select' : 'input', value); input.name = name;
|
|
||||||
input.checked = type === 'checkbox' && !!value;
|
|
||||||
wrapper.append(input); parent.append(wrapper); return input;
|
|
||||||
};
|
|
||||||
const button = (key, callback) => { const node = el('button', key); node.addEventListener('click', callback); return node; };
|
|
||||||
const note = (parent, key) => { const node = text('p', key); parent.append(node); return node; };
|
|
||||||
const context = {
|
|
||||||
el, text, field, button, note, t: key => key,
|
|
||||||
str: (name, scope) => scope.querySelector(`[name="${name}"]`).value.trim(),
|
|
||||||
toast: () => { throw Error('Unexpected error toast'); }, changed: () => {},
|
|
||||||
appConfirm: async () => true, adminListenerBackendCandidate: () => false,
|
|
||||||
backendHealthEditorControl: null,
|
|
||||||
read: (name, scope) => scope ? scope.querySelector(`[name="${name}"]`) :
|
|
||||||
({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
|
|
||||||
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
|
|
||||||
"status-min": "201", "status-max": "398" }[name] != null ?
|
|
||||||
{ value: ({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
|
|
||||||
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
|
|
||||||
"status-min": "201", "status-max": "398" })[name] } : null),
|
|
||||||
};
|
|
||||||
const exports = vm.runInNewContext(`${protocol}\n${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
|
|
||||||
const members = el('div');
|
|
||||||
const probe = (mode, path) => ({ mode, path, host: null, interval_ms: 5000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, rise: 1, expected_status_min: 200, expected_status_max: 399 });
|
|
||||||
const a = { id: 'alpha', url: 'http://192.0.2.1:9000', health_check_enabled: true, health_check: probe('http', '/a') };
|
|
||||||
const b = { id: 'disabled', url: 'http://192.0.2.2:9000', health_check_enabled: false, health_check: probe('http', '/b') };
|
|
||||||
const c = { id: 'charlie', url: 'http://192.0.2.3:9000', health_check_enabled: true, health_check: null };
|
|
||||||
for (const backend of [a, b, c]) exports.memberRow(members, { weight: 1, ...backend });
|
|
||||||
const [rowA, rowB, rowC] = members.children;
|
|
||||||
const wrapper = el('div'); context.backendHealthEditorControl = exports.backendHealthEditor(wrapper, members);
|
|
||||||
const select = wrapper.querySelector('[name="health-backend-select"]');
|
|
||||||
const control = (row, name) => row.memberHealthPanel.querySelector(`[name="${name}"]`);
|
|
||||||
assert(select.children.length === 2 && select.children[0].textContent.includes('alpha') && select.children[1].textContent.includes('charlie'), 'Unmonitored backend listed in selector');
|
|
||||||
assert(!rowA.memberHealthPanel.hidden && rowB.memberHealthPanel.hidden && rowC.memberHealthPanel.hidden, 'Selected panel visibility incorrect');
|
|
||||||
assert(control(rowB, 'member-health-path').disabled, 'Unmonitored override must be non-editable');
|
|
||||||
assert(!control(rowA, 'member-health-path').disabled, 'Monitored override unexpectedly disabled');
|
|
||||||
assert(control(rowA, 'member-health-path').value === '/a', 'Existing per-member override not loaded');
|
|
||||||
|
|
||||||
select.value = '1'; select.dispatch('change');
|
|
||||||
assert(rowA.memberHealthPanel.hidden && !rowC.memberHealthPanel.hidden, 'Switch did not change selected panel');
|
|
||||||
const customC = control(rowC, 'member-health-custom');
|
|
||||||
customC.checked = true; customC.dispatch('change');
|
|
||||||
assert(control(rowC, 'member-health-mode').value === 'http' &&
|
|
||||||
control(rowC, 'member-health-interval').value === '8500' &&
|
|
||||||
control(rowC, 'member-health-path').value === '/shared', 'First override did not inherit shared settings');
|
|
||||||
const pathC = control(rowC, 'member-health-path'); pathC.value = '/charlie-ready';
|
|
||||||
customC.checked = false; customC.dispatch('change');
|
|
||||||
customC.checked = true; customC.dispatch('change');
|
|
||||||
assert(pathC.value === '/charlie-ready', 'Toggling custom override discarded edits');
|
|
||||||
assert(!pathC.disabled, 'New custom override should become editable');
|
|
||||||
select.value = '0'; select.dispatch('change');
|
|
||||||
assert(control(rowA, 'member-health-path').value === '/a' && pathC.value === '/charlie-ready', 'Switch discarded unsaved member edits');
|
|
||||||
select.value = '1'; select.dispatch('change');
|
|
||||||
assert(pathC.value === '/charlie-ready' && !rowC.memberHealthPanel.hidden, 'Returning to backend lost its edits');
|
|
||||||
|
|
||||||
rowC.querySelector('[name="member-id"]').value = 'renamed';
|
|
||||||
rowC.querySelector('[name="member-id"]').dispatch('input');
|
|
||||||
assert(select.children[1].textContent.includes('renamed'), 'Backend rename not reflected in selector');
|
|
||||||
const monitorA = rowA.querySelector('[name="member-health-enabled"]');
|
|
||||||
monitorA.checked = false; monitorA.dispatch('change');
|
|
||||||
assert(select.children.length === 1 && select.children[0].textContent.includes('renamed'), 'Disabled monitor remains in selector');
|
|
||||||
assert(control(rowA, 'member-health-path').value === '/a' && control(rowA, 'member-health-path').disabled, 'Disabling monitor lost override or left active controls');
|
|
||||||
monitorA.checked = true; monitorA.dispatch('change');
|
|
||||||
assert(select.children.length === 2 && pathC.value === '/charlie-ready', 'Re-enabling monitor did not restore choice/edits');
|
|
||||||
const removeC = rowC.children[0].children[1]; // backend-card header's Remove button
|
|
||||||
removeC.dispatch('click');
|
|
||||||
assert(members.children.length === 2 && select.children.length === 1 && !rowA.memberHealthPanel.hidden, 'Deleting selected backend did not refresh selector');
|
|
||||||
assert(wrapper.querySelectorAll('*').includes(rowA.memberHealthPanel) && !wrapper.querySelectorAll('*').includes(rowC.memberHealthPanel), 'Removed backend panel remained retained in DOM');
|
|
||||||
monitorA.checked = false; monitorA.dispatch('change');
|
|
||||||
assert(select.disabled && select.children.length === 0 && rowB.memberHealthPanel.hidden, 'Empty monitored backend list not handled');
|
|
||||||
|
|
||||||
assert(editor.includes('health_check: yes("member-health-custom", row.memberHealthPanel)'), 'Per-member health settings not collected from selected panels');
|
|
||||||
assert(editor.includes('backendHealthEditorControl = backendHealthEditor(g, members)'), 'Selector not wired to Health tab');
|
|
||||||
assert(render.includes('backendHealthEditorControl?.reveal('), 'Invalid hidden member settings cannot be revealed');
|
|
||||||
assert(core.includes('backendHealthEditorControl = state.backendHealthEditorControl || null;') && core.includes('pagesEditor, backendHealthEditorControl,'), 'Draft switching lost selected backend editor');
|
|
||||||
assert(fs.readFileSync('src/admin/router.rs','utf8').includes('include_str!("web/vhosts/health.js")'), 'Bundled health editor missing');
|
|
||||||
console.log('PASS: 0.30.0 monitored-only backend selector, independent unsaved edits, monitor toggle, rename/removal, empty list and validation routing.');
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// Verifies real-status-to-badge mapping without guessing a configured Auto result.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const source = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
|
|
||||||
const block = source.slice(source.indexOf('const VHOST_PROTOCOL_FRESH_MS = '), source.indexOf('function render() {'));
|
|
||||||
assert(block.includes('function renderUpstreamProtocolBadges(status)'));
|
|
||||||
const rustClient = fs.readFileSync('src/upstream/client.rs', 'utf8');
|
|
||||||
const rustPool = fs.readFileSync('src/balance/pool_view.rs', 'utf8');
|
|
||||||
assert(rustClient.includes('self.inner.observe_protocol(value.version())'));
|
|
||||||
assert(rustClient.includes('let response = client.request(request).await;'));
|
|
||||||
for (const name of ['upstream_protocol_observed','upstream_protocol_last_seen_ms','upstream_protocol_http1_total','upstream_protocol_http2_total']) {
|
|
||||||
assert(rustPool.includes(`"${name}"`), name);
|
|
||||||
}
|
|
||||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
|
||||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
|
||||||
for(const key of ['vhBackendProtocolRecent','vhBackendProtocolUnobserved']) assert(en[key] && pl[key]);
|
|
||||||
const badge = (vhost, backend) => ({dataset:{upstreamProtocolVhost:vhost,upstreamProtocolBackend:backend},textContent:'',title:''});
|
|
||||||
const visible = [badge('api','auto-backend'),badge('api','h1-backend'),badge('other','auto-backend')];
|
|
||||||
const envelope = {config:{vhosts:[
|
|
||||||
{id:'api',backends:[{id:'auto-backend',protocol:'auto'},{id:'h1-backend',protocol:'http1'}]},
|
|
||||||
{id:'other',backends:[{id:'auto-backend',protocol:'auto'}]},
|
|
||||||
]}};
|
|
||||||
const ctx = {Date, Number, String, Map, envelope, document:{querySelectorAll:()=>visible},t:key=>en[key]};
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(block, ctx);
|
|
||||||
const recent = Date.now() - 2000;
|
|
||||||
let status = {backend_pools:[{id:'api',members:[
|
|
||||||
{id:'auto-backend',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:5,upstream_protocol_http2_total:9},
|
|
||||||
{id:'h1-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:12},
|
|
||||||
]}]};
|
|
||||||
ctx.renderUpstreamProtocolBadges(status);
|
|
||||||
assert.equal(visible[0].textContent,'auto-backend · AUTO → H2');
|
|
||||||
assert.equal(visible[0].dataset.observed,'true');
|
|
||||||
assert(visible[0].title.includes('H1: 5; H2: 9'));
|
|
||||||
assert.equal(visible[1].textContent,'h1-backend · H1 → H1');
|
|
||||||
assert.equal(visible[2].textContent,'auto-backend · AUTO → ?');
|
|
||||||
assert.equal(visible[2].dataset.observed,'false');
|
|
||||||
// Old/stale response must not masquerade as a recent ALPN observation.
|
|
||||||
status.backend_pools[0].members[0].upstream_protocol_last_seen_ms = Date.now() - 3600000;
|
|
||||||
ctx.renderUpstreamProtocolBadges(status);
|
|
||||||
assert.equal(visible[0].textContent,'auto-backend · AUTO → ?');
|
|
||||||
assert.equal(visible[0].dataset.observed,'false');
|
|
||||||
assert(visible[0].title.startsWith(en.vhBackendProtocolUnobserved));
|
|
||||||
// Fresh data for a similarly named backend from another vhost must not leak.
|
|
||||||
ctx.renderUpstreamProtocolBadges({backend_pools:[{id:'other',members:[
|
|
||||||
{id:'auto-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent}
|
|
||||||
]}]});
|
|
||||||
assert.equal(visible[0].textContent,'auto-backend · AUTO → ?');
|
|
||||||
assert.equal(visible[2].textContent,'auto-backend · AUTO → H1');
|
|
||||||
ctx.renderUpstreamProtocolBadges({backend_pools:[]});
|
|
||||||
assert.equal(visible[2].textContent,'auto-backend · AUTO → ?');
|
|
||||||
console.log('PASS: observed Auto/H1/H2 badges, freshness, cross-vhost isolation and unknown data');
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""0.31.9 Certificate API source/OpenAPI/UI contracts; no Rust runtime claims."""
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[2]
|
|
||||||
read = lambda path: (root / path).read_text(encoding='utf-8')
|
|
||||||
version = tomllib.loads(read('Cargo.toml'))['package']['version']
|
|
||||||
assert version == '0.32.7', version
|
|
||||||
spec = json.loads(read('src/admin/openapi.json'))
|
|
||||||
assert spec['info']['version'] == version
|
|
||||||
paths = spec['paths']
|
|
||||||
assert set(paths['/api/certificate-api/{key_id}']) == {'get', 'patch', 'delete'}
|
|
||||||
assert set(paths['/api/certificate-api/{key_id}/regenerate']) == {'post'}
|
|
||||||
for method, path in [('get', '/api/certificate-api/{key_id}'), ('patch', '/api/certificate-api/{key_id}'),
|
|
||||||
('post', '/api/certificate-api/{key_id}/regenerate')]:
|
|
||||||
operation = paths[path][method]
|
|
||||||
assert operation['security'] == ([{'AdminSession': []}] if method == 'get' else [{'AdminSession': [], 'CsrfToken': []}])
|
|
||||||
assert operation['parameters'][0]['name'] == 'key_id'
|
|
||||||
assert '401' in operation['responses'] and '404' in operation['responses']
|
|
||||||
edit = spec['components']['schemas']['CertificateApiEdit']
|
|
||||||
assert edit['additionalProperties'] is False
|
|
||||||
assert set(edit['required']) == {'comment', 'certificate_ids', 'allow_deploy'}
|
|
||||||
assert not {'token', 'token_hash', 'key_id'} & edit['properties'].keys()
|
|
||||||
assert set(spec['components']['schemas']['CertificateApiDetailResult']['properties']) == {'credential'}
|
|
||||||
assert set(spec['components']['schemas']['CertificateApiRegenerateResult']['properties']) == {'credential', 'token'}
|
|
||||||
assert 'readOnly' in spec['components']['schemas']['CertificateApiRegenerateResult']['properties']['token']
|
|
||||||
backend = read('src/admin/certificate_api.rs')
|
|
||||||
router = read('src/admin/router.rs')
|
|
||||||
db = read('src/db/certificate_api.rs')
|
|
||||||
assert 'include!("certificate_api.rs")' in read('src/admin/mod.rs')
|
|
||||||
for symbol in ('certificate_api_detail', 'certificate_api_edit', 'certificate_api_regenerate'):
|
|
||||||
assert f'async fn {symbol}(' in backend
|
|
||||||
assert symbol in router
|
|
||||||
assert 'deny_unknown_fields' in backend
|
|
||||||
read_source = backend.split('async fn certificate_api_detail(')[1].split('async fn certificate_api_edit(')[0]
|
|
||||||
edit_source = backend.split('async fn certificate_api_edit(')[1].split('async fn certificate_api_regenerate(')[0]
|
|
||||||
regenerate_source = backend.split('async fn certificate_api_regenerate(')[1].split('async fn certificate_api_revoke(')[0]
|
|
||||||
assert '"token"' not in read_source
|
|
||||||
assert '"token"' not in edit_source and '"token_hash"' not in edit_source
|
|
||||||
assert '"token": token' in regenerate_source
|
|
||||||
assert 'auth::random_secret()' in regenerate_source
|
|
||||||
assert '"certificate_api.invalid_certificate_ids"' in edit_source
|
|
||||||
update_source = db.split('pub async fn edit_certificate_api_credential(')[1].split('pub async fn regenerate_certificate_api_token(')[0]
|
|
||||||
rotate_source = db.split('pub async fn regenerate_certificate_api_token(')[1].split('pub async fn revoke_certificate_api_credential(')[0]
|
|
||||||
assert 'SET comment = ?' in update_source
|
|
||||||
assert 'SET token_hash' not in update_source
|
|
||||||
assert 'SET token_hash = ?, created_at = ?, last_used_at = NULL' in rotate_source
|
|
||||||
assert 'certificate_api.edit' in update_source and 'certificate_api.regenerate' in rotate_source
|
|
||||||
assert 'WHERE key_id = ?' in rotate_source
|
|
||||||
assert 'certificate_api_operation(method: &Method' in router or 'fn certificate_api_operation(method: &Method' in read('src/admin/access.rs') + router
|
|
||||||
assert 'if method != Method::POST' in router
|
|
||||||
panel = read('src/admin/web/certificate-api.js')
|
|
||||||
assert 'include_str!("web/certificate-api.js")' in router
|
|
||||||
for marker in ('function certificateApiDetailNode(', 'beginCertificateApiEdit(', 'certificateApiEditingKey',
|
|
||||||
'method: "PATCH"', '/regenerate', 'showCertificateApiSecret(data.token || "")',
|
|
||||||
'clearCertificateApiSecret()', 'token-field', 'certificateApiTokenHidden'):
|
|
||||||
assert marker in panel, marker
|
|
||||||
for item in ('certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-secret', 'certificate-api-token-field'):
|
|
||||||
assert f'id="{item}"' in read('src/admin/web/index.html')
|
|
||||||
for language in ('en_US', 'pl_pl'):
|
|
||||||
strings = json.loads(read(f'langs/{language}.json'))['translations']
|
|
||||||
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
|
|
||||||
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
|
|
||||||
assert strings.get(key), (language, key)
|
|
||||||
assert '0.32.7' in read('docs/TEST_REPORT.md')
|
|
||||||
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Execute the real 0.31.9 Certificate API panel code against a simulated DOM/API.
|
|
||||||
// This is not a compiled Rust proxy test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const source = readFileSync('src/admin/web/certificate-api.js', 'utf8');
|
|
||||||
const html = readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
|
|
||||||
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
|
||||||
for (const pack of [english, polish]) {
|
|
||||||
for (const key of ['certificateApiView', 'certificateApiHide', 'certificateApiEdit',
|
|
||||||
'certificateApiSave', 'certificateApiCancelEdit', 'certificateApiRegenerate',
|
|
||||||
'certificateApiRegenerateConfirm', 'certificateApiRegenerated', 'certificateApiTokenHidden']) {
|
|
||||||
assert.ok(pack[key], key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const id of ['certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-token-field',
|
|
||||||
'certificate-api-secret', 'certificate-api-credentials', 'certificate-api-edit-note']) {
|
|
||||||
assert.ok(html.includes(`id="${id}"`), id);
|
|
||||||
}
|
|
||||||
class FakeNode {
|
|
||||||
constructor(tag = 'div', content = '', cls = '') {
|
|
||||||
this.tagName = tag; this.value = ''; this.content = String(content ?? '');
|
|
||||||
this.className = cls; this.children = []; this.events = {};
|
|
||||||
this.dataset = {}; this.hidden = false; this.disabled = false;
|
|
||||||
this.checked = false; this.readOnly = false;
|
|
||||||
this.classList = { add: cls => { this.className += ` ${cls}`; } };
|
|
||||||
}
|
|
||||||
get textContent() { return this.content + this.children.map(child => child.textContent).join(''); }
|
|
||||||
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
|
|
||||||
addEventListener(type, callback) { this.events[type] = callback; }
|
|
||||||
append(...children) { this.children.push(...children); }
|
|
||||||
replaceChildren(...children) { this.children = [...children]; this.content = ''; }
|
|
||||||
scrollIntoView() { }
|
|
||||||
focus() { }
|
|
||||||
async fire(type, event = {}) { assert.ok(this.events[type], `${this.tagName} missing ${type}`); await this.events[type](event); }
|
|
||||||
findByText(value) { return [this, ...this.children.flatMap(child => child.findAll())].find(child => child.content === value); }
|
|
||||||
findAll() { return [this, ...this.children.flatMap(child => child.findAll())]; }
|
|
||||||
}
|
|
||||||
const fields = new Map();
|
|
||||||
const dollar = id => {
|
|
||||||
if (!fields.has(id)) fields.set(id, new FakeNode(id));
|
|
||||||
return fields.get(id);
|
|
||||||
};
|
|
||||||
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
|
|
||||||
let persisted = [{ key_id: 'renew-prod', comment: 'Old owner', certificate_ids: ['first-cert'],
|
|
||||||
allow_deploy: false, created_at: 1690000000, last_used_at: 1700000000 }];
|
|
||||||
let activeToken = 'original-secret-not-readable';
|
|
||||||
const requests = [];
|
|
||||||
const makeStatus = () => ({ configured: persisted.length > 0, credentials: structuredClone(persisted),
|
|
||||||
max_credentials: 32, scopes: ['certificates:reload', 'certificates:deploy'] });
|
|
||||||
async function api(path, options = {}) {
|
|
||||||
const method = options.method || 'GET';
|
|
||||||
const data = options.body ? JSON.parse(options.body) : {};
|
|
||||||
requests.push({ path, method, data });
|
|
||||||
if (path === '/api/certificate-api' && method === 'GET') return makeStatus();
|
|
||||||
if (path === '/api/certificate-api' && method === 'DELETE') { persisted = []; return { revoked: 1 }; }
|
|
||||||
if (path === '/api/certificate-api' && method === 'PUT') {
|
|
||||||
activeToken = data.token || 'created-once-token';
|
|
||||||
const entry = { key_id: data.key_id || 'generated-id', comment: data.comment,
|
|
||||||
certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy,
|
|
||||||
created_at: 1800000000, last_used_at: null };
|
|
||||||
persisted.push(entry);
|
|
||||||
return { ...makeStatus(), credential: entry, token: activeToken, generated: !data.token };
|
|
||||||
}
|
|
||||||
const match = path.match(/^\/api\/certificate-api\/([^/]+)(\/regenerate)?$/);
|
|
||||||
assert.ok(match, `Unexpected ${method} ${path}`);
|
|
||||||
const credential = persisted.find(entry => entry.key_id === match[1]);
|
|
||||||
assert.ok(credential);
|
|
||||||
if (method === 'GET') return { credential: structuredClone(credential) };
|
|
||||||
if (method === 'PATCH') {
|
|
||||||
assert.deepEqual(Object.keys(data).sort(), ['allow_deploy', 'certificate_ids', 'comment']);
|
|
||||||
assert.ok(!('token' in data) && !('token_hash' in data));
|
|
||||||
Object.assign(credential, { comment: data.comment, certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy });
|
|
||||||
return { credential: structuredClone(credential) };
|
|
||||||
}
|
|
||||||
if (method === 'POST' && match[2] === '/regenerate') {
|
|
||||||
activeToken = 'replacement-secret-once';
|
|
||||||
credential.last_used_at = null;
|
|
||||||
credential.created_at = 1800000001;
|
|
||||||
return { credential: structuredClone(credential), token: activeToken };
|
|
||||||
}
|
|
||||||
if (method === 'DELETE') {
|
|
||||||
persisted = persisted.filter(entry => entry.key_id !== credential.key_id);
|
|
||||||
return { revoked: true };
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported ${method} ${path}`);
|
|
||||||
}
|
|
||||||
const toasts = [];
|
|
||||||
const ctx = {
|
|
||||||
document: {}, window: {}, $: dollar, el: node,
|
|
||||||
action: (text, cb) => { const result = node('button', text, 'quiet'); result.addEventListener('click', cb); result.type = 'button'; return result; },
|
|
||||||
t: key => english[key] || key,
|
|
||||||
location: { origin: 'https://admin.example.test' },
|
|
||||||
authenticated: true, api, appConfirm: async () => true,
|
|
||||||
formatDateTime: timestamp => new Date(timestamp).toISOString(),
|
|
||||||
toast: value => toasts.push(value),
|
|
||||||
fieldError: (_, message) => new Error(message), showFieldError: () => {},
|
|
||||||
};
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(source, ctx, { filename: 'src/admin/web/certificate-api.js' });
|
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
|
||||||
const list = dollar('certificate-api-credentials');
|
|
||||||
const actionsFor = (rowIndex = 0) => list.children[rowIndex].children[0].children[1].children;
|
|
||||||
const click = async (label, rowIndex = 0) => {
|
|
||||||
const btn = actionsFor(rowIndex).find(item => item.content === label);
|
|
||||||
assert.ok(btn, `Missing button: ${label}`);
|
|
||||||
await btn.fire('click');
|
|
||||||
};
|
|
||||||
assert.equal(list.children.length, 1);
|
|
||||||
assert.ok(requests.some(request => request.path === '/api/certificate-api' && request.method === 'GET'));
|
|
||||||
|
|
||||||
await click(english.certificateApiView);
|
|
||||||
assert.equal(list.children[0].children.length, 2, 'details expanded');
|
|
||||||
const previewText = list.children[0].children[1].textContent;
|
|
||||||
assert.match(previewText, /renew-prod/);
|
|
||||||
assert.match(previewText, /Old owner/);
|
|
||||||
assert.match(previewText, /first-cert/);
|
|
||||||
assert.match(previewText, /Unavailable/);
|
|
||||||
assert.ok(!previewText.includes(activeToken), 'never show existing bearer token');
|
|
||||||
assert.ok(requests.some(request => request.path === '/api/certificate-api/renew-prod' && request.method === 'GET'));
|
|
||||||
|
|
||||||
await click(english.certificateApiEdit);
|
|
||||||
assert.equal(dollar('certificate-api-key-id').value, 'renew-prod');
|
|
||||||
assert.equal(dollar('certificate-api-key-id').readOnly, true);
|
|
||||||
assert.equal(dollar('certificate-api-comment').value, 'Old owner');
|
|
||||||
assert.equal(dollar('certificate-api-token-field').hidden, true);
|
|
||||||
assert.equal(dollar('certificate-api-save').content, english.certificateApiSave);
|
|
||||||
assert.equal(dollar('certificate-api-cancel-edit').hidden, false);
|
|
||||||
dollar('certificate-api-comment').value = 'New owner';
|
|
||||||
dollar('certificate-api-certificate-ids').value = 'second-cert,third-cert';
|
|
||||||
dollar('certificate-api-allow-deploy').checked = true;
|
|
||||||
await dollar('certificate-api-form').fire('submit', { preventDefault() {} });
|
|
||||||
assert.equal(persisted[0].comment, 'New owner');
|
|
||||||
assert.deepEqual(persisted[0].certificate_ids, ['second-cert', 'third-cert']);
|
|
||||||
assert.equal(persisted[0].allow_deploy, true);
|
|
||||||
assert.equal(persisted[0].created_at, 1690000000, 'editing must not rotate');
|
|
||||||
assert.equal(activeToken, 'original-secret-not-readable');
|
|
||||||
assert.equal(dollar('certificate-api-token-field').hidden, false);
|
|
||||||
assert.equal(dollar('certificate-api-save').content, english.certificateApiRotate);
|
|
||||||
assert.equal(dollar('certificate-api-secret-wrap').hidden, true);
|
|
||||||
|
|
||||||
await click(english.certificateApiRegenerate);
|
|
||||||
assert.equal(activeToken, 'replacement-secret-once');
|
|
||||||
assert.equal(dollar('certificate-api-secret').textContent, activeToken);
|
|
||||||
assert.equal(dollar('certificate-api-secret-wrap').hidden, false);
|
|
||||||
assert.equal(persisted[0].comment, 'New owner', 'metadata unchanged by regeneration');
|
|
||||||
assert.equal(persisted[0].allow_deploy, true);
|
|
||||||
assert.equal(persisted[0].last_used_at, null);
|
|
||||||
assert.equal(list.textContent.includes(activeToken), false, 'token not in persistent credential DOM');
|
|
||||||
await click(english.certificateApiHide);
|
|
||||||
assert.equal(dollar('certificate-api-secret').textContent, 'replacement-secret-once', 'collapsing is not a new API secret read');
|
|
||||||
await click(english.certificateApiView);
|
|
||||||
assert.equal(dollar('certificate-api-secret-wrap').hidden, true, 'reading settings does not re-expose the token');
|
|
||||||
assert.equal(dollar('certificate-api-secret').textContent, '');
|
|
||||||
assert.ok(!list.textContent.includes(activeToken));
|
|
||||||
|
|
||||||
ctx.window.rustProxyCertificateApi.reset();
|
|
||||||
assert.equal(dollar('certificate-api-secret').textContent, '');
|
|
||||||
assert.equal(list.children.length, 0);
|
|
||||||
await ctx.window.rustProxyCertificateApi.load();
|
|
||||||
assert.equal(list.children.length, 1);
|
|
||||||
assert.ok(!list.textContent.includes(activeToken));
|
|
||||||
assert.equal(requests.filter(item => item.path.endsWith('/regenerate')).length, 1);
|
|
||||||
assert.ok(toasts.includes(english.certificateApiSaved));
|
|
||||||
assert.ok(toasts.includes(english.certificateApiRegenerated));
|
|
||||||
console.log('PASS: 0.31.9 Certificate API view/edit/regenerate/one-time secret UI (real module, simulated DOM/API).');
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,91 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Optional real-CSS/JS Chromium regression for the horizontal Overview card ribbon.
|
|
||||||
|
|
||||||
Mocks authenticated preferences; no server or physical iOS device involved.
|
|
||||||
Run with: python tools/checks/dashboard_appearance_ribbon_0326_browser.py
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
root=Path(__file__).resolve().parents[2]
|
|
||||||
html=(root/'src/admin/web/index.html').read_text()
|
|
||||||
css=(root/'src/admin/web/style.css').read_text()
|
|
||||||
prefs=(root/'src/admin/web/app/preferences.js').read_text()
|
|
||||||
registry=json.loads((root/'src/admin/web/dashboard-widgets.json').read_text())
|
|
||||||
translations=json.loads((root/'langs/pl_pl.json').read_text())['translations']
|
|
||||||
heading=html.index('data-i18n="dashboardLayout"')
|
|
||||||
section_start=html.rfind('<section class="appearance-section">',0,heading)
|
|
||||||
section_end=html.index('</section>',heading)+len('</section>')
|
|
||||||
assert section_start >= 0
|
|
||||||
source=html[section_start:section_end]
|
|
||||||
start=prefs.index('function renderDashboardWidgetSettings() {')
|
|
||||||
end=prefs.index('function refreshChartStylePalette(',start)
|
|
||||||
code=prefs[start:end]
|
|
||||||
stub='''
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
const el = (tag, txt, classes) => { let node=document.createElement(tag); if (txt!==undefined) node.textContent=String(txt); if (classes) node.className=classes; return node; };
|
|
||||||
const labels = __TRANSLATIONS__;
|
|
||||||
const registry = __REGISTRY__;
|
|
||||||
const ADMIN_DASHBOARD_BY_ID = new Map(registry.map(def=>[def.id,def]));
|
|
||||||
const order = registry.map(def=>def.id);
|
|
||||||
let adminPreferences = {dashboard_widgets:order, dashboard_hidden_widgets:registry.filter(def=>def.default_hidden).map(def=>def.id)};
|
|
||||||
const ADMIN_APPEARANCE_DEFAULTS = adminPreferences;
|
|
||||||
function t(key){ return labels[key] || key; }
|
|
||||||
function dashboardWidgetLabel(id) { return t(ADMIN_DASHBOARD_BY_ID.get(id)?.label_key || id); }
|
|
||||||
function normalizeDashboardWidgets(value){return Array.isArray(value)?value:order;}
|
|
||||||
function normalizeHiddenDashboardWidgets(value){return Array.isArray(value)?value:[];}
|
|
||||||
function applyDashboardLayout(order,hidden){ window.__state={order,hidden}; }
|
|
||||||
async function saveAppearancePreference(next){ Object.assign(adminPreferences,next);return {}; }
|
|
||||||
'''.replace('__TRANSLATIONS__',json.dumps(translations,ensure_ascii=False)).replace('__REGISTRY__',json.dumps(registry))
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser=p.chromium.launch(executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),args=['--no-sandbox'],headless=True)
|
|
||||||
for width,height in [(1500,950),(393,850)]:
|
|
||||||
page=browser.new_page(viewport={'width':width,'height':height},device_scale_factor=1)
|
|
||||||
page.set_content('<!doctype html><html data-theme="dark" lang="pl"><head><style>'+css+'</style></head><body><main style="margin:0; padding:28px 16px; width:100%; max-width:100%; box-sizing:border-box">'+source+'</main></body></html>',wait_until='domcontentloaded')
|
|
||||||
page.add_script_tag(content=stub+code+'\nrenderDashboardWidgetSettings();')
|
|
||||||
page.wait_for_timeout(90)
|
|
||||||
metrics=page.evaluate('''() => {
|
|
||||||
const list=document.getElementById('dashboard-widget-list'), section=document.querySelector('.appearance-section');
|
|
||||||
const parent=list.closest('.dashboard-widget-settings');
|
|
||||||
return { total: list.children.length, listHeight:list.getBoundingClientRect().height,
|
|
||||||
listWidth:list.getBoundingClientRect().width, contentWidth:list.scrollWidth,
|
|
||||||
sectionHeight:section.getBoundingClientRect().height,
|
|
||||||
overflowX:getComputedStyle(list).overflowX,
|
|
||||||
firstWidth:list.firstElementChild.getBoundingClientRect().width,
|
|
||||||
density: document.querySelector('.dashboard-density-setting').getBoundingClientRect().width,
|
|
||||||
widget:parent.getBoundingClientRect().width,
|
|
||||||
viewportOverflow:document.documentElement.scrollWidth>window.innerWidth,
|
|
||||||
nextDisabled:document.getElementById('dashboard-widget-scroll-next').disabled};
|
|
||||||
}''')
|
|
||||||
print(width,'COMPACT',metrics)
|
|
||||||
assert metrics['total']==len(registry)
|
|
||||||
assert metrics['listHeight']<66,(width,metrics)
|
|
||||||
assert metrics['contentWidth']>metrics['listWidth'],(width,metrics)
|
|
||||||
assert metrics['sectionHeight'] < (320 if width>700 else 450), (width,metrics)
|
|
||||||
assert not metrics['viewportOverflow']
|
|
||||||
page.locator('#dashboard-widget-scroll-next').click()
|
|
||||||
page.wait_for_timeout(450)
|
|
||||||
pos=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft')
|
|
||||||
print(width,'scroll after next:',pos)
|
|
||||||
assert pos>10
|
|
||||||
page.locator('.dashboard-widget-setting').nth(7).locator('button.dashboard-widget-toggle').click(force=True)
|
|
||||||
page.wait_for_timeout(60)
|
|
||||||
state=page.evaluate('window.__state')
|
|
||||||
assert state and len(state['order'])==len(registry)
|
|
||||||
retained=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft')
|
|
||||||
assert retained>=pos-15,(pos,retained)
|
|
||||||
page.locator('#dashboard-widget-expand').click()
|
|
||||||
page.wait_for_timeout(60)
|
|
||||||
expanded=page.evaluate('''() => { const list = document.getElementById("dashboard-widget-list"); return { height:list.getBoundingClientRect().height, mode:getComputedStyle(list).display, count:list.children.length, expanded:document.getElementById('dashboard-widget-expand').getAttribute('aria-expanded'), prevHidden:document.getElementById('dashboard-widget-scroll-prev').hidden } }''')
|
|
||||||
print(width,'EXPANDED',expanded)
|
|
||||||
assert expanded['mode']=='grid'
|
|
||||||
assert expanded['expanded']=='true' and expanded['prevHidden']
|
|
||||||
assert expanded['height']>metrics['listHeight']*3
|
|
||||||
page.locator('#dashboard-widget-expand').click()
|
|
||||||
assert page.locator('#dashboard-widget-expand').get_attribute('aria-expanded')=='false'
|
|
||||||
outfile=str(root.parent / f'dashboard_appearance_0326_preview_{width}.png')
|
|
||||||
page.locator('.appearance-section').screenshot(path=outfile)
|
|
||||||
print('screenshot',outfile)
|
|
||||||
page.close()
|
|
||||||
browser.close()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// Appearance ribbon: compact by default, with full-width expansion and the same
|
|
||||||
// persisted visibility/reorder controls. No browser or Rust service required.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
|
||||||
const js = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
|
||||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
|
||||||
const locales = ['en_US', 'pl_pl'].map(id => JSON.parse(fs.readFileSync(`langs/${id}.json`, 'utf8')).translations);
|
|
||||||
for (const id of ['dashboard-widget-list', 'dashboard-widget-expand', 'dashboard-widget-scroll-prev',
|
|
||||||
'dashboard-widget-scroll-next', 'overview-layout-open-settings', 'ui-dashboard-density']) {
|
|
||||||
assert(html.includes(`id="${id}"`), `missing ${id}`);
|
|
||||||
}
|
|
||||||
assert.match(html, /class="appearance-settings-grid dashboard-appearance-grid"/);
|
|
||||||
assert.match(css, /\.appearance-settings-grid\.dashboard-appearance-grid\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s,
|
|
||||||
'dashboard setting layout must use the full available width');
|
|
||||||
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*overflow-x:\s*auto;/s,
|
|
||||||
'the default card selector must be a horizontal ribbon, not a tall two-column list');
|
|
||||||
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\.is-expanded\s*\{[^}]*display:\s*grid;/s);
|
|
||||||
assert.match(css, /\.dashboard-widget-settings-controls\s*>\s*\.dashboard-widget-scroll-button/);
|
|
||||||
assert(js.includes('list.scrollLeft = previousScroll'), 'rebuilding settings must preserve horizontal scroll position');
|
|
||||||
assert(js.includes('focusedId') && js.includes('preventScroll: true'), 'keyboard focus must survive updates');
|
|
||||||
assert(js.includes('saveAppearancePreference({ dashboard_hidden_widgets:') &&
|
|
||||||
js.includes('saveAppearancePreference({ dashboard_widgets:'), 'both APIs must retain persistence');
|
|
||||||
assert(js.includes('const up = el("button", "←"') && js.includes('const down = el("button", "→"'),
|
|
||||||
'horizontal order controls must align with the ribbon');
|
|
||||||
assert.equal(registry.length, 62, 'all configured Overview cards must remain available');
|
|
||||||
for (const translation of locales) {
|
|
||||||
for (const key of ['dashboardWidgetsShowAll', 'dashboardWidgetsCompact', 'dashboardWidgetsScrollBack',
|
|
||||||
'dashboardWidgetsScrollNext', 'dashboardWidgetsScrollHelp',
|
|
||||||
'dashboardWidgetsMoveEarlier', 'dashboardWidgetsMoveLater']) {
|
|
||||||
assert(translation[key], `missing ${key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('PASS: full-width compact Appearance ribbon with expandable 62-card layout, keyboard focus and preference persistence');
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Release-independent, real-source startup ordering and recovery contract.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
import { resolve, dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
||||||
const read = path => readFileSync(resolve(root, path), 'utf8');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const runtime = read('src/admin/web/app/runtime.js');
|
|
||||||
const core = read('src/admin/web/app/core.js');
|
|
||||||
const router = read('src/admin/router.rs');
|
|
||||||
const bundle = read('tools/checks/web_assets.py');
|
|
||||||
const version = read('Cargo.toml').match(/^version\s*=\s*"([\d.]+)"/m)?.[1];
|
|
||||||
assert(version);
|
|
||||||
assert.equal((html.match(/<script defer="" src="\/assets\//g) || []).length, 1, 'one atomic dashboard script');
|
|
||||||
assert.match(html, /src="\/assets\/app___RUSTPROXY_ASSET_ID__\.js"/);
|
|
||||||
assert.match(html, /href="\/assets\/style___RUSTPROXY_ASSET_ID__\.css"/);
|
|
||||||
assert.match(router, /admin_vhosts_javascript\(\)/);
|
|
||||||
assert.match(bundle, /"integrations\.js", "vhosts\.js"/);
|
|
||||||
assert(router.indexOf('include_str!("web/integrations.js")') < router.indexOf('admin_vhosts_javascript(),'));
|
|
||||||
assert(router.indexOf('admin_vhosts_javascript(),') < router.indexOf('include_str!("web/host404.js")'));
|
|
||||||
|
|
||||||
const prefix = runtime.slice(0, runtime.indexOf('function certificateUsesExternalFiles'));
|
|
||||||
assert(prefix.includes('document.addEventListener("DOMContentLoaded", startAdminPanel'));
|
|
||||||
function simulate(complete) {
|
|
||||||
let bootstrap, calls = 0, activated = 0, failed = null;
|
|
||||||
const modules = {};
|
|
||||||
for (const [name, action] of [
|
|
||||||
['rustProxyIntegrations','bindings'], ['rustProxyVhosts','render'],
|
|
||||||
['rustProxySettings','render'], ['rustProxyCachePanel','refresh'],
|
|
||||||
['rustProxyTrafficPanel','refresh'], ['rustProxyPreferences','load'],
|
|
||||||
['rustProxyRefresh','start'],
|
|
||||||
]) if (complete || name !== 'rustProxyIntegrations') modules[name] = { [action]() {} };
|
|
||||||
const ctx = {
|
|
||||||
window: modules, document: { readyState: 'loading', addEventListener(name, fn) {
|
|
||||||
assert.equal(name, 'DOMContentLoaded'); bootstrap = fn;
|
|
||||||
} },
|
|
||||||
api: async () => { calls++; return { csrf_token: 'abc' }; },
|
|
||||||
clearInvalidOriginRecovery() {}, loadLanguages: async () => {},
|
|
||||||
translate() {}, syncAuthForm() {}, activate: async () => { activated++; },
|
|
||||||
recoverInvalidOrigin: () => false, showStartupFailure: e => { failed = e; },
|
|
||||||
location: { pathname: '/overview', search: '', replace() { throw new Error('unexpected redirect'); } },
|
|
||||||
};
|
|
||||||
runInNewContext(prefix, ctx);
|
|
||||||
assert.equal(calls, 0, 'zero API calls before all defer/module readiness');
|
|
||||||
assert.equal(activated, 0);
|
|
||||||
assert(bootstrap, 'registered DOMContentLoaded bootstrap');
|
|
||||||
return { bootstrap, state: () => ({ calls, activated, failed }) };
|
|
||||||
}
|
|
||||||
const success = simulate(true);
|
|
||||||
success.bootstrap();
|
|
||||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
||||||
assert.equal(success.state().calls, 1);
|
|
||||||
assert.equal(success.state().activated, 1);
|
|
||||||
assert.equal(success.state().failed, null);
|
|
||||||
const missing = simulate(false);
|
|
||||||
missing.bootstrap();
|
|
||||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
||||||
assert.equal(missing.state().calls, 0, 'do not initialize partially loaded modules');
|
|
||||||
assert.match(missing.state().failed.message, /rustProxyIntegrations/);
|
|
||||||
|
|
||||||
// Exercise real activate on temporary config API errors and on persistent failure.
|
|
||||||
const activation = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
|
|
||||||
assert(activation.startsWith('async function activate(info)'));
|
|
||||||
async function configRetry(failures) {
|
|
||||||
const nodes = new Map();
|
|
||||||
const node = key => { if (!nodes.has(key)) nodes.set(key, { hidden: true, value: '' }); return nodes.get(key); };
|
|
||||||
let config = 0, fallback = 0, status = 0;
|
|
||||||
const ctx = {
|
|
||||||
$: node, authenticated: false, csrf: '', authEpoch: 0, sessionInfo: null, pendingAuthDestination: null,
|
|
||||||
window: {}, document: { documentElement: { classList: { remove() {} } } },
|
|
||||||
authMessage() {}, renderSession() {}, rustProxyRefresh: { start() {} },
|
|
||||||
loadAdminPreferences: async () => ({}),
|
|
||||||
loadConfig: async () => { config++; if (config <= failures) throw new Error('Temporary proxy failure'); },
|
|
||||||
refresh: async () => { status++; },
|
|
||||||
setTimeout: fn => { fn(); return 1; },
|
|
||||||
showStartupFailure: () => { fallback++; },
|
|
||||||
toast() { throw new Error('A transient failure must not be hidden in a toast'); },
|
|
||||||
};
|
|
||||||
runInNewContext(activation, ctx);
|
|
||||||
await ctx.activate({ username: 'owner', csrf_token: 'token' });
|
|
||||||
for (let i=0;i<4;i++) await Promise.resolve();
|
|
||||||
return { config, fallback, status };
|
|
||||||
}
|
|
||||||
assert.deepEqual(await configRetry(2), { config: 3, fallback: 0, status: 1 });
|
|
||||||
assert.deepEqual(await configRetry(3), { config: 3, fallback: 1, status: 0 });
|
|
||||||
console.log(`PASS: ${version} one build-fingerprinted JS payload, DOM/module-gated startup, retries and visible failure`);
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
// Offline simulation of direct Overview arrangement. No authenticated browser
|
|
||||||
// or Rust networking is exercised by this test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const widgets = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
|
||||||
const ids = widgets.map(w => w.id);
|
|
||||||
const hiddenDefault = widgets.filter(w => w.default_hidden).map(w => w.id);
|
|
||||||
const source = fs.readFileSync('src/admin/web/app/dashboard-inline-layout.js', 'utf8');
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
for (const id of ['overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save',
|
|
||||||
'overview-inline-cancel', 'overview-inline-hint', 'dashboard-overview-grid'])
|
|
||||||
assert(html.includes(`id="${id}"`), `missing ${id}`);
|
|
||||||
|
|
||||||
class Node {
|
|
||||||
constructor(tag = 'div', value = '', cls = '') {
|
|
||||||
this.tag = tag; this.textContent = value ?? ''; this.classes = new Set(cls.split(' ').filter(Boolean));
|
|
||||||
this.classList = { add: (...names) => names.forEach(name => this.classes.add(name)),
|
|
||||||
remove: (...names) => names.forEach(name => this.classes.delete(name)) };
|
|
||||||
this.children = []; this.listeners = new Map(); this.dataset = {};
|
|
||||||
this.attributes = {}; this.hidden = false; this.disabled = false; this.parent = null;
|
|
||||||
}
|
|
||||||
append(...children) {
|
|
||||||
for (const child of children) {
|
|
||||||
child.remove(); this.children.push(child); child.parent = this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
replaceChildren(...children) {
|
|
||||||
for (const child of this.children) child.parent = null;
|
|
||||||
this.children = [];
|
|
||||||
this.append(...children);
|
|
||||||
}
|
|
||||||
remove() {
|
|
||||||
if (this.parent) { const at = this.parent.children.indexOf(this); if (at >= 0) this.parent.children.splice(at, 1); }
|
|
||||||
this.parent = null;
|
|
||||||
}
|
|
||||||
closest(selector) {
|
|
||||||
for (let cur = this; cur; cur = cur.parent) {
|
|
||||||
if (selector === '[data-dashboard-widget]' && cur.dataset.dashboardWidget) return cur;
|
|
||||||
if (selector === '[data-overview-drag-handle]' && cur.dataset.overviewDragHandle) return cur;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
contains(other) { return !!other && (other === this || this.children.some(child => child.contains(other))); }
|
|
||||||
querySelector(selector) {
|
|
||||||
for (const child of this.children) {
|
|
||||||
if (child.classes.has(selector.slice(1)) || child.tag === selector) return child;
|
|
||||||
const nested = child.querySelector(selector);
|
|
||||||
if (nested) return nested;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
|
||||||
getBoundingClientRect() { return { left: 0, width: 100 }; }
|
|
||||||
addEventListener(name, cb) { this.listeners.set(name, [...(this.listeners.get(name) || []), cb]); }
|
|
||||||
fire(name, event = {}) {
|
|
||||||
const e = { target: this, clientX: 0, prevented: false, preventDefault() { this.prevented = true; }, ...event };
|
|
||||||
for (const cb of this.listeners.get(name) || []) cb(e);
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
focus() { this.focused = true; }
|
|
||||||
}
|
|
||||||
const controls = new Map(['dashboard-overview-grid', 'overview-layout-unlock',
|
|
||||||
'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel',
|
|
||||||
'overview-inline-hint', 'overview-inline-hidden', 'overview-inline-hidden-list',
|
|
||||||
'overview-inline-hidden-count', 'overview-layout-open', 'overview-layout-dialog'].map(id => [id, new Node()]));
|
|
||||||
const grid = controls.get('dashboard-overview-grid');
|
|
||||||
controls.get('overview-inline-hidden').append(new Node('summary', 'Hidden blocks'));
|
|
||||||
const cards = new Map(ids.map(id => {
|
|
||||||
const node = new Node('article'); node.dataset.dashboardWidget = id;
|
|
||||||
node.hidden = hiddenDefault.includes(id);
|
|
||||||
node.setAttribute('id', `real-${id}`);
|
|
||||||
node.append(new Node('strong', id));
|
|
||||||
grid.append(node);
|
|
||||||
return [id, node];
|
|
||||||
}));
|
|
||||||
let current = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault], theme: 'light' };
|
|
||||||
const writes = [], notifications = [];
|
|
||||||
let reject = false;
|
|
||||||
const window = {
|
|
||||||
rustProxyPreferences: {
|
|
||||||
load: async () => current,
|
|
||||||
current: () => current,
|
|
||||||
update: async patch => {
|
|
||||||
if (reject) throw Error('server refused');
|
|
||||||
writes.push(patch);
|
|
||||||
current = { ...current, ...patch };
|
|
||||||
applyDashboardLayout(current.dashboard_widgets, current.dashboard_hidden_widgets);
|
|
||||||
return current;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
function applyDashboardLayout(order, hidden) {
|
|
||||||
for (const id of order) { cards.get(id).hidden = hidden.includes(id); grid.append(cards.get(id)); }
|
|
||||||
}
|
|
||||||
const $ = id => controls.get(id);
|
|
||||||
const ctx = {
|
|
||||||
window, $, el: (tag, value, cls) => new Node(tag, value, cls),
|
|
||||||
t: key => `loc:${key}`, toast: msg => notifications.push(msg),
|
|
||||||
dashboardWidgetLabel: id => `label:${id}`,
|
|
||||||
dashboardWidgetTypeBadge: id => new Node('span', `kind:${widgets.find(w => w.id === id).display_type}`, 'overview-widget-type'),
|
|
||||||
applyDashboardLayout,
|
|
||||||
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault] },
|
|
||||||
normalizeDashboardWidgets: order => [...new Set([...(Array.isArray(order) ? order.filter(id => ids.includes(id)) : []), ...ids])],
|
|
||||||
normalizeHiddenDashboardWidgets: hidden => [...new Set((hidden || []).filter(id => ids.includes(id)))],
|
|
||||||
};
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(source, ctx);
|
|
||||||
const editor = window.rustProxyInlineLayout;
|
|
||||||
const refresh = () => new Promise(resolve => setImmediate(resolve));
|
|
||||||
const visible = () => grid.children.filter(node => !node.hidden).map(node => node.dataset.dashboardWidget);
|
|
||||||
const toolbarCount = () => grid.children.filter(node => node.querySelector('.overview-inline-toolbar')).length;
|
|
||||||
const button = (id, cls) => cards.get(id).querySelector(cls);
|
|
||||||
const grip = id => button(id, '.overview-inline-grip');
|
|
||||||
const dataTransfer = () => ({ setData(k, value) { this[k] = value; }, effectAllowed: '', dropEffect: '' });
|
|
||||||
|
|
||||||
// Opening does not clone cards or trigger network writes. Only the handle drags.
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
assert(editor.isEditing());
|
|
||||||
assert.equal(controls.get('overview-inline-actions').hidden, false);
|
|
||||||
assert.equal(toolbarCount(), 62);
|
|
||||||
assert.equal(cards.get('requests').attributes.id, 'real-requests');
|
|
||||||
assert.equal(grip('requests').dataset.overviewDragHandle, 'requests');
|
|
||||||
const accidental = grid.fire('dragstart', { target: cards.get('requests'), dataTransfer: dataTransfer() });
|
|
||||||
assert(accidental.prevented);
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
// Hide removes the real block from the current view, not the registry; restore
|
|
||||||
// works immediately without an API call, and Cancel reverts an uncommitted hide.
|
|
||||||
button('requests', '.overview-inline-hide').fire('click');
|
|
||||||
assert(cards.get('requests').hidden);
|
|
||||||
assert(controls.get('overview-inline-hidden').open);
|
|
||||||
assert.equal(controls.get('overview-inline-hidden').hidden, false);
|
|
||||||
assert(controls.get('overview-inline-hidden-list').children.length > hiddenDefault.length);
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
controls.get('overview-inline-hidden-list').children
|
|
||||||
.find(item => item.children.some(child => child.textContent === 'label:requests'))
|
|
||||||
.querySelector('.overview-inline-restore').fire('click');
|
|
||||||
assert(!cards.get('requests').hidden, 'restore makes the block visible in the same draft');
|
|
||||||
button('requests', '.overview-inline-hide').fire('click');
|
|
||||||
assert(cards.get('requests').hidden);
|
|
||||||
controls.get('overview-inline-cancel').fire('click');
|
|
||||||
assert(!cards.get('requests').hidden, 'cancel restores the original visibility');
|
|
||||||
assert.equal(controls.get('overview-inline-hidden').hidden, true);
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
const transfer = dataTransfer();
|
|
||||||
grid.fire('dragstart', { target: grip('requests'), dataTransfer: transfer });
|
|
||||||
assert.equal(transfer['text/plain'], 'requests');
|
|
||||||
grid.fire('dragover', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
|
|
||||||
grid.fire('drop', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
|
|
||||||
assert.deepEqual(visible().slice(0, 3), ['active','requests','bandwidth']);
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
button('requests', '.overview-inline-up').fire('click');
|
|
||||||
assert.deepEqual(visible().slice(0, 3), ['requests','active','bandwidth']);
|
|
||||||
button('traffic_chart', '.overview-inline-up').fire('click');
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
controls.get('overview-inline-cancel').fire('click');
|
|
||||||
assert(!editor.isEditing());
|
|
||||||
assert.deepEqual(visible(), ids.filter(id => !hiddenDefault.includes(id)));
|
|
||||||
assert.equal(toolbarCount(), 0);
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
|
|
||||||
// Saving writes exactly one ordered/hidden patch and retains other preferences.
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
grid.fire('dragstart', { target: grip('requests'), dataTransfer: dataTransfer() });
|
|
||||||
grid.fire('drop', { target: cards.get('active'), clientX: 90 });
|
|
||||||
reject = true;
|
|
||||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
|
||||||
assert(editor.isEditing(), 'failed PUT preserves the draft');
|
|
||||||
assert.equal(controls.get('overview-inline-hint').textContent, 'server refused');
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
reject = false;
|
|
||||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
|
||||||
assert(!editor.isEditing());
|
|
||||||
assert.equal(toolbarCount(), 0);
|
|
||||||
assert.equal(writes.length, 1);
|
|
||||||
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
|
|
||||||
assert.deepEqual([...current.dashboard_hidden_widgets], hiddenDefault);
|
|
||||||
assert.equal(current.theme, 'light');
|
|
||||||
assert.equal(notifications.length, 1);
|
|
||||||
|
|
||||||
// Locking without changes does not write again. Navigation discard and dialog guard.
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
assert(!editor.isEditing());
|
|
||||||
assert.equal(writes.length, 1);
|
|
||||||
controls.get('overview-layout-dialog').open = true;
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
assert(!editor.isEditing(), 'do not edit actual cards underneath the open modal');
|
|
||||||
controls.get('overview-layout-dialog').open = false;
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
|
|
||||||
grid.fire('drop', { target: cards.get('requests'), clientX: 0 });
|
|
||||||
assert.notDeepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
|
|
||||||
editor.cancel();
|
|
||||||
assert.deepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
|
|
||||||
assert.equal(writes.length, 1);
|
|
||||||
console.log('PASS: direct Overview unlock, handles, move arrows, cancel, one save, retry, lock, navigation and modal guard');
|
|
||||||
|
|
||||||
// Hide persists across unlock sessions and the list allows recovery without
|
|
||||||
// touching unrelated account settings. The same API path handles a large panel.
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
button('backend_table', '.overview-inline-hide').fire('click');
|
|
||||||
assert(cards.get('backend_table').hidden);
|
|
||||||
assert.equal(writes.length, 1);
|
|
||||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
|
||||||
assert.equal(writes.length, 2);
|
|
||||||
assert(current.dashboard_hidden_widgets.includes('backend_table'));
|
|
||||||
assert(cards.get('backend_table').hidden);
|
|
||||||
assert.equal(current.theme, 'light');
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
controls.get('overview-inline-hidden-list').children
|
|
||||||
.find(item => item.children.some(child => child.textContent === 'label:backend_table'))
|
|
||||||
.querySelector('.overview-inline-restore').fire('click');
|
|
||||||
assert(!cards.get('backend_table').hidden);
|
|
||||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
|
||||||
assert.equal(writes.length, 3);
|
|
||||||
assert(!current.dashboard_hidden_widgets.includes('backend_table'));
|
|
||||||
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
|
|
||||||
console.log('PASS: inline hide, unsaved cancel, hidden shelf restore and one-request persistence');
|
|
||||||
|
|
||||||
// In the real browser the row controller groups cards by footprint. A cross-
|
|
||||||
// group drop should not appear to move a card into an incompatible-height row.
|
|
||||||
window.rustProxyMasonry = {groupFor: id => id === 'traffic_chart' ? 1 : 0};
|
|
||||||
const beforeGroups = [...current.dashboard_widgets];
|
|
||||||
controls.get('overview-layout-unlock').fire('click'); await refresh();
|
|
||||||
const beforeCrossDrop = visible();
|
|
||||||
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
|
|
||||||
grid.fire('drop', { target: cards.get('requests'), clientX: 50 });
|
|
||||||
assert.deepEqual(visible(), beforeCrossDrop, 'different-size cards must not switch groups');
|
|
||||||
controls.get('overview-inline-save').fire('click'); await refresh();
|
|
||||||
assert.equal(writes.length, 3, 'blocked cross-group move must not write');
|
|
||||||
assert.deepEqual([...current.dashboard_widgets], beforeGroups);
|
|
||||||
console.log('PASS: cross-size drops cannot break symmetric grid groups');
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
// Browser-independent UI simulation of the actual visual Overview modal.
|
|
||||||
// This does not replace an authenticated DOM/browser acceptance test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
|
||||||
const ids = registry.map(widget => widget.id);
|
|
||||||
const initialHidden = registry.filter(widget => widget.default_hidden).map(widget => widget.id);
|
|
||||||
const source = fs.readFileSync('src/admin/web/app/dashboard-layout-editor.js', 'utf8');
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
for (const id of ['overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel'])
|
|
||||||
assert(html.includes(`id="${id}"`), id);
|
|
||||||
|
|
||||||
class FakeNode {
|
|
||||||
constructor(tag = 'div', content = '', classes = '') {
|
|
||||||
this.tag = tag; this.textContent = String(content ?? ''); this.classes = new Set(classes.split(/\s+/).filter(Boolean));
|
|
||||||
this.classList = { add: (...args) => args.forEach(x => this.classes.add(x)), remove: (...args) => args.forEach(x => this.classes.delete(x)) };
|
|
||||||
this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = new Map();
|
|
||||||
this.hidden = false; this.open = false; this.disabled = false; this.value = ''; this.draggable = false;
|
|
||||||
}
|
|
||||||
append(...nodes) { this.children.push(...nodes); }
|
|
||||||
replaceChildren(...nodes) { this.children = nodes; }
|
|
||||||
addEventListener(name, cb) { const list = this.listeners.get(name) || []; list.push(cb); this.listeners.set(name, list); }
|
|
||||||
fire(name, event = {}) { for (const cb of this.listeners.get(name) || []) cb({ preventDefault() {}, target: this, ...event }); }
|
|
||||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
||||||
removeAttribute(k) { delete this.attrs[k]; }
|
|
||||||
focus() { this.focused = true; }
|
|
||||||
showModal() { this.open = true; }
|
|
||||||
close() { this.open = false; this.fire('close'); }
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
const nodes = [];
|
|
||||||
function visit(node) {
|
|
||||||
for (const child of node.children) {
|
|
||||||
if (selector === '[id]' && child.attrs.id) nodes.push(child);
|
|
||||||
else if (selector === '[aria-labelledby], [aria-describedby]' && (child.attrs['aria-labelledby'] || child.attrs['aria-describedby'])) nodes.push(child);
|
|
||||||
else if (selector === '.overview-layout-tile' && child.classes.has('overview-layout-tile')) nodes.push(child);
|
|
||||||
visit(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visit(this); return nodes;
|
|
||||||
}
|
|
||||||
querySelector(selector) {
|
|
||||||
return this.querySelectorAll('.overview-layout-tile').find(node => node.classes.has(selector.slice(1))) ||
|
|
||||||
(() => { const visited = [this]; while (visited.length) { const node = visited.shift(); if (node.classes.has(selector.slice(1))) return node; visited.push(...node.children); } return null; })();
|
|
||||||
}
|
|
||||||
cloneNode(deep = false) {
|
|
||||||
const copy = new FakeNode(this.tag, this.textContent, [...this.classes].join(' '));
|
|
||||||
copy.hidden = this.hidden; copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
|
|
||||||
if (deep) copy.children = this.children.map(node => node.cloneNode(true));
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const controls = Object.fromEntries([
|
|
||||||
'overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden',
|
|
||||||
'overview-layout-save', 'overview-layout-cancel', 'overview-layout-search', 'overview-layout-type-filter',
|
|
||||||
'overview-layout-message', 'overview-layout-open', 'overview-layout-open-settings',
|
|
||||||
'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel',
|
|
||||||
].map(id => [id, new FakeNode()]));
|
|
||||||
const originals = new Map(ids.map(id => {
|
|
||||||
const node = new FakeNode('article', 'metric value', 'stat');
|
|
||||||
node.attrs.id = `real-${id}`; node.dataset.dashboardWidget = id;
|
|
||||||
node.append(new FakeNode('strong', '42'));
|
|
||||||
node.children[0].attrs.id = `metric-${id}`;
|
|
||||||
node.hidden = initialHidden.includes(id);
|
|
||||||
return [id, node];
|
|
||||||
}));
|
|
||||||
let saved = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden], theme: 'light' };
|
|
||||||
const writes = [], notifications = [];
|
|
||||||
let failSave = false;
|
|
||||||
const window = { rustProxyPreferences: {
|
|
||||||
current: () => saved,
|
|
||||||
load: async () => saved,
|
|
||||||
update: async patch => {
|
|
||||||
if (failSave) throw Error('server refused');
|
|
||||||
writes.push(patch); saved = { ...saved, ...patch };
|
|
||||||
for (const id of ids) originals.get(id).hidden = patch.dashboard_hidden_widgets.includes(id);
|
|
||||||
return saved;
|
|
||||||
},
|
|
||||||
} };
|
|
||||||
const $ = id => controls[id];
|
|
||||||
const ctx = {
|
|
||||||
window, $, t: text => `loc:${text}`, toast: text => notifications.push(text),
|
|
||||||
el: (tag, content, cls) => new FakeNode(tag, content, cls),
|
|
||||||
ADMIN_DASHBOARD_BY_ID: new Map(registry.map(widget => [widget.id, widget])),
|
|
||||||
ADMIN_DASHBOARD_WIDGETS: [...ids],
|
|
||||||
ADMIN_DEFAULT_HIDDEN_WIDGETS: [...initialHidden],
|
|
||||||
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden] },
|
|
||||||
normalizeDashboardWidgets: value => [...new Set([...(Array.isArray(value) ? value.filter(id => ids.includes(id)) : []), ...ids])],
|
|
||||||
normalizeHiddenDashboardWidgets: value => [...new Set((Array.isArray(value) ? value : []).filter(id => ids.includes(id)))],
|
|
||||||
dashboardWidgetType: id => registry.find(widget => widget.id === id)?.display_type,
|
|
||||||
dashboardWidgetTypeLabel: id => `loc:overviewBlockType${({
|
|
||||||
value: 'Value', value_chart: 'ValueChart', chart: 'Chart', panel: 'Panel', table: 'Table',
|
|
||||||
})[registry.find(widget => widget.id === id)?.display_type]}`,
|
|
||||||
dashboardWidgetTypeBadge: id => new FakeNode('span', `type:${registry.find(widget => widget.id === id)?.display_type}`, 'overview-widget-type'),
|
|
||||||
document: { querySelector: selector => originals.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
|
|
||||||
};
|
|
||||||
vm.createContext(ctx); vm.runInContext(source, ctx);
|
|
||||||
const module = window.rustProxyDashboardLayout;
|
|
||||||
const dialog = controls['overview-layout-dialog'];
|
|
||||||
const visible = controls['overview-layout-visible'];
|
|
||||||
const hidden = controls['overview-layout-hidden'];
|
|
||||||
const refresh = () => new Promise(resolve => setImmediate(resolve));
|
|
||||||
const previewIds = () => visible.children.filter(node => node.dataset.previewWidget).map(node => node.dataset.previewWidget);
|
|
||||||
|
|
||||||
// Cancel changes, no PUT or change of real dashboard.
|
|
||||||
controls['overview-layout-open'].fire('click'); await refresh();
|
|
||||||
assert.equal(dialog.open, true);
|
|
||||||
assert.equal(previewIds().length, ids.length - initialHidden.length);
|
|
||||||
assert.equal(hidden.children.length, initialHidden.length);
|
|
||||||
assert.equal(visible.children[0].children[0].children[1].textContent, 'type:value');
|
|
||||||
assert.equal(hidden.children[0].children[1].textContent, `type:${registry.find(w => w.id === initialHidden[0]).display_type}`);
|
|
||||||
controls['overview-layout-type-filter'].value = 'chart';
|
|
||||||
controls['overview-layout-type-filter'].fire('change');
|
|
||||||
assert.equal(hidden.children.filter(item => item.classes.has('overview-layout-hidden-item')).length, registry.filter(w => w.default_hidden && w.display_type === 'chart').length, 'type filter shows only hidden charts');
|
|
||||||
controls['overview-layout-type-filter'].value = 'all';
|
|
||||||
controls['overview-layout-type-filter'].fire('change');
|
|
||||||
assert.equal(hidden.children.length, initialHidden.length);
|
|
||||||
const previewCopy = visible.children[0].children[1];
|
|
||||||
assert.equal(previewCopy.attrs.id, undefined, 'preview clones must not duplicate real IDs');
|
|
||||||
assert.equal(previewCopy.children[0].attrs.id, undefined);
|
|
||||||
assert.equal(previewCopy.attrs['aria-hidden'], 'true');
|
|
||||||
const first = previewIds()[0];
|
|
||||||
visible.children[0].children[0].children[2].children[2].fire('click');
|
|
||||||
assert(!previewIds().includes(first));
|
|
||||||
assert.equal(originals.get(first).hidden, false, 'draft hide must not touch live cards');
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
controls['overview-layout-cancel'].fire('click');
|
|
||||||
assert.equal(dialog.open, false);
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
|
|
||||||
// Settings and Overview entry points are wired to the same editor.
|
|
||||||
controls['overview-layout-open-settings'].fire('click'); await refresh();
|
|
||||||
assert.equal(dialog.open, true);
|
|
||||||
const originalVisible = previewIds();
|
|
||||||
const from = visible.children[0], to = visible.children[2];
|
|
||||||
const dataTransfer = { setData(type, value) { this[type] = value; }, effectAllowed: '', dropEffect: '' };
|
|
||||||
from.fire('dragstart', { target: from, dataTransfer });
|
|
||||||
to.fire('dragover', { dataTransfer });
|
|
||||||
to.fire('drop', { dataTransfer });
|
|
||||||
assert.equal(previewIds()[2], originalVisible[0], 'dropping moves a tile after the drop target');
|
|
||||||
assert.equal(previewIds()[0], originalVisible[1]);
|
|
||||||
// Keyboard buttons move cards back, without rebuilding saved prefs prematurely.
|
|
||||||
visible.children[2].children[0].children[2].children[0].fire('click');
|
|
||||||
assert.equal(previewIds()[1], originalVisible[0]);
|
|
||||||
const firstHiddenId = initialHidden[0];
|
|
||||||
const hiddenItem = hidden.children.find(node => node.children[0].textContent === `loc:${registry.find(x => x.id === firstHiddenId).label_key}`);
|
|
||||||
hiddenItem.children[2].fire('click');
|
|
||||||
assert(previewIds().includes(firstHiddenId));
|
|
||||||
assert.equal(writes.length, 0);
|
|
||||||
controls['overview-layout-save'].fire('click'); await refresh();
|
|
||||||
assert.equal(writes.length, 1, 'one atomic preferences update');
|
|
||||||
assert.equal(dialog.open, false);
|
|
||||||
assert.equal(saved.theme, 'light', 'unrelated preferences are untouched');
|
|
||||||
assert.equal(new Set(saved.dashboard_widgets).size, ids.length);
|
|
||||||
assert.equal(saved.dashboard_widgets.length, ids.length);
|
|
||||||
assert(!saved.dashboard_hidden_widgets.includes(firstHiddenId));
|
|
||||||
assert.equal(notifications.length, 1);
|
|
||||||
|
|
||||||
// Failed save keeps the dialog open and the unsaved draft available for retry.
|
|
||||||
controls['overview-layout-open'].fire('click'); await refresh();
|
|
||||||
failSave = true;
|
|
||||||
const writeCount = writes.length;
|
|
||||||
controls['overview-layout-save'].fire('click'); await refresh();
|
|
||||||
assert.equal(dialog.open, true);
|
|
||||||
assert.equal(controls['overview-layout-message'].textContent, 'server refused');
|
|
||||||
assert.equal(writes.length, writeCount);
|
|
||||||
failSave = false;
|
|
||||||
controls['overview-layout-cancel'].fire('click');
|
|
||||||
assert.equal(dialog.open, false);
|
|
||||||
// Default view is only a draft until Save: no implicit writes.
|
|
||||||
controls['overview-layout-open'].fire('click'); await refresh();
|
|
||||||
const writesBeforeDefault = writes.length;
|
|
||||||
controls['overview-layout-default'].fire('click');
|
|
||||||
assert.deepEqual(previewIds(), ids.filter(id => !initialHidden.includes(id)));
|
|
||||||
assert(controls['overview-layout-message'].textContent.includes('overviewLayoutDefaultLoaded'));
|
|
||||||
assert.equal(writes.length, writesBeforeDefault);
|
|
||||||
assert(previewIds().includes('backend_table'), 'all seven full-width Overview panels belong to the editor');
|
|
||||||
assert.equal(visible.children.find(node => node.dataset.previewWidget === 'backend_table').classes.has('is-panel'), true);
|
|
||||||
// Reset first prompts for confirmation, cancellation must not change preferences.
|
|
||||||
controls['overview-layout-reset'].fire('click');
|
|
||||||
assert.equal(controls['overview-reset-confirm'].open, true);
|
|
||||||
assert.equal(writes.length, writesBeforeDefault);
|
|
||||||
controls['overview-reset-cancel'].fire('click');
|
|
||||||
assert.equal(controls['overview-reset-confirm'].open, false);
|
|
||||||
assert.equal(writes.length, writesBeforeDefault);
|
|
||||||
// A failed reset keeps the confirmation open and never claims success.
|
|
||||||
controls['overview-layout-reset'].fire('click'); failSave = true;
|
|
||||||
controls['overview-reset-apply'].fire('click'); await refresh();
|
|
||||||
assert.equal(controls['overview-reset-confirm'].open, true);
|
|
||||||
assert.equal(writes.length, writesBeforeDefault);
|
|
||||||
failSave = false;
|
|
||||||
controls['overview-reset-apply'].fire('click'); await refresh();
|
|
||||||
assert.equal(controls['overview-reset-confirm'].open, false);
|
|
||||||
assert.equal(dialog.open, false);
|
|
||||||
assert.equal(writes.length, writesBeforeDefault + 1);
|
|
||||||
assert.deepEqual([...saved.dashboard_widgets], ids);
|
|
||||||
assert.deepEqual([...saved.dashboard_hidden_widgets], initialHidden);
|
|
||||||
assert.equal(saved.theme, 'light', 'reset must not affect unrelated account preferences');
|
|
||||||
console.log('PASS: full Overview editor, drag/keyboard, legacy settings, default preview, confirmed reset, cancel, atomic save and failures');
|
|
||||||
|
|
||||||
// Group boundaries are a UX constraint in the real browser: the modal must
|
|
||||||
// not let a small tile replace the position of a tall diagnostic panel.
|
|
||||||
window.rustProxyMasonry = { groupFor: id => id === 'traffic_chart' ? 1 : id.endsWith('_diagnostics') ? 3 : 0 };
|
|
||||||
controls['overview-layout-open'].fire('click'); await refresh();
|
|
||||||
const beforeGroupMove = previewIds();
|
|
||||||
const small = visible.children.find(node => node.dataset.previewWidget === 'requests');
|
|
||||||
const big = visible.children.find(node => node.dataset.previewWidget === 'traffic_chart');
|
|
||||||
small.fire('dragstart', { target: small, dataTransfer: {setData() {},effectAllowed:''} });
|
|
||||||
big.fire('drop');
|
|
||||||
assert.deepEqual(previewIds(), beforeGroupMove, 'the modal must not mix incompatible tile groups');
|
|
||||||
controls['overview-layout-cancel'].fire('click');
|
|
||||||
console.log('PASS: visual editor respects symmetric height groups');
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Browser bootstrap contract: visible recovery and early shell.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
import { resolve, dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
||||||
const read = path => readFileSync(resolve(root, path), 'utf8');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const css = read('src/admin/web/style.css');
|
|
||||||
const core = read('src/admin/web/app/core.js');
|
|
||||||
const runtime = read('src/admin/web/app/runtime.js');
|
|
||||||
const resume = read('src/admin/web/app/session-resume.js');
|
|
||||||
|
|
||||||
assert.match(html, /<html class="app-loading"/);
|
|
||||||
assert.match(html, /id="app-startup"[^>]*role="status"/);
|
|
||||||
assert.match(html, /id="app-startup-title"/);
|
|
||||||
assert.match(html, /class="app-startup-spinner"/);
|
|
||||||
assert.match(html, /class="app-startup-recovery"/);
|
|
||||||
assert.match(html, /class="outline" id="app-startup-reload" href="" data-i18n="startupRetry"/);
|
|
||||||
assert.match(html, /<noscript>/);
|
|
||||||
assert.doesNotMatch(css, /html\.app-loading\s+body\s*\{\s*visibility:\s*hidden/);
|
|
||||||
assert.match(css, /html\.app-loading \.shell/);
|
|
||||||
assert.match(css, /app-startup-reveal 0s linear 12s forwards/);
|
|
||||||
assert.match(css, /prefers-reduced-motion: reduce/);
|
|
||||||
assert.match(runtime, /showStartupFailure\(e\)/);
|
|
||||||
|
|
||||||
const titleRow = html.match(/<div class="page-intro-title-row">([\s\S]*?)<\/div>/)?.[1];
|
|
||||||
assert(titleRow, 'title row exists');
|
|
||||||
assert.match(titleRow, /id="page-title"/);
|
|
||||||
assert.match(titleRow, /id="overview-refresh"[^>]*hidden/);
|
|
||||||
assert.equal((html.match(/id="overview-refresh"/g) || []).length, 1);
|
|
||||||
assert.match(core, /\$\("overview-refresh"\)\.hidden = name !== "overview"/);
|
|
||||||
assert.match(css, /\.page-intro-title-row\s*\{\s*display:\s*flex/);
|
|
||||||
assert.match(css, /\.page-intro-title-row \.overview-refresh/);
|
|
||||||
|
|
||||||
// Exercise the real activate() and failure handler against a small DOM fake.
|
|
||||||
const activateSource = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
|
|
||||||
const failureSource = resume.slice(resume.indexOf('function displayStartupError(error, sessionResume = false)'));
|
|
||||||
assert(activateSource.startsWith('async function activate(info)'));
|
|
||||||
assert(failureSource.startsWith('function displayStartupError(error, sessionResume = false)'));
|
|
||||||
|
|
||||||
const nodes = new Map();
|
|
||||||
const node = key => {
|
|
||||||
if (!nodes.has(key)) nodes.set(key, { hidden: true, value: 'x', textContent: '', addEventListener() {} });
|
|
||||||
return nodes.get(key);
|
|
||||||
};
|
|
||||||
const classes = new Set(['app-loading']);
|
|
||||||
let startCount = 0, configCount = 0, statusCount = 0, prefResolve, configResolve;
|
|
||||||
const preferencesPromise = new Promise(resolve => { prefResolve = resolve; });
|
|
||||||
const configPromise = new Promise(resolve => { configResolve = resolve; });
|
|
||||||
const context = {
|
|
||||||
$: node,
|
|
||||||
document: { documentElement: { classList: {
|
|
||||||
add(...keys) { for (const key of keys) classes.add(key); },
|
|
||||||
remove(...keys) { for (const key of keys) classes.delete(key); },
|
|
||||||
toggle(key, force) { if (force) classes.add(key); else classes.delete(key); },
|
|
||||||
contains(key) { return classes.has(key); },
|
|
||||||
} } },
|
|
||||||
pendingAuthDestination: null,
|
|
||||||
authMessage() {},
|
|
||||||
authEpoch: 0, authenticated: false, csrf: '', sessionInfo: null,
|
|
||||||
renderSession() {},
|
|
||||||
rustProxyRefresh: { start() { startCount++; } },
|
|
||||||
loadAdminPreferences: () => preferencesPromise,
|
|
||||||
loadConfig: () => { configCount++; return configPromise; },
|
|
||||||
refresh: async () => { statusCount++; },
|
|
||||||
toast() { throw new Error('Unexpected toast'); },
|
|
||||||
window: { rustProxyRefresh: { stop() {} } }, languagePacks: new Map(), t: key => key,
|
|
||||||
};
|
|
||||||
runInNewContext(`${activateSource}\n${failureSource}`, context);
|
|
||||||
const activating = context.activate({ username: 'admin', csrf_token: 'CSRF' });
|
|
||||||
assert.equal(startCount, 1, 'SSE/polling starts without waiting for preferences');
|
|
||||||
assert.equal(context.authenticated, true);
|
|
||||||
assert.equal(node('workspace').hidden, false, 'shell is shown immediately');
|
|
||||||
assert(!classes.has('app-loading'), 'spinner is cleared before preferences/config/status');
|
|
||||||
assert.equal(configCount, 0, 'preferences request is still pending');
|
|
||||||
assert.equal(statusCount, 0);
|
|
||||||
prefResolve({ refresh: {} });
|
|
||||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
||||||
assert.equal(configCount, 1);
|
|
||||||
assert.equal(node('workspace').hidden, false, 'slow configuration does not hide shell');
|
|
||||||
configResolve({});
|
|
||||||
await activating;
|
|
||||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
||||||
assert.equal(statusCount, 1);
|
|
||||||
context.showStartupFailure(new Error('Offline'));
|
|
||||||
assert(classes.has('app-loading') && classes.has('app-startup-failed'));
|
|
||||||
assert.equal(node('app-startup-detail').textContent, 'Offline');
|
|
||||||
assert.equal(node('app-startup-detail').hidden, false);
|
|
||||||
assert.equal(node('workspace').hidden, true, 'failed shell cannot obscure retry UI');
|
|
||||||
|
|
||||||
console.log('PASS: startup spinner/retry, shell before slow API, error recovery and compact Overview title row.');
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Check the 0.31.9 dependency/source migration; optionally require a resolved lock.
|
|
||||||
|
|
||||||
This is not a substitute for rustc, cargo metadata or runtime tests.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
|
||||||
args = parser.parse_args()
|
|
||||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
|
||||||
assert manifest['package']['version'] == '0.32.7'
|
|
||||||
assert manifest['package']['rust-version'] == '1.99'
|
|
||||||
deps = manifest['dependencies']
|
|
||||||
expected_requirements = {
|
|
||||||
'argon2': '0.6', 'rand': '0.10', 'sqlx': '0.9', 'rcgen': '0.14.10',
|
|
||||||
'tower-http': '0.7.1', 'brotli': '9', 'sysinfo': '0.39',
|
|
||||||
'h3': '=2.5.20', 'tokio': '1.53.2', 'hyper': '1.12.0',
|
|
||||||
}
|
|
||||||
for name, version in expected_requirements.items():
|
|
||||||
entry = deps[name]
|
|
||||||
found = entry if isinstance(entry, str) else entry['version']
|
|
||||||
assert found == version, f'{name} requirement changed: {found!r} != {version!r}'
|
|
||||||
assert deps['h3']['package'] == 'hpx-h3'
|
|
||||||
assert deps['h3']['features'] == ['quinn']
|
|
||||||
assert deps['sqlx']['default-features'] is False
|
|
||||||
assert deps['sqlx']['features'] == ['runtime-tokio', 'sqlite']
|
|
||||||
assert set(deps['sysinfo']['features']) == {'network','system'}
|
|
||||||
assert set(deps['rcgen']['features']) == {'crypto', 'pem', 'ring'}
|
|
||||||
assert 'tls12' in deps['rustls']['features'] and 'ring' in deps['rustls']['features']
|
|
||||||
assert 'tls12' in deps['tokio-rustls']['features']
|
|
||||||
for target in ('Dockerfile', 'integrations/routeros-oci-build/Dockerfile.routeros'):
|
|
||||||
assert 'FROM rust:1.99-trixie AS build' in (ROOT / target).read_text(), target
|
|
||||||
files = [p for p in (ROOT / 'src').rglob('*.rs')]
|
|
||||||
sources = '\n'.join(p.read_text() for p in files)
|
|
||||||
for old in ('rngs::OsRng', 'rand::thread_rng()', 'SaltString::generate(',
|
|
||||||
'distributions::Alphanumeric', '.key_pair.serialize_pem()',
|
|
||||||
'hash_password(password.as_bytes(), &salt)'):
|
|
||||||
assert old not in sources, f'obsolete dependency API: {old}'
|
|
||||||
for new in ('SysRng.try_fill_bytes(', 'phc::PasswordHash',
|
|
||||||
'hash_password(password.as_bytes())', 'verify_password(password.as_bytes()',
|
|
||||||
'signing_key.serialize_pem()', 'rand::rng()'):
|
|
||||||
assert new in sources, f'missing dependency migration: {new}'
|
|
||||||
assert 'VERSION = "5.33.1"' in (ROOT / 'scripts/build/swagger_assets.py').read_text()
|
|
||||||
lock = tomllib.loads((ROOT / 'Cargo.lock').read_text())
|
|
||||||
installed: dict[str,set[str]] = {}
|
|
||||||
for item in lock['package']:
|
|
||||||
installed.setdefault(item['name'], set()).add(item['version'])
|
|
||||||
latest_required = {
|
|
||||||
'argon2': '0.6.0', 'rand': '0.10.3', 'sqlx': '0.9.0',
|
|
||||||
'rcgen': '0.14.10', 'tower-http': '0.7.1', 'brotli': '9.0.0',
|
|
||||||
'sysinfo': '0.39.6', 'hpx-h3': '2.5.20', 'hyper': '1.12.0',
|
|
||||||
'tokio': '1.53.2',
|
|
||||||
}
|
|
||||||
def compatible_update(package: str, installed_version: str, minimum: str) -> bool:
|
|
||||||
candidate = tuple(int(x) for x in installed_version.split('.')[:3])
|
|
||||||
baseline = tuple(int(x) for x in minimum.split('.')[:3])
|
|
||||||
if package == 'hpx-h3':
|
|
||||||
return candidate == baseline # Intentionally exact-pinned for the QUIC integration.
|
|
||||||
if candidate[0] != baseline[0]:
|
|
||||||
return False
|
|
||||||
if baseline[0] == 0 and candidate[1] != baseline[1]:
|
|
||||||
return False
|
|
||||||
return candidate >= baseline
|
|
||||||
|
|
||||||
missing = {name: version for name,version in latest_required.items()
|
|
||||||
if not any(compatible_update(name, candidate, version)
|
|
||||||
for candidate in installed.get(name, set()))}
|
|
||||||
if missing:
|
|
||||||
details = ', '.join(f'{name} {version}' for name,version in missing.items())
|
|
||||||
if args.require_lock:
|
|
||||||
raise SystemExit(f'FAIL: Cargo.lock not resolved to current deps: {details}. Run cargo update first.')
|
|
||||||
print(f'PENDING: lockfile still requires cargo update ({details})')
|
|
||||||
else:
|
|
||||||
print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required')
|
|
||||||
print('PASS: 0.31.9 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Runs the real Docker VHosts dashboard module against a minimal DOM + API fixture.
|
|
||||||
// This is a JS-only regression, not a compiled RustProxy or live agent test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const dockerSource = readFileSync('src/admin/web/docker.js', 'utf8');
|
|
||||||
const coreSource = readFileSync('src/admin/web/app/core.js', 'utf8');
|
|
||||||
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
|
|
||||||
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
|
||||||
for (const translations of [english, polish]) {
|
|
||||||
assert.ok(translations.dockerNoObservation);
|
|
||||||
assert.ok(translations.dockerAgentConnectionFailed);
|
|
||||||
assert.ok(translations.dockerUnknownPollError);
|
|
||||||
assert.doesNotMatch(translations.dockerNoObservation.toLowerCase(), /refresh|odśwież/);
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeNode {
|
|
||||||
constructor(tag, text = '', cls = '') {
|
|
||||||
this.tagName = tag;
|
|
||||||
this.content = String(text ?? '');
|
|
||||||
this.className = cls;
|
|
||||||
this.children = [];
|
|
||||||
this.dataset = {};
|
|
||||||
this.attrs = {};
|
|
||||||
this.events = {};
|
|
||||||
this.hidden = false;
|
|
||||||
this.open = false;
|
|
||||||
this.classList = {
|
|
||||||
toggle: (name, value) => {
|
|
||||||
const parts = new Set(this.className.split(' ').filter(Boolean));
|
|
||||||
if (value) parts.add(name); else parts.delete(name);
|
|
||||||
this.className = [...parts].join(' ');
|
|
||||||
},
|
|
||||||
add: name => { this.className += ` ${name}`; },
|
|
||||||
remove: name => { this.className = this.className.split(' ').filter(part => part !== name).join(' '); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
get textContent() { return this.content + this.children.map(child => child.textContent || '').join(''); }
|
|
||||||
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
|
|
||||||
append(...nodes) { this.children.push(...nodes.filter(Boolean)); }
|
|
||||||
replaceChildren(...nodes) { this.children = nodes.filter(Boolean); }
|
|
||||||
setAttribute(name, value) { this.attrs[name] = value; }
|
|
||||||
addEventListener(name, fn) { this.events[name] = fn; }
|
|
||||||
descendants() { return this.children.flatMap(child => [child, ...child.descendants()]); }
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
const part = selector.split(' ').at(-1);
|
|
||||||
if (part.startsWith('.')) return this.descendants().filter(child => child.className.split(' ').includes(part.slice(1)));
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
|
||||||
}
|
|
||||||
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
|
|
||||||
const destination = node('div');
|
|
||||||
const elements = new Map([
|
|
||||||
['docker-discovery-list', destination],
|
|
||||||
['overview-refresh', node('button')],
|
|
||||||
['page-label', node('span')],
|
|
||||||
['page-title', node('h1')],
|
|
||||||
['page-description', node('p')]
|
|
||||||
]);
|
|
||||||
const agent = {
|
|
||||||
id: 'docker1', name: 'Docker One', enabled: true,
|
|
||||||
base_domain: 'apps.example.test', endpoint: 'https://192.0.2.2:9876',
|
|
||||||
tls_fingerprint_sha256: 'A'.repeat(64), poll_interval_seconds: 30,
|
|
||||||
assignments: []
|
|
||||||
};
|
|
||||||
const config = { docker_agents: { agents: [agent] }, listeners: { endpoints: [] }, tls: { certificates: [] } };
|
|
||||||
const storage = new Map();
|
|
||||||
const requests = [];
|
|
||||||
let nextResult = { observations: [], pools: [], pool_routes: [] };
|
|
||||||
let refreshSpec;
|
|
||||||
let syncs = 0, manuals = 0;
|
|
||||||
const coordinator = {
|
|
||||||
register(id, spec) { assert.equal(id, 'docker'); refreshSpec = spec; },
|
|
||||||
sync() { syncs++; },
|
|
||||||
manual(id) { assert.equal(id, 'docker'); manuals++; return Promise.resolve(); },
|
|
||||||
setPanel(name) { this.panel = name; },
|
|
||||||
};
|
|
||||||
const ctx = {
|
|
||||||
el: node,
|
|
||||||
$: name => elements.get(name) || null,
|
|
||||||
t: name => english[name] || name,
|
|
||||||
document: {
|
|
||||||
activeElement: null,
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
if (selector === '#docker-discovery-list .docker-agent-card') return destination.children;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
window: { rustProxyRefresh: coordinator, rustProxySettings: {} },
|
|
||||||
rustProxyRefresh: coordinator,
|
|
||||||
localStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value) },
|
|
||||||
authenticated: true, authEpoch: 1,
|
|
||||||
currentPanel: 'docker-vhosts', envelope: { config },
|
|
||||||
settingsDirty: new Set(), busy: false,
|
|
||||||
api: async (path, args = {}) => { requests.push({ path, args }); return nextResult; },
|
|
||||||
toast(message) { throw new Error(`Unexpected toast: ${message}`); },
|
|
||||||
console,
|
|
||||||
};
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(dockerSource, ctx, { filename: 'src/admin/web/docker.js' });
|
|
||||||
assert(refreshSpec, 'register Docker refresh with central scheduler');
|
|
||||||
assert.equal(refreshSpec.interval(), 5);
|
|
||||||
ctx.window.rustProxyDocker.render();
|
|
||||||
assert.equal(manuals, 1, 'deep-link bootstrap fetches a snapshot after configuration loads');
|
|
||||||
assert.equal(syncs, 1, 'deep-link bootstrap syncs waiting-state scheduler');
|
|
||||||
assert.equal(refreshSpec.active(), true, 'bootstrap retries cached status without requiring local Live view');
|
|
||||||
assert.match(destination.textContent, /WAITING/);
|
|
||||||
assert.match(destination.textContent, /Waiting for the first agent check/);
|
|
||||||
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
|
|
||||||
assert.doesNotMatch(destination.textContent, /Agent connection failed/);
|
|
||||||
assert.equal(destination.querySelector('.docker-agent-error'), null, 'pending must not be styled as a failure');
|
|
||||||
let tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
|
|
||||||
assert.ok(tlsPill);
|
|
||||||
assert.ok(!tlsPill.className.split(' ').includes('live'), 'configured fingerprint does not prove successful handshake');
|
|
||||||
|
|
||||||
await ctx.window.rustProxyDocker.refresh(false);
|
|
||||||
assert.equal(requests.at(-1).path, '/api/docker');
|
|
||||||
assert.notEqual(requests.at(-1).args.method, 'POST', 'automatic status fetch never polls the agent');
|
|
||||||
assert.equal(refreshSpec.active(), true, 'retry while the initial server-side poll is not complete');
|
|
||||||
|
|
||||||
nextResult = { observations: [{ agent_id: 'docker1', ok: false, error: 'connection refused', checked_at_unix_ms: 1, inventory: null }], pools: [], pool_routes: [] };
|
|
||||||
await ctx.window.rustProxyDocker.refresh(false);
|
|
||||||
assert.match(destination.textContent, /Last Docker agent check failed/);
|
|
||||||
assert.match(destination.textContent, /connection refused/);
|
|
||||||
assert.doesNotMatch(destination.textContent, /TLS certificate SHA-256:/, 'pinned fingerprint is configuration, not the reason for an arbitrary failure');
|
|
||||||
assert.ok(destination.querySelector('.docker-agent-error'), 'a genuine failed observation must be visible');
|
|
||||||
assert.equal(refreshSpec.active(), false, 'local Live-off stops automatic refresh after first status, even if it is a failure');
|
|
||||||
|
|
||||||
nextResult = { observations: [{ agent_id: 'docker1', ok: true, error: null, checked_at_unix_ms: Date.now(), inventory: {
|
|
||||||
agent_version: '0.22.3', hostname: 'docker1', addresses: [], services: [], public_ip: '192.0.2.2', certificate_sha256: 'A'.repeat(64)
|
|
||||||
} }], pools: [], pool_routes: [] };
|
|
||||||
await ctx.window.rustProxyDocker.refresh(false);
|
|
||||||
assert.match(destination.textContent, /CONNECTED/);
|
|
||||||
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
|
|
||||||
assert.equal(refreshSpec.active(), false, 'local Live-off remains off with a successful observation');
|
|
||||||
tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
|
|
||||||
assert.ok(tlsPill.className.split(' ').includes('live'), 'pin may be marked verified after a successful poll');
|
|
||||||
storage.set('rustproxy.docker.live-view', '1');
|
|
||||||
assert.equal(refreshSpec.active(), true, 'Live view still polls normally');
|
|
||||||
storage.delete('rustproxy.docker.live-view');
|
|
||||||
|
|
||||||
// Execute the real panel-navigation function with side effects disabled.
|
|
||||||
const from = coreSource.indexOf('function setPanel(name, navigate = true) {');
|
|
||||||
const to = coreSource.indexOf('function bytes(value)', from);
|
|
||||||
assert(from >= 0 && to > from);
|
|
||||||
ctx.PANEL_PATHS = { overview: '/', 'docker-vhosts': '/docker-vhosts' };
|
|
||||||
vm.runInContext(coreSource.slice(from, to), ctx);
|
|
||||||
ctx.currentPanel = 'overview';
|
|
||||||
ctx.setPanel('docker-vhosts', false);
|
|
||||||
assert.equal(manuals, 2, 'opening Docker VHosts requests a new cached snapshot even with Live view off');
|
|
||||||
ctx.setPanel('docker-vhosts', false);
|
|
||||||
assert.equal(manuals, 2, 'rendering the same panel does not duplicate one-shot requests');
|
|
||||||
ctx.setPanel('overview', false);
|
|
||||||
ctx.setPanel('docker-vhosts', false);
|
|
||||||
assert.equal(manuals, 3, 're-entering the page reads the newest cached status');
|
|
||||||
|
|
||||||
ctx.currentPanel = 'overview';
|
|
||||||
assert.equal(refreshSpec.active(), false, 'no bootstrap activity outside Docker VHosts');
|
|
||||||
ctx.currentPanel = 'docker-vhosts';
|
|
||||||
config.docker_agents.agents = [{ ...agent, enabled: false }];
|
|
||||||
ctx.window.rustProxyDocker.render();
|
|
||||||
assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling');
|
|
||||||
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
|
|
||||||
console.log('PASS: 0.31.9 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.9 release."""
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
from rust_source import rust_source
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
DOCS = ROOT / "docs"
|
|
||||||
|
|
||||||
required = [
|
|
||||||
ROOT / "README.md",
|
|
||||||
ROOT / "ROADMAP.md",
|
|
||||||
ROOT / "CHANGELOG.md",
|
|
||||||
DOCS / "README.md",
|
|
||||||
DOCS / "WEB_SERVER.md",
|
|
||||||
DOCS / "TODO.md",
|
|
||||||
DOCS / "TEST_REPORT.md",
|
|
||||||
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
|
|
||||||
DOCS / "ROADMAP_HISTORY.md",
|
|
||||||
]
|
|
||||||
for path in required:
|
|
||||||
assert path.is_file(), f"missing required documentation: {path.relative_to(ROOT)}"
|
|
||||||
|
|
||||||
index = (DOCS / "README.md").read_text(encoding="utf-8")
|
|
||||||
for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "SECURITY_AUDIT_0.31.0.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]:
|
|
||||||
assert marker in index, marker
|
|
||||||
for document in sorted(DOCS.glob("*.md")):
|
|
||||||
if document.name == "README.md":
|
|
||||||
continue
|
|
||||||
assert document.name in index, f"documentation index does not classify {document.name}"
|
|
||||||
|
|
||||||
reader_html = (ROOT / "src/admin/web/docs.html").read_text(encoding="utf-8")
|
|
||||||
reader_js = (ROOT / "src/admin/web/docs.js").read_text(encoding="utf-8")
|
|
||||||
build = (ROOT / "build.rs").read_text(encoding="utf-8")
|
|
||||||
admin = rust_source(ROOT, "src/admin/mod.rs")
|
|
||||||
for marker in ["docs-tab-reader", "docs-reader-list", "docs-reader-content", "docs-tab-api"]:
|
|
||||||
assert marker in reader_html, marker
|
|
||||||
for marker in ["renderMarkdown", "selectDocument", 'get("/docs/content.json")', "docsByPath", "docsCategoryHistorical"]:
|
|
||||||
assert marker in reader_js, marker
|
|
||||||
documentation_rs = rust_source(ROOT, "src/admin/documentation.rs") + (ROOT / "src/admin/documentation/documents.rs").read_text(encoding="utf-8")
|
|
||||||
assert "READER_DOCUMENTS" in documentation_rs and "reader_bundle" in documentation_rs
|
|
||||||
assert 'include_str!(concat!(env!("CARGO_MANIFEST_DIR")' in documentation_rs
|
|
||||||
assert 'docs-content.json' not in documentation_rs and 'documentation_bundle' not in build
|
|
||||||
for document in sorted(DOCS.glob("*.md")):
|
|
||||||
marker = f'"{document.relative_to(ROOT).as_posix()}"'
|
|
||||||
assert marker in documentation_rs, f"Reader does not embed {document.relative_to(ROOT)}"
|
|
||||||
for path in set(re.findall(r'"(docs/[A-Za-z0-9_.-]+\.md)"', documentation_rs)):
|
|
||||||
assert (ROOT / path).is_file(), f"Reader embeds missing document: {path}"
|
|
||||||
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.32.7")
|
|
||||||
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.30.0" and "## 0.29.1" in 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.29.1", "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.29.1") < 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}"
|
|
||||||
|
|
||||||
link_re = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
|
|
||||||
paths = [ROOT / "README.md", ROOT / "ROADMAP.md", ROOT / "CHANGELOG.md", *sorted(DOCS.glob("*.md"))]
|
|
||||||
for source in paths:
|
|
||||||
text = source.read_text(encoding="utf-8")
|
|
||||||
assert "Run compiler-enabled `cargo fmt --all`" not in text, f"obsolete release-host checklist remains in {source.relative_to(ROOT)}"
|
|
||||||
for raw in link_re.findall(text):
|
|
||||||
target = raw.strip().split()[0].strip("<>")
|
|
||||||
if not target or target.startswith(("http://", "https://", "mailto:", "#", "/")):
|
|
||||||
continue
|
|
||||||
target = target.split("#", 1)[0].split("?", 1)[0]
|
|
||||||
if not target:
|
|
||||||
continue
|
|
||||||
resolved = (source.parent / target).resolve()
|
|
||||||
assert resolved.exists(), f"broken documentation link: {source.relative_to(ROOT)} -> {target}"
|
|
||||||
|
|
||||||
install = (DOCS / "INSTALL.md").read_text(encoding="utf-8")
|
|
||||||
for marker in ["https://rustproxy.linuxiarz.pl/", "https://rustproxy-opn.linuxiarz.pl/", "Optional: Debian 13+ one-line source install/update"]:
|
|
||||||
assert marker in install, marker
|
|
||||||
|
|
||||||
print("PASS: documentation index, Reader/API split, release docs and local Markdown links are consistent.")
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Static source contract for DDNS ACL 0.32.7; NOT a Rust test."""
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
import tomllib
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
read = lambda p: (ROOT / p).read_text(encoding="utf-8")
|
|
||||||
acl = read('src/acl.rs')
|
|
||||||
dns = read('src/acl/dynamic.rs')
|
|
||||||
js = read('src/admin/web/management.js')
|
|
||||||
checks = {
|
|
||||||
'keep static IP/CIDR checks': 'RuleMatch::Ip(net) => net.contains(&peer)' in acl,
|
|
||||||
'dynamic DNS only allows explicit allow rules': 'dynamic DNS ACL rules only support allow' in acl,
|
|
||||||
'deny precedence retained': 'return Some(Action::Deny)' in acl,
|
|
||||||
'strict hostname validation': 'pub(crate) fn hostname(' in dns,
|
|
||||||
'scope scan covers vhosts, locations, profiles, host policies and docker': all(f in dns for f in [
|
|
||||||
'config.host_policies', 'config.acl_profiles', 'config.vhosts', 'vhost.routes', 'config.docker_agents.agents', 'agent.assignments'
|
|
||||||
]),
|
|
||||||
'bounded asynchronous DNS work': all(x in dns for x in ['REFRESH_SECONDS: u64 = 60', 'MAX_AGE_SECONDS: u64 = 180', 'MAX_NAMES: usize = 128', 'MAX_ADDRESSES: usize = 32', 'PARALLEL_LOOKUPS: usize = 8', 'LOOKUP_SECONDS: u64 = 4', 'buffer_unordered(PARALLEL_LOOKUPS)']),
|
|
||||||
'lookup on refresh, not on request': 'lookup_host' in dns and 'lookup_host' not in acl and 'dynamic::matches' in acl,
|
|
||||||
'resolver owns the cache key independently of the DNS future (E0505)': 'let lookup_name = name.clone();' in dns and 'lookup_host((lookup_name.as_str(), 0u16))' in dns and 'lookup_host((name.as_str(), 0u16))' not in dns,
|
|
||||||
'fail closed on empty or failed resolution': 'CACHE.store(Arc::new(results))' in dns and 'return (name, None);' in dns and 'entry.valid_until > Instant::now()' in dns,
|
|
||||||
'update after configuration commit': all('crate::acl::dynamic::config_changed()' in read(p) for p in ['src/runtime/updates.rs', 'src/runtime/docker.rs', 'src/runtime/certificates.rs']),
|
|
||||||
'startup background refresh': 'state.start_dns_acl_updates();' in read('src/main/admin_runtime.rs'),
|
|
||||||
'visual selector and disabled DNS deny': '.acl-profile-rule-type' in js and 'aclDnsAllowOnly' in js,
|
|
||||||
'old network field persisted': 'network: normalizeNetwork(' in js,
|
|
||||||
'PL/EN translations': all(k in json.loads(read(f'langs/{loc}.json'))['translations'] for loc in ('en_US','pl_pl') for k in ('aclRuleType','aclRuleDns','aclDnsHelp','aclDnsAllowOnly')),
|
|
||||||
'package version': tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7',
|
|
||||||
}
|
|
||||||
for name, ok in checks.items():
|
|
||||||
if not ok: raise AssertionError(name)
|
|
||||||
print(f'PASS: {len(checks)} DNS ACL 0.32.7 static source checks (not compiled or network-tested)')
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Execute the real ACL editor initializer, not just parse it: an uninitialized
|
|
||||||
// i18n helper used to stop the entire fingerprinted dashboard bundle at load.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
|
|
||||||
const source = readFileSync('src/admin/web/management.js', 'utf8');
|
|
||||||
const start = source.lastIndexOf('(() => {');
|
|
||||||
assert(start > 0 && source.slice(start).includes('window.rustProxyAclProfiles ='), 'ACL editor IIFE not found');
|
|
||||||
|
|
||||||
function element(tag = 'div') {
|
|
||||||
return {
|
|
||||||
tag, children: [], dataset: {}, value: '', checked: false, hidden: false,
|
|
||||||
classList: { add() {}, remove() {}, toggle() {} },
|
|
||||||
append(...nodes) { this.children.push(...nodes); },
|
|
||||||
after(...nodes) { this.insertedAfter = nodes; },
|
|
||||||
replaceChildren(...nodes) { this.children = nodes; },
|
|
||||||
addEventListener() {},
|
|
||||||
reset() {},
|
|
||||||
querySelector() { return element(); },
|
|
||||||
querySelectorAll() { return []; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const nodes = new Map();
|
|
||||||
const get = id => {
|
|
||||||
if (!nodes.has(id)) nodes.set(id, element());
|
|
||||||
return nodes.get(id);
|
|
||||||
};
|
|
||||||
const modes = ['allow_exceptions', 'block_exceptions', 'custom'].map((value, i) => ({ value, checked: i === 0 }));
|
|
||||||
get('acl-profile-form').querySelectorAll = selector => selector === 'input[name="acl-profile-mode"]' ? modes : [];
|
|
||||||
const window = {};
|
|
||||||
const context = {
|
|
||||||
$: get, window, envelope: null,
|
|
||||||
t: key => `localized:${key}`,
|
|
||||||
el: (tag, text, cls) => {
|
|
||||||
const node = element(tag);
|
|
||||||
if (text !== undefined) node.textContent = text;
|
|
||||||
if (cls) node.className = cls;
|
|
||||||
return node;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
runInNewContext(source.slice(start), context, { filename: 'src/admin/web/management.js' });
|
|
||||||
assert.equal(typeof window.rustProxyAclProfiles?.render, 'function', 'ACL panel initializer must finish');
|
|
||||||
assert.equal(typeof window.rustProxyAclProfiles?.refreshTranslations, 'function');
|
|
||||||
const hint = get('acl-profile-ip-help').insertedAfter?.[0];
|
|
||||||
assert.equal(hint?.tag, 'p', 'DDNS hint must be rendered');
|
|
||||||
assert.equal(hint?.dataset.i18n, 'aclDnsHelp', 'DDNS hint must remain translatable');
|
|
||||||
assert.equal(hint?.textContent, 'localized:aclDnsHelp');
|
|
||||||
window.rustProxyAclProfiles.reset();
|
|
||||||
console.log('PASS: ACL/DDNS real initializer runs without i18n temporal dead zone');
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const js = read('src/admin/web/file-logs.js');
|
|
||||||
const css = read('src/admin/web/style.css');
|
|
||||||
const en = JSON.parse(read('langs/en_US.json')).translations;
|
|
||||||
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
|
|
||||||
const header = html.indexOf('class="file-logs-heading-actions"');
|
|
||||||
const filter = html.indexOf('id="file-logs-filter"');
|
|
||||||
const toggle = html.indexOf('id="file-log-consolidate-vhosts"');
|
|
||||||
assert.ok(header >= 0 && header < toggle && toggle < filter, 'compact consolidation switch belongs in header');
|
|
||||||
assert.equal((html.match(/id="file-log-consolidate-vhosts"/g) || []).length, 1);
|
|
||||||
for (const view of ['entries','counts']) {
|
|
||||||
assert.match(html, new RegExp(`id="file-log-tab-${view}"[^>]+role="tab"`));
|
|
||||||
assert.match(html, new RegExp(`id="file-log-view-${view}"[^>]+role="tabpanel"`));
|
|
||||||
}
|
|
||||||
assert.match(html, /id="file-log-view-counts"[^>]+hidden/);
|
|
||||||
assert.ok(html.indexOf('id="file-log-aggregate-form"') > html.indexOf('id="file-log-view-counts"'));
|
|
||||||
assert.ok(html.indexOf('id="file-log-aggregate-form"') < html.indexOf('id="file-log-view-entries"'));
|
|
||||||
assert.match(js, /function setView\(view\)/);
|
|
||||||
assert.match(js, /aria-selected/);
|
|
||||||
assert.match(js, /\$\("file-log-view-" \+ name\)\.hidden = !selected/);
|
|
||||||
assert.match(js, /activeView === "counts" && aggregateField/);
|
|
||||||
assert.match(js, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
|
||||||
assert.match(css, /#file-log-view-counts\[hidden\]\s*,\s*#file-log-view-entries\[hidden\]/);
|
|
||||||
for (const strings of [en, pl]) for (const key of ['fileLogsConsolidateShort', 'fileLogsEntriesTab','fileLogsCountsTab']) assert.ok(strings[key], key);
|
|
||||||
console.log('PASS: Stored Logs separate tabs, shared filter, compact persisted consolidation toggle, localized UI.');
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Source contract tests only: no Rust compilation, live H3 peer or UI browser.
|
|
||||||
// Keep these checks aligned with Rust unit tests in src/http3/tests.rs and
|
|
||||||
// src/runtime/tests.rs; do not interpret source checks as wire acceptance.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
|
|
||||||
const h3 = read('src/http3.rs');
|
|
||||||
const identity = read('src/http3/identity.rs');
|
|
||||||
const body = read('src/http3/body.rs');
|
|
||||||
const h3Tests = read('src/http3/tests.rs');
|
|
||||||
const cache = read('src/runtime/types.rs');
|
|
||||||
const cacheTests = read('src/runtime/tests.rs');
|
|
||||||
const css = read('src/admin/web/style.css');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const logs = read('src/admin/web/file-logs.js');
|
|
||||||
const en = JSON.parse(read('langs/en_US.json')).translations;
|
|
||||||
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
|
|
||||||
|
|
||||||
// A peer-initiated RESET_STREAM or STOP_SENDING must not be logged as a
|
|
||||||
// fabricated HTTP 400 when the browser aborts a prior navigation on F5.
|
|
||||||
assert.match(identity, /fn client_cancelled_code\(code: Code\)/);
|
|
||||||
assert.match(identity, /code == Code::H3_REQUEST_CANCELLED \|\| code == Code::H3_NO_ERROR/);
|
|
||||||
assert.match(identity, /StreamError::RemoteTerminate \{ code, \.\. \} if client_cancelled_code\(\*code\)/);
|
|
||||||
assert.match(h3, /Ok\(Err\(error\)\)\s*=>\s*\{\s*if client_cancelled_request\(&error\)/);
|
|
||||||
assert.match(h3, /\} else \{\s*log_protocol_error\(\s*&state, "http3_request_error", error, peer, local, 400/);
|
|
||||||
assert.match(h3, /"http3_invalid_headers"[^\n]*\n\s*peer, local, 400/);
|
|
||||||
assert.match(h3, /error\.downcast_ref::<h3::error::StreamError>\(\)\s*\.is_some_and\(client_cancelled_request\)/);
|
|
||||||
assert.match(h3, /if !cancelled \{[\s\S]*?send\.stop_stream\(Code::H3_INTERNAL_ERROR\)/);
|
|
||||||
const bodyDrop = body.slice(body.indexOf('impl Drop for ReceiveBody'));
|
|
||||||
assert.ok(!bodyDrop.includes('stop_sending('), 'Do not reintroduce a receive-stream Drop panic');
|
|
||||||
assert.match(h3Tests, /client_cancelled_code\(Code::H3_MESSAGE_ERROR\)/);
|
|
||||||
|
|
||||||
// H2/H3 client transport selection is not a cached representation property.
|
|
||||||
const revision = cache.slice(cache.indexOf('fn cache_revision('), cache.indexOf('fn cache_invalidation('));
|
|
||||||
assert.match(revision, /for vhost in &mut vhosts/);
|
|
||||||
assert.match(revision, /vhost\.http2_enabled = true;/);
|
|
||||||
assert.match(revision, /vhost\.http3_enabled = true;/);
|
|
||||||
assert.ok(revision.indexOf('vhost.http3_enabled = true;') < revision.indexOf('serde_json::to_vec('));
|
|
||||||
assert.match(cacheTests, /cache_invalidation\(&base, &protocol_only\), CacheInvalidation::None/);
|
|
||||||
assert.match(cacheTests, /cache_invalidation\(&protocol_only, &base\), CacheInvalidation::None/);
|
|
||||||
assert.match(cacheTests, /cache_revision\(&both_protocols\), identity/);
|
|
||||||
assert.match(cacheTests, /CacheInvalidation::All\(value\) if value == cache_identity/);
|
|
||||||
|
|
||||||
// Every row is still keyboard-expandable and all selected columns are
|
|
||||||
// represented on the mobile card rather than removed by display:none.
|
|
||||||
assert.match(html, /id="panel-filelogs"/);
|
|
||||||
assert.match(logs, /row\.tabIndex = 0/);
|
|
||||||
assert.match(logs, /row\.setAttribute\("aria-expanded"/);
|
|
||||||
assert.match(logs, /event\.key === "Enter" \|\| event\.key === " "/);
|
|
||||||
assert.match(logs, /cell\.dataset\.mobileColumn = id/);
|
|
||||||
assert.match(logs, /cell\.dataset\.mobileLabel = label/);
|
|
||||||
assert.match(logs, /if \(selectedIndex === index\) nodes\.push\(detailRow\(entry\)\)/);
|
|
||||||
assert.match(css, /@media \(max-width: 700px\)/);
|
|
||||||
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\) \{\s*display: grid/);
|
|
||||||
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\)\s*>\s*td \{\s*display: block/);
|
|
||||||
assert.match(css, /overflow-wrap: anywhere/);
|
|
||||||
assert.match(css, /#panel-filelogs \.file-logs-table,\s*#panel-filelogs \.file-log-aggregate-table \{[^}]*overflow-x: auto/s);
|
|
||||||
assert.match(css, /#panel-filelogs \.file-log-inline-detail pre \{[^}]*max-width:\s*100%;[^}]*overflow-x:\s*auto;[^}]*\}/s);
|
|
||||||
|
|
||||||
// Exercise the actual browser code's entry-summary function with a logged
|
|
||||||
// H3 cancellation and a normal access event; this is a JS unit simulation.
|
|
||||||
const start = logs.indexOf(' function entrySummary(entry) {');
|
|
||||||
const end = logs.indexOf(' function nestedValue(', start);
|
|
||||||
assert.ok(start >= 0 && end > start, 'entrySummary function exists');
|
|
||||||
const context = vm.createContext({});
|
|
||||||
vm.runInContext(logs.slice(start, end), context);
|
|
||||||
assert.equal(vm.runInContext('entrySummary({reason:"http3_request_error",error:"Remote reset: H3_REQUEST_CANCELLED"})', context), 'http3_request_error');
|
|
||||||
assert.equal(vm.runInContext('entrySummary({method:"GET",host:"rp.example.test",path:"/admin"})', context), 'GET rp.example.test/admin');
|
|
||||||
|
|
||||||
for (const [language, pack] of [['en_US', en], ['pl_pl', pl]]) {
|
|
||||||
assert.match(pack.administrationPublicOriginHelp, /HTTPS/i, `${language}: origin scheme`);
|
|
||||||
assert.match(pack.administrationPublicOriginHelp, /Origin\/CSRF/i, `${language}: security purpose`);
|
|
||||||
assert.ok(pack.administrationHostsAdvanced.length > 10, `${language}: advanced DNS hosts`);
|
|
||||||
}
|
|
||||||
console.log('PASS: H3 cancellation classification, cache stability, mobile log-card/keyboard contracts and origin help.');
|
|
||||||
console.log('NOT VERIFIED: Rust build, actual QUIC navigation or full authenticated mobile UI.');
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Compiler-independent guard for the 0.31.7 H3 navigation patch."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
|
||||||
assert '0.32.7' in (ROOT / "Cargo.toml").read_text()
|
|
||||||
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
|
||||||
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
|
||||||
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
|
||||||
assert 'let navigation_method = method == Method::GET || method == Method::HEAD;' in body
|
|
||||||
assert body.count('std::time::Duration::from_millis(35)') >= 2
|
|
||||||
assert 'let bounded_h3_navigation = request.version() == Version::HTTP_3' in body
|
|
||||||
assert 'Body::new(body::ReplayBody::new(frame, tail))' in body
|
|
||||||
assert '|| method == Method::GET' not in body.split('async fn normalize_h3_h1_unknown_body',1)[1].split('// Ignore a small number',1)[0]
|
|
||||||
h3 = (ROOT / 'src/http3.rs').read_text()
|
|
||||||
assert '"http3_peer_cancelled_before_headers"' in h3
|
|
||||||
assert '"http3_peer_cancelled_after_headers"' in h3
|
|
||||||
assert '"http3_response_write_error"' in h3
|
|
||||||
assert 'stream_id={stream_id}' in h3 and 'body_bytes_sent={sent_body_bytes}' in h3
|
|
||||||
diagnostics = (ROOT / 'src/http3/diagnostics.rs').read_text()
|
|
||||||
assert 'http3_navigation_complete' in diagnostics and 'http3_navigation_started' in diagnostics
|
|
||||||
assert 'Err(error)=>{requests.abort_all();return Err(error.into());}' not in h3
|
|
||||||
rust_tests = (ROOT / 'src/proxy/tests/h3_navigation_tests.rs').read_text()
|
|
||||||
for marker in ('h3_bodyless_get_and_head_are_normalized_for_http1_navigation',
|
|
||||||
'h3_get_with_payload_retains_payload_for_http1_upstream',
|
|
||||||
'h3_stalled_empty_get_does_not_block_navigation',
|
|
||||||
'h3_stalled_cache_probe_bypasses_without_blocking_navigation',
|
|
||||||
'h3_cache_probe_replays_nonempty_get_without_dropping_bytes'):
|
|
||||||
assert marker in rust_tests, marker
|
|
||||||
print('PASS: RustProxy 0.31.7 original logo, H3 empty-GET receive lifecycle, bounded probe, per-stream diagnostics and drain invariants')
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Offline high-cardinality invalidation replay + HTTP load-driver smoke test.
|
|
||||||
|
|
||||||
The replay models bookkeeping cardinality, not a running RustProxy heap. The HTTP
|
|
||||||
requests target a Python test peer solely to validate the staging load harness.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
|
|
||||||
from memory_load import run_load # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def purge_replay(operations: int = 300_000, windows: int = 6) -> list[tuple[int, int]]:
|
|
||||||
old_generations = {}
|
|
||||||
new_generations = {}
|
|
||||||
counts = []
|
|
||||||
assert operations % windows == 0
|
|
||||||
for i in range(operations):
|
|
||||||
key = i.to_bytes(32, "big")
|
|
||||||
# Legacy: each already-removed key keeps a generation slot forever.
|
|
||||||
old_generations[key] = 1
|
|
||||||
# Same-version fix: the global generation barrier cancels all older
|
|
||||||
# tickets, so only the current purge's per-key metadata is needed.
|
|
||||||
new_generations.clear()
|
|
||||||
new_generations[key] = 1
|
|
||||||
if (i + 1) % (operations // windows) == 0:
|
|
||||||
counts.append((len(old_generations), len(new_generations)))
|
|
||||||
assert counts == [(operations // windows * i, 1) for i in range(1, windows + 1)]
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
class TestPeer(BaseHTTPRequestHandler):
|
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
body = b"HTTP-load-driver-ok"
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def log_message(self, *_: object) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--http-requests", type=int, default=2_000, help="HTTP requests against the local Python test peer (default: 2000)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if not 1 <= args.http_requests <= 1_000_000:
|
|
||||||
parser.error("--http-requests must be 1..1000000")
|
|
||||||
checkpoints = purge_replay()
|
|
||||||
print("PASS: 300,000 simulated distinct PURGEs; cumulative vs reset per-key generation slots:", checkpoints)
|
|
||||||
peer = ThreadingHTTPServer(("127.0.0.1", 0), TestPeer)
|
|
||||||
runner = threading.Thread(target=peer.serve_forever, daemon=True)
|
|
||||||
runner.start()
|
|
||||||
try:
|
|
||||||
url = f"http://127.0.0.1:{peer.server_port}/"
|
|
||||||
report = run_load(url=url, requests=args.http_requests, concurrency=24, unique_paths=min(1000, args.http_requests),
|
|
||||||
timeout=5, pid=os.getpid(), sample_interval=0.05, cooldown=0.15)
|
|
||||||
assert report["requests_sent"] == args.http_requests, report
|
|
||||||
assert report["statuses"] == {"200": args.http_requests}, report
|
|
||||||
assert report["response_bytes_read"] == args.http_requests * len(b"HTTP-load-driver-ok"), report
|
|
||||||
assert report["rss_peak_mib"] is not None, report
|
|
||||||
print(f"PASS: load tool generated {args.http_requests:,} real HTTP requests with 24 connections and {min(1000, args.http_requests):,} paths against a Python test peer")
|
|
||||||
print("NOTE: peer/load-tool RSS is NOT RustProxy RSS; live RustProxy profiling remains mandatory")
|
|
||||||
finally:
|
|
||||||
peer.shutdown()
|
|
||||||
peer.server_close()
|
|
||||||
runner.join(timeout=5)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Regression for live Network diagnostic DOM stability beside Process.
|
|
||||||
|
|
||||||
Runs the actual renderer and the shipped dashboard CSS with mocked sample data
|
|
||||||
in Chromium touch emulation. This does NOT certify Safari/iOS 27 behavior.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
WEB = ROOT / 'src/admin/web'
|
|
||||||
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
|
|
||||||
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
|
|
||||||
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
|
|
||||||
assert process and network
|
|
||||||
source = (WEB / 'app/runtime.js').read_text()
|
|
||||||
start = source.index('function renderNetworkInterfaceCharts(interfaces, appendSample) {')
|
|
||||||
start = source.rfind('let latestNetworkInterfaceSnapshot', 0, start)
|
|
||||||
assert start >= 0
|
|
||||||
renderer = source[start:source.index('function renderDiagnostics(', start)]
|
|
||||||
helper = source[source.index('function setChartText('):start]
|
|
||||||
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
|
|
||||||
'<head><meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
||||||
'<style>' + (WEB / 'style.css').read_text() + '</style></head>'
|
|
||||||
'<body style="margin:0"><div style="height:1020px">Overview metrics</div>'
|
|
||||||
'<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned" '
|
|
||||||
'id="dashboard-overview-grid">' + str(process) + str(network) +
|
|
||||||
'</div></section><div style="height:160px"></div></body></html>')
|
|
||||||
stubs = '''
|
|
||||||
const networkInterfaceSamples = new Map();
|
|
||||||
const hiddenNetworkInterfaces = new Set();
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
const t = s => ({networkInterfacesVisibleCount:'{visible} / {total}',
|
|
||||||
networkInterfaceRate:'Rate', networkInterfacesHistory:'Traffic',
|
|
||||||
hideMetric:'Hide', showMetric:'Show',hiddenInterfaces:'Hidden',
|
|
||||||
networkNoInterfaces:'No interfaces',networkNoVisibleInterfaces:'None visible'})[s] || s;
|
|
||||||
const fmtBytes = n => `${n} B`;
|
|
||||||
const el = (tag, text, cls) => {const node=document.createElement(tag);
|
|
||||||
if(text !== undefined) node.textContent=text;if(cls) node.className=cls;return node;};
|
|
||||||
const action = (label, fn) => {const node=el('button',label);node.addEventListener('click',fn);return node;};
|
|
||||||
const svgNode = (tag, attrs={}) => {const node=document.createElementNS('http://www.w3.org/2000/svg',tag);
|
|
||||||
Object.entries(attrs).forEach(([key,value])=>node.setAttribute(key,value));return node;};
|
|
||||||
const sparklinePoints = (values,width=300,height=60,padding=5,peakOverride=null) => {
|
|
||||||
const peak=Math.max(1,peakOverride??Math.max(0,...values));
|
|
||||||
return values.map((value,index)=>`${index*width/Math.max(1,values.length-1)},${height-padding-Math.max(0,value)/peak*(height-padding*2)}`).join(' ');
|
|
||||||
};
|
|
||||||
const saveHiddenNetworkInterfaces = () => {};
|
|
||||||
'''
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
|
|
||||||
context = browser.new_context(viewport={'width':393,'height':844},
|
|
||||||
device_scale_factor=3, is_mobile=True, has_touch=True)
|
|
||||||
page = context.new_page()
|
|
||||||
page.set_content(html, wait_until='domcontentloaded')
|
|
||||||
page.add_script_tag(content=stubs + helper + renderer)
|
|
||||||
baseline = page.evaluate('''() => {
|
|
||||||
const items = [{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:20},
|
|
||||||
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}];
|
|
||||||
renderNetworkInterfaceCharts(items,true);
|
|
||||||
const panel = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
|
|
||||||
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
|
|
||||||
const target = document.getElementById('network-interface-charts');
|
|
||||||
window.testNodes = {first:target.firstElementChild, svg:target.querySelector('svg'),
|
|
||||||
path:target.querySelector('.network-rx-line')};
|
|
||||||
window.testInterfaces = items;
|
|
||||||
window.scrollTo(0,Math.round(network.getBoundingClientRect().top+window.scrollY+180));
|
|
||||||
return {processHeight:panel.getBoundingClientRect().height, y:scrollY,
|
|
||||||
network:network.getBoundingClientRect().top, count:target.children.length,
|
|
||||||
scrollHeight:document.documentElement.scrollHeight};
|
|
||||||
}''')
|
|
||||||
assert baseline['y'] > 0 and baseline['count'] == 2, baseline
|
|
||||||
count = page.evaluate('''() => {
|
|
||||||
const target = document.getElementById('network-interface-charts');
|
|
||||||
let mutations=0;
|
|
||||||
const obs = new MutationObserver(list=>mutations+=list.filter(item=>item.type==='childList').length);
|
|
||||||
obs.observe(target,{childList:true,subtree:true});
|
|
||||||
const beforeY=scrollY;
|
|
||||||
const beforeHeight=document.documentElement.scrollHeight;
|
|
||||||
const beforeProcess=document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height;
|
|
||||||
for(let i=0;i<45;i++){
|
|
||||||
renderNetworkInterfaceCharts([{name:'ens18',rx_bytes_per_second:i*109,tx_bytes_per_second:12},
|
|
||||||
{name:'lo',rx_bytes_per_second:i*2,tx_bytes_per_second:3}],true);
|
|
||||||
if(target.firstElementChild!==window.testNodes.first ||
|
|
||||||
target.querySelector('svg')!==window.testNodes.svg ||
|
|
||||||
target.querySelector('.network-rx-line')!==window.testNodes.path) throw Error('Network DOM replaced');
|
|
||||||
}
|
|
||||||
obs.takeRecords().forEach(r=> {if(r.type==='childList')mutations++});obs.disconnect();
|
|
||||||
return {mutations, y:scrollY, beforeY, count:target.children.length,
|
|
||||||
scrollHeight:document.documentElement.scrollHeight, beforeHeight,
|
|
||||||
processHeight:document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height, beforeProcess,
|
|
||||||
changed:target.querySelector('.network-interface-rate').textContent.includes('4796')};
|
|
||||||
}''')
|
|
||||||
assert count['mutations'] == 0, count
|
|
||||||
assert abs(count['y'] - count['beforeY']) < 2, count
|
|
||||||
assert count['count'] == 2 and count['changed'], count
|
|
||||||
assert abs(count['scrollHeight'] - count['beforeHeight']) < 2, count
|
|
||||||
assert abs(count['processHeight'] - count['beforeProcess']) < 2, count
|
|
||||||
print('PASS: 45 updates preserve SVG nodes, Process height, document height and scroll')
|
|
||||||
|
|
||||||
# Interface charts advance at every Overview live tick like the aggregate
|
|
||||||
# network chart, repeating the last observed rate until system-info updates.
|
|
||||||
# A system-info-only refresh or Hide/Show redraw never adds history points.
|
|
||||||
history = page.evaluate('''() => {
|
|
||||||
networkInterfaceSamples.clear();
|
|
||||||
const one=[{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200}];
|
|
||||||
renderNetworkInterfaceCharts(one,true);
|
|
||||||
const before=networkInterfaceSamples.get('ens18').rx.length;
|
|
||||||
for(let i=0;i<45;i++) renderNetworkInterfaceCharts(one,true);
|
|
||||||
const live=networkInterfaceSamples.get('ens18').rx.length;
|
|
||||||
const measured=[{name:'ens18',rx_bytes_per_second:500,tx_bytes_per_second:250}];
|
|
||||||
renderNetworkInterfaceCharts(measured,false); // only new system-info data
|
|
||||||
const afterSystem=networkInterfaceSamples.get('ens18').rx.length;
|
|
||||||
const label=document.querySelector('.network-interface-rate').textContent;
|
|
||||||
renderNetworkInterfaceCharts(measured,true); // next live tick
|
|
||||||
const afterLive=networkInterfaceSamples.get('ens18').rx.length;
|
|
||||||
const newest=networkInterfaceSamples.get('ens18').rx.at(-1);
|
|
||||||
return {before,live,afterSystem,afterLive,label,newest};
|
|
||||||
}''')
|
|
||||||
assert history['before'] == 1 and history['live'] == 46, history
|
|
||||||
assert history['afterSystem'] == 46 and history['afterLive'] == 47, history
|
|
||||||
assert history['newest'] == 500 and '500 B' in history['label'], history
|
|
||||||
assert 'renderNetworkInterfaceCharts(live.network_interfaces || [], appendSample);' in source
|
|
||||||
refresh = (WEB / 'app/refresh.js').read_text()
|
|
||||||
assert 'interval: () => options.system_info_seconds' in refresh
|
|
||||||
assert 'renderDiagnostics(latestStatus, false, true);' in refresh
|
|
||||||
print('PASS: frequent interface chart history, configured system-info cadence, no extra samples on system-only redraw')
|
|
||||||
page.evaluate('''() => renderNetworkInterfaceCharts([
|
|
||||||
{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200},
|
|
||||||
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}],false)''')
|
|
||||||
|
|
||||||
# The Hide / Show controls retain the newest sample on click, not the array
|
|
||||||
# captured when a chart node was created.
|
|
||||||
page.locator('.network-interface-hide').first.click()
|
|
||||||
assert page.locator('.network-interface-row').count() == 1
|
|
||||||
assert page.locator('.network-interface-hidden button').count() == 1
|
|
||||||
page.locator('.network-interface-hidden button').click()
|
|
||||||
assert page.locator('.network-interface-row').count() == 2
|
|
||||||
assert page.locator('.network-interface-hidden').count() == 0
|
|
||||||
page.evaluate('''() => renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:9,tx_bytes_per_second:11}],true)''')
|
|
||||||
assert page.locator('.network-interface-row').count() == 1
|
|
||||||
assert page.locator('.network-interface-row').first.get_attribute('data-interface-name') == 'eth0'
|
|
||||||
page.evaluate('''() => renderNetworkInterfaceCharts([],true)''')
|
|
||||||
assert page.locator('.network-interface-empty').count() == 1
|
|
||||||
print('PASS: Hide/Show actions, hotplug/removal and empty state still work')
|
|
||||||
browser.close()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Validate language-pack content and emit the same-shaped bundle used by UI QA.
|
|
||||||
Rust build.rs performs the actual compile-time discovery and embedding.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
def bundle(root=ROOT):
|
|
||||||
languages=[];locales=set()
|
|
||||||
for path in sorted((root/'langs').glob('*.json')):
|
|
||||||
assert re.fullmatch(r'[A-Za-z0-9_-]{1,64}',path.stem),path
|
|
||||||
assert path.stat().st_size<=262144,path
|
|
||||||
pack=json.loads(path.read_text(encoding='utf-8'));assert set(pack)=={'meta','translations'},f'unexpected top-level language-pack keys: {set(pack)-{"meta","translations"}} in {path}';meta=pack['meta'];translations=pack['translations']
|
|
||||||
assert all(isinstance(meta.get(key),str) and 0<len(meta[key].encode())<=128 for key in ['locale','name','native_name','direction']),path
|
|
||||||
assert meta['direction'] in ('ltr','rtl'),path
|
|
||||||
assert re.fullmatch(r'[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*',meta['locale']),path
|
|
||||||
assert meta['locale'].lower() not in locales, 'duplicate locale'
|
|
||||||
locales.add(meta['locale'].lower())
|
|
||||||
assert isinstance(translations,dict) and len(translations)<=4096,path
|
|
||||||
assert all(isinstance(v,str) and 0<len(k.encode())<=128 and len(v.encode())<=16384 for k,v in translations.items()),path
|
|
||||||
languages.append({'id':path.stem,**pack})
|
|
||||||
assert 1<=len(languages)<=128
|
|
||||||
fallback=next(pack for pack in languages if pack['id']=='en_US')['translations']
|
|
||||||
html=(root/'src/admin/web/index.html').read_text()
|
|
||||||
keys=set(re.findall(r'data-i18n(?:-placeholder|-aria)?="([^"]+)"',html))
|
|
||||||
js='\n'.join(p.read_text() for p in (root/'src/admin/web').glob('**/*.js'))
|
|
||||||
keys.update(re.findall(r'\bt\("([^"\n]+)"\)',js))
|
|
||||||
keys.update(re.findall(r'"(vh[A-Z][A-Za-z]+)"',js))
|
|
||||||
assert keys<=fallback.keys(),f'Missing fallback keys: {keys-fallback.keys()}'
|
|
||||||
for language in languages:
|
|
||||||
translations=language['translations']
|
|
||||||
assert keys<=translations.keys(),f"Missing UI keys in {language['id']}: {keys-translations.keys()}"
|
|
||||||
result={'default':'en_US','languages':languages}
|
|
||||||
assert len(json.dumps(result,ensure_ascii=False).encode())<=8388608
|
|
||||||
return result
|
|
||||||
if __name__=='__main__':
|
|
||||||
parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path);args=parser.parse_args()
|
|
||||||
data=bundle()
|
|
||||||
if args.output:args.output.write_text(json.dumps(data,ensure_ascii=False)+'\n')
|
|
||||||
print(f"PASS: {len(data['languages'])} language packs, metadata, fallback UI keys and bounds.")
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Real diagnostic renderer/CSS in Chromium mobile emulation (not iOS Safari)."""
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
WEB = ROOT / 'src/admin/web'
|
|
||||||
source = (WEB / 'app/runtime.js').read_text()
|
|
||||||
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
|
|
||||||
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
|
|
||||||
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
|
|
||||||
assert process and network
|
|
||||||
# Run the real renderDiagnostics and interface component as shipped, without
|
|
||||||
# creating a fake SSE server or depending on Rust/Cargo in this test environment.
|
|
||||||
start = source.index('let systemSamples =')
|
|
||||||
end = source.index('function renderHeaderRules(', start)
|
|
||||||
renderer = source[start:end]
|
|
||||||
html = ('<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded" '
|
|
||||||
'data-dashboard-density="compact"><head><meta name="viewport" '
|
|
||||||
'content="width=device-width, initial-scale=1"><style>'
|
|
||||||
+ (WEB / 'style.css').read_text() + '</style></head>'
|
|
||||||
'<body style="margin:0"><div style="height:1100px">Earlier dashboard cards</div>'
|
|
||||||
'<section id="panel-overview"><div id="dashboard-overview-grid" '
|
|
||||||
'class="stats-grid dashboard-overview-grid is-aligned">'
|
|
||||||
+ str(process) + str(network) + '</div></section>'
|
|
||||||
'<div class="vhost-policy-pills"><small class="pill">H1</small>'
|
|
||||||
'<small class="vhost-backend-protocol-badge" data-observed="true">AUTO → H2</small></div>'
|
|
||||||
'<div style="height:900px">Footer</div></body></html>')
|
|
||||||
stubs = '''
|
|
||||||
const dummy = new Map();
|
|
||||||
const $ = id => document.getElementById(id) || (() => {
|
|
||||||
if (!dummy.has(id)) dummy.set(id, { textContent:'-', value:'', hidden:false,
|
|
||||||
classList:{ toggle(){} }, setAttribute(){}, removeAttribute(){}, append(){},
|
|
||||||
replaceChildren(){}, closest(){ return {hidden:false}; } });
|
|
||||||
return dummy.get(id);
|
|
||||||
})();
|
|
||||||
const envelope = {config:{cache:{enabled:true,cold_enabled:false}}};
|
|
||||||
const t = key => ({networkInterfacesVisibleCount:'{visible} / {total}',
|
|
||||||
networkInterfaceRate:'Rate',networkInterfacesHistory:'Traffic',hideMetric:'Hide',
|
|
||||||
showMetric:'Show',hiddenInterfaces:'Hidden',networkNoInterfaces:'None',
|
|
||||||
networkNoVisibleInterfaces:'None visible',notEnabled:'Disabled',
|
|
||||||
activeListeners:'Listeners',restartPending:'Restart',realSample:'Sampled',
|
|
||||||
threads:'threads',unavailable:'Unavailable',unlimited:'Unlimited'})[key] || key;
|
|
||||||
const bytes = value => `${Number(value)} B`;
|
|
||||||
const locale = () => 'en-US';
|
|
||||||
const uptime = seconds => `${seconds}s`;
|
|
||||||
const formatDateTime = seconds => String(seconds);
|
|
||||||
const action = (text, fn) => { const node=document.createElement('button'); node.textContent=text;
|
|
||||||
node.addEventListener('click',fn); return node; };
|
|
||||||
const el = (tag, text, cls) => {const node=document.createElement(tag);
|
|
||||||
if(text!==undefined) node.textContent=text; if(cls) node.className=cls; return node;};
|
|
||||||
const updateCacheMemorySizing = () => {};
|
|
||||||
const rustProxyRateSampling = {cadence:()=>3,difference:(previous,values,at)=>({next:{values,at},
|
|
||||||
deltas:previous?values.map((v,i)=>v-previous.values[i]):null,seconds:3})};
|
|
||||||
'''
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
|
|
||||||
ctx = browser.new_context(viewport={'width':393,'height':844}, device_scale_factor=3,
|
|
||||||
is_mobile=True, has_touch=True)
|
|
||||||
page = ctx.new_page()
|
|
||||||
page.set_content(html, wait_until='domcontentloaded')
|
|
||||||
page.on('pageerror', lambda error: print('BROWSER ERROR:', error))
|
|
||||||
page.add_script_tag(content=stubs + renderer)
|
|
||||||
result = page.evaluate('''() => {
|
|
||||||
const system = {source:'sysinfo', platform:'Linux', process_threads:5,
|
|
||||||
process_cpu_percent:5,process_rss_bytes:100,cpu_percent:15,
|
|
||||||
network_rx_bytes_per_second:20, network_tx_bytes_per_second:10,
|
|
||||||
sampled_at_ms:Date.now(),network_interfaces:[
|
|
||||||
{name:'eth0',rx_bytes_per_second:10,tx_bytes_per_second:5}]};
|
|
||||||
const s = {system,cache:{hits:50,misses:30,bypasses:10,resident_bytes:4096},
|
|
||||||
metrics:{http3_requests:2,mean_duration_ms:0.5},uptime_seconds:100,
|
|
||||||
listeners:{active_endpoints:[]},proxy_listen:null,tls_listen:null};
|
|
||||||
renderDiagnostics(s,true,true);
|
|
||||||
const process = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
|
|
||||||
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
|
|
||||||
const target = document.getElementById('network-interface-charts');
|
|
||||||
const firstRow = target.firstElementChild;
|
|
||||||
const initialDetail = document.getElementById('process-cpu').textContent;
|
|
||||||
const initialHistory = processCpuSamples.length;
|
|
||||||
window.scrollTo(0, Math.round(network.getBoundingClientRect().top + window.scrollY + 125));
|
|
||||||
const y = window.scrollY, height = process.getBoundingClientRect().height;
|
|
||||||
for (let i=0; i<35; i++) {
|
|
||||||
s.live_system = {sampled_at_ms:Date.now(),process_cpu_percent:7+i,
|
|
||||||
process_rss_bytes:150+i, cpu_percent:22+i,
|
|
||||||
network_rx_bytes_per_second:100+i*20,network_tx_bytes_per_second:10+i,
|
|
||||||
network_interfaces:[{name:'eth0',rx_bytes_per_second:100+i*20,
|
|
||||||
tx_bytes_per_second:10+i}]};
|
|
||||||
renderDiagnostics(s,true,false);
|
|
||||||
}
|
|
||||||
const unchangedDetails = document.getElementById('process-cpu').textContent === initialDetail;
|
|
||||||
const recentChart = document.getElementById('process-cpu-chart-value').textContent;
|
|
||||||
const iface = target.querySelector('.network-interface-rate').textContent;
|
|
||||||
const beforeSystem = processCpuSamples.length;
|
|
||||||
const y2 = window.scrollY;
|
|
||||||
s.system = {...system, process_cpu_percent:93,process_rss_bytes:3000,
|
|
||||||
network_rx_bytes_per_second:9000, network_tx_bytes_per_second:900};
|
|
||||||
renderDiagnostics(s,false,true);
|
|
||||||
const slowlyUpdated = document.getElementById('process-cpu').textContent;
|
|
||||||
const pill = document.querySelector('.pill');
|
|
||||||
const origin = document.querySelector('.vhost-backend-protocol-badge');
|
|
||||||
const roundedOrigin = getComputedStyle(origin).borderTopLeftRadius;
|
|
||||||
document.documentElement.dataset.cornerStyle = 'default';
|
|
||||||
const defaultPillRadius = getComputedStyle(pill).borderTopLeftRadius;
|
|
||||||
const defaultOriginRadius = getComputedStyle(origin).borderTopLeftRadius;
|
|
||||||
document.documentElement.dataset.cornerStyle = 'rounded';
|
|
||||||
return {initialDetail,initialHistory,unchangedDetails,recentChart,iface,
|
|
||||||
beforeSystem,afterSystem:processCpuSamples.length,slowlyUpdated,
|
|
||||||
rowStable:target.firstElementChild===firstRow,
|
|
||||||
heightDelta:process.getBoundingClientRect().height-height,
|
|
||||||
scrollDelta:y2-y,postSystemScrollDelta:window.scrollY-y,
|
|
||||||
nestedScroll:getComputedStyle(target).overflowY,
|
|
||||||
nestedMaxHeight:getComputedStyle(target).maxHeight,
|
|
||||||
pillRadius:getComputedStyle(pill).borderTopLeftRadius,
|
|
||||||
originRadius:roundedOrigin,defaultPillRadius,defaultOriginRadius,
|
|
||||||
originColor:getComputedStyle(origin).color};
|
|
||||||
}''')
|
|
||||||
assert result['initialDetail'] == '5%', result
|
|
||||||
assert result['initialHistory'] == 1, result
|
|
||||||
assert result['unchangedDetails'] and result['recentChart'] == '41%', result
|
|
||||||
assert result['beforeSystem'] == 36 == result['afterSystem'], result
|
|
||||||
assert result['slowlyUpdated'] == '93%', result
|
|
||||||
assert result['rowStable'] and '780 B' in result['iface'], result
|
|
||||||
assert abs(result['heightDelta']) < 2 and abs(result['scrollDelta']) < 2, result
|
|
||||||
assert abs(result['postSystemScrollDelta']) < 2, result
|
|
||||||
assert result['nestedScroll'] == 'visible' and result['nestedMaxHeight'] == 'none', result
|
|
||||||
assert result['pillRadius'] == '8px' and result['originRadius'] == '8px', result
|
|
||||||
assert result['defaultPillRadius'] == result['defaultOriginRadius'], result
|
|
||||||
assert result['originColor'] != '', result
|
|
||||||
print('PASS: 35 live CPU/RSS/network samples, slow Process metadata, stable mobile layout/scroll, badge radii', result)
|
|
||||||
browser.close()
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Static regression checks for bounded-memory/background-worker contracts.
|
|
||||||
|
|
||||||
This is intentionally Rust-toolchain independent. It does not replace runtime RSS/
|
|
||||||
heap profiling; it prevents the source-level bounds audited for this release from
|
|
||||||
silently disappearing before compiler-enabled acceptance runs.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from rust_source import rust_source
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
|
|
||||||
|
|
||||||
def read(path: str) -> str:
|
|
||||||
target = ROOT / path
|
|
||||||
return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def require(path: str, *markers: str) -> None:
|
|
||||||
text = read(path)
|
|
||||||
for marker in markers:
|
|
||||||
assert marker in text, f"{path}: missing memory contract: {marker}"
|
|
||||||
|
|
||||||
|
|
||||||
def forbid(path: str, *markers: str) -> None:
|
|
||||||
text = read(path)
|
|
||||||
for marker in markers:
|
|
||||||
assert marker not in text, f"{path}: forbidden memory pattern returned: {marker}"
|
|
||||||
|
|
||||||
|
|
||||||
# Transform path: hard aggregate/decode/concurrency budgets and no JSON DOM tree.
|
|
||||||
require(
|
|
||||||
"src/transform/mod.rs",
|
|
||||||
"DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024",
|
|
||||||
"pub memory_budget_bytes: usize",
|
|
||||||
"pub max_inflight: usize",
|
|
||||||
"try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)",
|
|
||||||
"try_reserve_transform_slot(settings.max_inflight)",
|
|
||||||
"TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize",
|
|
||||||
"TRANSFORM_INFLIGHT: AtomicUsize",
|
|
||||||
"pub struct ResourceUsage",
|
|
||||||
"pub fn resource_usage(settings: &Settings)",
|
|
||||||
"memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load",
|
|
||||||
"Limited::new(body, settings.max_body_bytes)",
|
|
||||||
".try_reserve_exact(read)",
|
|
||||||
'"memory_pressure"',
|
|
||||||
'"concurrency_pressure"',
|
|
||||||
"aggregation_is_bounded_even_when_content_length_is_false",
|
|
||||||
"transform_memory_reservation_obeys_runtime_budget",
|
|
||||||
"transform_inflight_reservation_obeys_runtime_limit",
|
|
||||||
)
|
|
||||||
require("src/transform/json.rs", "IgnoredAny::deserialize", "Vec::with_capacity(body.len())")
|
|
||||||
forbid("src/transform/json.rs", "use serde_json::Value", "from_slice::<Value>")
|
|
||||||
require("src/transform/css.rs", "let mut last_char: Option<char> = None")
|
|
||||||
require("src/transform/html.rs", "enum RawElement", "fn raw_tag(tag: &str)")
|
|
||||||
|
|
||||||
# Cache background work: all previously high-retention paths have hard bounds.
|
|
||||||
require(
|
|
||||||
"src/cache/model.rs",
|
|
||||||
"COLD_DELETE_FALLBACKS: usize = 2",
|
|
||||||
"COLD_CLEANUP_SLOTS: usize = 1",
|
|
||||||
"BACKGROUND_REVALIDATION_LIMIT: usize = 8",
|
|
||||||
"pub cold_limit_bytes: u64",
|
|
||||||
"pub cold_limit_entries: usize",
|
|
||||||
"pub hot_auto_trim_enabled: bool",
|
|
||||||
"pub hot_auto_trim_after_seconds: u64",
|
|
||||||
"pub hot_auto_trim_target_percent: u8",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/admin/mod.rs",
|
|
||||||
'payload["transform_resources"]',
|
|
||||||
'payload["memory_accounting"]',
|
|
||||||
'"non_cache_rss_includes_transform": true',
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/cache/cold.rs",
|
|
||||||
"BinaryHeap::with_capacity(requested_limit.saturating_add(1))",
|
|
||||||
"self.cold_delete_fallbacks.clone().try_acquire_owned()",
|
|
||||||
"self.cleanup_slots.clone().try_acquire_owned()",
|
|
||||||
"std::mem::take(&mut delete)",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/cache/tickets.rs",
|
|
||||||
"active.len() >= BACKGROUND_REVALIDATION_LIMIT",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/cache/collapse.rs",
|
|
||||||
".saturating_mul(64)",
|
|
||||||
".clamp(64, 256)",
|
|
||||||
"settings.proactive_refresh_max_inflight as u64",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/cache/lifecycle.rs",
|
|
||||||
"mpsc::channel(COLD_QUEUE)",
|
|
||||||
"cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS))",
|
|
||||||
"cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS))",
|
|
||||||
"cache.trim_hot_idle();",
|
|
||||||
"fn trim_hot_idle(&self)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# COLD bodies stay disk-backed and the long-lived index must not retain full
|
|
||||||
# response header/validator allocations for every object.
|
|
||||||
cache_model = read("src/cache/model/cache.rs")
|
|
||||||
cold_entry = cache_model.split("struct ColdEntry {", 1)[1].split("#[derive(Default)]", 1)[0]
|
|
||||||
assert "headers: HeaderMap" not in cold_entry, "COLD index retains full response headers"
|
|
||||||
assert "origin_etag: Option<HeaderValue>" not in cold_entry, "COLD index retains ETag HeaderValue"
|
|
||||||
assert "origin_last_modified: Option<HeaderValue>" not in cold_entry, "COLD index retains Last-Modified HeaderValue"
|
|
||||||
assert "body_sha256: Key" in cold_entry, "COLD digest must use the compact 32-byte representation"
|
|
||||||
assert "has_validator: bool" in cold_entry, "COLD index must retain validator presence without full values"
|
|
||||||
require(
|
|
||||||
"src/cache/model/cold_metadata.rs",
|
|
||||||
"tokio::fs::read(&entry.meta_path).await",
|
|
||||||
"pairs_to_headers(&file.headers)",
|
|
||||||
"body_sha256 != entry.body_sha256",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Rate-limit client state stores a vhost allocation once and reclaims expired
|
|
||||||
# fixed-window buckets without waiting for the global cardinality ceiling.
|
|
||||||
require(
|
|
||||||
"src/rate_limit.rs",
|
|
||||||
"buckets: HashMap<String, HashMap<IpAddr, Bucket>>",
|
|
||||||
"fn sweep_expired(&mut self, now: Instant, force: bool)",
|
|
||||||
"now.duration_since(bucket.started) < Duration::from_secs(bucket.window_seconds)",
|
|
||||||
"state.sweep_expired(now, false)",
|
|
||||||
"state.sweep_expired(now, true)",
|
|
||||||
"clients.shrink_to_fit()",
|
|
||||||
"self.buckets.shrink_to_fit()",
|
|
||||||
"pub fn maintenance(&self)",
|
|
||||||
"MAX_BUCKETS: usize = 65_536",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/runtime/constructors.rs",
|
|
||||||
"app.rate_limiter.maintenance()",
|
|
||||||
"spawn_blocking(crate::diagnostics::trim_allocator)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Compression remains response-streaming; no complete-body collection is added.
|
|
||||||
require(
|
|
||||||
"src/compression.rs",
|
|
||||||
"No full-body aggregation is introduced.",
|
|
||||||
"CompressionLevel::Fastest",
|
|
||||||
"Compression::new(service)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# One JSONL buffer is reused across local file and Logstash sends (Loki-only
|
|
||||||
# sinks should not allocate the JSONL line). Both output queues remain bounded.
|
|
||||||
logging_sink = read("src/logging/runtime.rs")
|
|
||||||
assert logging_sink.count("serde_json::to_vec(&payload)") == 1
|
|
||||||
assert "if config.file.enabled || config.logstash.enabled" in logging_sink
|
|
||||||
assert "opened.write_all(&jsonl_line)" in logging_sink
|
|
||||||
assert "state.stream.write_all(&jsonl_line)" in logging_sink
|
|
||||||
assert "PER_VHOST_OPEN_FILES: usize = 64" in logging_sink
|
|
||||||
|
|
||||||
# Stored Logs file discovery must also be cardinality bounded before sort:
|
|
||||||
# rotated files and per-vhost directories may contain arbitrary historical files.
|
|
||||||
require("src/admin/file_logs.rs", "newest_log_files(std::fs::read_dir(&directory)", ", 256);")
|
|
||||||
require("src/admin/file_logs_io.rs", "BinaryHeap::with_capacity(limit)", "newest_log_files(std::fs::read_dir(parent)", "), 64);")
|
|
||||||
|
|
||||||
# A per-key invalidation generation is useful only inside the current PURGE
|
|
||||||
# publication epoch; retaining each purged URL after eviction is an unbounded
|
|
||||||
# lifetime-memory leak on high-cardinality cache invalidation workloads.
|
|
||||||
invalidation = read("src/cache/invalidation/invalidate.rs")
|
|
||||||
start = invalidation.index("InvalidationOperation::Purge =>")
|
|
||||||
barrier = invalidation.index("store.generation = store.generation.wrapping_add(1);", start)
|
|
||||||
reset = invalidation.index("store.key_generations.clear();", barrier)
|
|
||||||
assert "store.key_generations.capacity() > 256" in invalidation[barrier:reset]
|
|
||||||
matcher = invalidation.index("let affected = self.purge_matcher(&matcher);", reset)
|
|
||||||
assert start < barrier < reset < matcher
|
|
||||||
require("src/cache/tests/v022_cache_tests.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever")
|
|
||||||
|
|
||||||
# Repeated hot-path analytics labels should use borrowed lookups instead of
|
|
||||||
# allocating a String for every request in an already populated top table.
|
|
||||||
require("src/traffic.rs", "fn truncate(value: &str, max_bytes: usize) -> &str", "self.values.get_mut(value)", "self.values.insert(value.to_owned(), weight)")
|
|
||||||
forbid("src/web_server/serve.rs", "Body::from(listing.clone())")
|
|
||||||
require("src/web_server/serve.rs", "entries.retain(|entry|", "let listing_bytes = listing.len();", "Body::from(listing)")
|
|
||||||
require("src/logging/runtime.rs", "String::from_utf8_lossy(&jsonl_line[..jsonl_line.len() - 1])")
|
|
||||||
|
|
||||||
# Other long-lived/background facilities: queue/task/worker cardinality is bounded
|
|
||||||
# or owned by a cancellation-aware supervisor/TaskTracker.
|
|
||||||
require(
|
|
||||||
"src/logging.rs",
|
|
||||||
"HISTORY_CAPACITY: usize = 4096",
|
|
||||||
"LIVE_STREAM_CAPACITY: usize = 4096",
|
|
||||||
"mpsc::channel(SINK_QUEUE)",
|
|
||||||
"self.sink.try_send(entry)",
|
|
||||||
"pub capture_user_agent: bool",
|
|
||||||
"pub capture_country: bool",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/traffic.rs",
|
|
||||||
"QUEUE_CAPACITY: usize = 2048",
|
|
||||||
"TOP_CAPACITY: usize = 32",
|
|
||||||
"MAX_VHOSTS: usize = 64",
|
|
||||||
"self.tx.try_send(sample)",
|
|
||||||
"client_ips: TopTable",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/diagnostics.rs",
|
|
||||||
"tokio::task::spawn_blocking(move || system_stats::sample(previous)).await",
|
|
||||||
"stop.cancelled()",
|
|
||||||
"pub fn trim_allocator() -> bool",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/runtime.rs",
|
|
||||||
"Duration::from_secs(60)",
|
|
||||||
"spawn_blocking(crate::diagnostics::trim_allocator)",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/upstream.rs",
|
|
||||||
"Semaphore::new(16)",
|
|
||||||
".try_acquire_owned()",
|
|
||||||
"tokio::time::timeout(DNS_TIMEOUT, lookup)",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/upstream/client.rs",
|
|
||||||
"pool_recycle_requests",
|
|
||||||
"pool_recycle_seconds",
|
|
||||||
"http1_max_buf_size(keepalive.http1_max_buffer_bytes)",
|
|
||||||
"ArcSwap<RawProxyClient>",
|
|
||||||
"self.current.store(Arc::new(client))",
|
|
||||||
'"recycled upstream client pool"',
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/balance.rs",
|
|
||||||
"if checks.len() >= 16 { break; }",
|
|
||||||
"checks.abort_all()",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/stream.rs",
|
|
||||||
"Semaphore::new(host.max_connections)",
|
|
||||||
"mpsc::channel(64)",
|
|
||||||
"JoinSet::new()",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/server.rs",
|
|
||||||
"Semaphore::new(max_connections)",
|
|
||||||
"let mut tasks = JoinSet::new()",
|
|
||||||
)
|
|
||||||
require(
|
|
||||||
"src/runtime.rs",
|
|
||||||
"tokio_util::task::TaskTracker::new()",
|
|
||||||
"update_slots: Arc::new(Semaphore::new(8))",
|
|
||||||
"inflight: Arc::new(Semaphore::new(max_inflight))",
|
|
||||||
)
|
|
||||||
require("src/acme.rs", "slot: Arc::new(Semaphore::new(1))", ".try_acquire_owned()")
|
|
||||||
require("src/docker.rs", "shutdown.cancelled()", "Duration::from_secs(5)")
|
|
||||||
require("src/update_check.rs", "shutdown.cancelled()", "tokio::time::sleep(interval)")
|
|
||||||
|
|
||||||
print("PASS: bounded-memory transform/cache contracts and background-worker limits are present.")
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Source-only mobile/observed-backend UI invariants, no browser required."""
|
|
||||||
from pathlib import Path
|
|
||||||
root=Path(__file__).resolve().parents[2]
|
|
||||||
css=(root/'src/admin/web/style.css').read_text()
|
|
||||||
preferences=(root/'src/admin/web/app/preferences.js').read_text()
|
|
||||||
masonry=(root/'src/admin/web/app/overview-masonry.js').read_text()
|
|
||||||
html=(root/'src/admin/web/index.html').read_text()
|
|
||||||
render=(root/'src/admin/web/vhosts/render.js').read_text()
|
|
||||||
client=(root/'src/upstream/client.rs').read_text()
|
|
||||||
pool=(root/'src/balance/pool_view.rs').read_text()
|
|
||||||
assert 'touch-action: pan-y pinch-zoom' in css
|
|
||||||
assert 'overflow-anchor: none' in css
|
|
||||||
assert 'grid-template-columns: repeat(2, minmax(0, 1fr))' in css
|
|
||||||
assert '@container overview-canvas (max-width: 700px)' in css
|
|
||||||
assert 'overview-canvas (max-width: 440px)' not in css
|
|
||||||
assert '.vhost-backend-protocol-badge' in css
|
|
||||||
assert '.vhost-card' in css[css.index('html:is([data-corner-style=square]'):]
|
|
||||||
assert '.draft-dock,' not in css[css.index('html:is([data-corner-style=square]'):]
|
|
||||||
assert 'grid.append(...orderedNodes)' in preferences
|
|
||||||
assert 'orderedNodes.some((node, index) => node !== currentNodes[index])' in preferences
|
|
||||||
assert 'if (ordered.some((node, i) => node !== previous[i])) grid.append(...ordered);' in masonry
|
|
||||||
assert html.count('data-dashboard-widget=') >= 62
|
|
||||||
assert 'function renderUpstreamProtocolBadges(status)' in render
|
|
||||||
assert 'const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000' in render
|
|
||||||
# Protocol badges live in the existing Vhost policy-pill group, not above the
|
|
||||||
# live traffic metrics. The class and observed-color state remain unchanged.
|
|
||||||
policy_start=render.index('const policy = el("div", undefined, "vhost-policy-pills");')
|
|
||||||
policy_end=render.index('if (policy.children.length) box.append(policy);', policy_start)
|
|
||||||
policy_block=render[policy_start:policy_end]
|
|
||||||
assert 'policy.append(badge);' in policy_block
|
|
||||||
assert 'vhost-backend-protocol-badge' in policy_block
|
|
||||||
assert 'box.append(badges)' not in render
|
|
||||||
assert '.vhost-backend-protocol-badge[data-observed=true]' in css
|
|
||||||
assert 'color: var(--green)' in css[css.index('.vhost-backend-protocol-badge[data-observed=true]'):]
|
|
||||||
assert 'self.inner.observe_protocol(value.version())' in client
|
|
||||||
assert '"upstream_protocol_observed"' in pool
|
|
||||||
print('PASS: mobile grid, shape, stable reorder and observed backend protocol source contracts')
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Mobile Dashboard scroll/layout and appearance browser regression.
|
|
||||||
|
|
||||||
Uses the shipped static web assets in a Chromium touch-device emulation, not
|
|
||||||
physical Safari, the authenticated server or a running Rust backend.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
WEB = ROOT / 'src/admin/web'
|
|
||||||
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
|
|
||||||
widgets = soup.select('#dashboard-overview-grid > [data-dashboard-widget]')
|
|
||||||
assert len(widgets) == 62
|
|
||||||
widget_ids = [widget['data-dashboard-widget'] for widget in widgets]
|
|
||||||
css = (WEB / 'style.css').read_text()
|
|
||||||
source = (WEB / 'app/preferences.js').read_text()
|
|
||||||
layout_code = source.split('function applyDashboardLayout(order, hidden) {')[1].split('function dashboardWidgetLabel(')[0]
|
|
||||||
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact" '
|
|
||||||
'data-corner-style="default"><head><meta name="viewport" '
|
|
||||||
'content="width=device-width,initial-scale=1"><style>' + css +
|
|
||||||
'</style></head><body style="margin:0">'
|
|
||||||
'<div style="height:80px">Mobile navigation</div>'
|
|
||||||
'<section id="panel-overview" style="width:auto;padding:0 10px;box-sizing:border-box">'
|
|
||||||
'<div id="dashboard-overview-grid" class="stats-grid dashboard-overview-grid">' +
|
|
||||||
''.join(map(str, widgets)) + '</div></section>'
|
|
||||||
'<div id="radii" style="padding:10px"><article class="vhost-card" id="vhost-test">Vhost</article>'
|
|
||||||
'<div class="entity" id="entity-test">Entity</div>'
|
|
||||||
'<span class="vhost-backend-protocol-badge" id="badge-test">AUTO → H2</span></div>'
|
|
||||||
'</body></html>')
|
|
||||||
|
|
||||||
with sync_playwright() as browser_tool:
|
|
||||||
chromium = browser_tool.chromium.launch(headless=True,
|
|
||||||
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
|
|
||||||
args=['--no-sandbox'])
|
|
||||||
context = chromium.new_context(viewport={'width': 393, 'height': 844},
|
|
||||||
device_scale_factor=3, is_mobile=True, has_touch=True)
|
|
||||||
page = context.new_page()
|
|
||||||
page.set_content(html, wait_until='domcontentloaded')
|
|
||||||
page.add_script_tag(content=(WEB / 'app/overview-masonry.js').read_text())
|
|
||||||
preference_fixture = (
|
|
||||||
'const ADMIN_DASHBOARD_WIDGETS=' + json.dumps(widget_ids) + ';' +
|
|
||||||
'const ADMIN_DEFAULT_HIDDEN_WIDGETS=[];' +
|
|
||||||
'const latestStatus=null; const currentPanel="overview";' +
|
|
||||||
'const normalizeDashboardWidgets=(ids)=>ids;' +
|
|
||||||
'const normalizeHiddenDashboardWidgets=(ids)=>ids;' +
|
|
||||||
'function applyDashboardLayout(order, hidden) {' + layout_code
|
|
||||||
)
|
|
||||||
page.add_script_tag(content=preference_fixture)
|
|
||||||
for width in (320, 375, 393, 402, 430, 550):
|
|
||||||
page.set_viewport_size({'width': width, 'height': 844})
|
|
||||||
snapshot = page.evaluate('''() => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid');
|
|
||||||
const cards=[...grid.querySelectorAll('[data-dashboard-widget].stat')]
|
|
||||||
.filter(node => !node.hidden).slice(0,6).map(node => {
|
|
||||||
const r=node.getBoundingClientRect();
|
|
||||||
return {left:r.left,top:r.top,right:r.right,bottom:r.bottom,width:r.width};
|
|
||||||
});
|
|
||||||
return {columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,
|
|
||||||
cards,gridWidth:grid.clientWidth,gridScroll:grid.scrollWidth,
|
|
||||||
screenScroll:document.documentElement.scrollWidth,
|
|
||||||
screen:innerWidth,
|
|
||||||
touch:getComputedStyle(grid.querySelector('.stat')).touchAction};
|
|
||||||
}''')
|
|
||||||
a,b,c,d,e,f = snapshot['cards']
|
|
||||||
assert snapshot['columns'] == 2, (width, snapshot)
|
|
||||||
assert abs(a['top']-b['top']) < 1 and abs(c['top']-d['top']) < 1
|
|
||||||
assert abs(e['top']-f['top']) < 1 and c['top'] > a['top'] and e['top'] > c['top']
|
|
||||||
assert b['left'] >= a['right'] and d['left'] >= c['right']
|
|
||||||
assert a['width'] > 50 and b['width'] > 50
|
|
||||||
assert snapshot['gridScroll'] <= snapshot['gridWidth']+2, (width,snapshot)
|
|
||||||
assert snapshot['screenScroll'] <= snapshot['screen']+2, (width,snapshot)
|
|
||||||
assert 'pan-y' in snapshot['touch']
|
|
||||||
action = page.evaluate('''() => {
|
|
||||||
const tile=document.querySelector('.stat-with-action');
|
|
||||||
const label=tile.querySelector(':scope > span').getBoundingClientRect();
|
|
||||||
const button=tile.querySelector('.stat-corner-action').getBoundingClientRect();
|
|
||||||
const value=tile.querySelector('strong').getBoundingClientRect();
|
|
||||||
const card=tile.getBoundingClientRect();
|
|
||||||
return {labelBottom:label.bottom,buttonTop:button.top,
|
|
||||||
buttonBottom:button.bottom,valueTop:value.top,
|
|
||||||
buttonRight:button.right,cardRight:card.right};
|
|
||||||
}''')
|
|
||||||
assert action['buttonTop'] >= action['labelBottom'] - 2, (width,action)
|
|
||||||
assert action['valueTop'] >= action['buttonBottom'] - 2, (width,action)
|
|
||||||
assert action['buttonRight'] <= action['cardRight'] + 1, (width,action)
|
|
||||||
page.evaluate('window.scrollTo(0,350)')
|
|
||||||
before = page.evaluate('window.scrollY')
|
|
||||||
assert before > 0, width
|
|
||||||
page.evaluate('''() => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid');
|
|
||||||
window.rustProxyMasonry.refresh();
|
|
||||||
for(const [index,node] of [...grid.querySelectorAll('.stat strong')].entries()) {
|
|
||||||
node.textContent=index%2 ? '26216' : '49,750';
|
|
||||||
}
|
|
||||||
}''')
|
|
||||||
after = page.evaluate('window.scrollY')
|
|
||||||
assert abs(after - before) <= 2, (width, before, after)
|
|
||||||
# Reapplying an unchanged preference must NOT reparent the grid children.
|
|
||||||
moves = page.evaluate('''() => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid');
|
|
||||||
const order=[...grid.children].map(n=>n.dataset.dashboardWidget);
|
|
||||||
let moves=0;
|
|
||||||
const observer=new MutationObserver(items => { moves+=items.filter(i=>i.type==='childList').length; });
|
|
||||||
observer.observe(grid,{childList:true});
|
|
||||||
applyDashboardLayout(order,[]);
|
|
||||||
observer.takeRecords().forEach(i=>{if(i.type==='childList')moves++});
|
|
||||||
observer.disconnect();
|
|
||||||
return moves;
|
|
||||||
}''')
|
|
||||||
assert moves == 0, (width, moves)
|
|
||||||
assert abs(page.evaluate('window.scrollY')-before) <= 2
|
|
||||||
print(f'PASS: {width}px touch emulation, two metric columns, no overflow or scroll jumps')
|
|
||||||
for style,surface,control in (('square','0px','0px'), ('subtle','5px','4px'),
|
|
||||||
('rounded','12px','8px'), ('extra','18px','12px')):
|
|
||||||
page.evaluate('(s) => document.documentElement.dataset.cornerStyle=s', style)
|
|
||||||
for selector in ('#vhost-test','#entity-test','.stat'):
|
|
||||||
radius = page.locator(selector).first.evaluate('node=>getComputedStyle(node).borderTopLeftRadius')
|
|
||||||
assert radius == surface, (style,selector,radius)
|
|
||||||
radius = page.locator('#badge-test').evaluate('node=>getComputedStyle(node).borderTopLeftRadius')
|
|
||||||
assert radius == control, (style,'origin protocol badge',radius)
|
|
||||||
print('PASS: vhost, entity, badge/control and metric corner presets')
|
|
||||||
chromium.close()
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Real-source simulation of mobile tab restore, BFCache, Origin 403 and retry.
|
|
||||||
// This does not replace a real Safari/Chrome session or a compiled Rust backend.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
import { dirname, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
||||||
const read = path => readFileSync(resolve(root, path), 'utf8');
|
|
||||||
const core = read('src/admin/web/app/core.js');
|
|
||||||
const refresh = read('src/admin/web/app/refresh.js');
|
|
||||||
const resume = read('src/admin/web/app/session-resume.js');
|
|
||||||
const runtime = read('src/admin/web/app/runtime.js');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const server = read('src/admin/session.rs');
|
|
||||||
assert.match(html, /id="app-session-retry"[^>]*data-i18n="sessionRetry"/);
|
|
||||||
assert.match(server, /origin_mismatch/);
|
|
||||||
assert.match(server, /cross_site_fetch/);
|
|
||||||
assert.match(server, /administration origin rejected/);
|
|
||||||
|
|
||||||
function sourceBetween(source, start, end) {
|
|
||||||
const a = source.indexOf(start), b = source.indexOf(end, a);
|
|
||||||
assert(a >= 0 && b > a, `Missing source slice: ${start}`);
|
|
||||||
return source.slice(a, b);
|
|
||||||
}
|
|
||||||
const actualApi = sourceBetween(core, 'function apiArea(path)', 'function setPanel(name');
|
|
||||||
const actualLive = sourceBetween(core, 'function renderLiveStatus(value)', 'async function refreshCertificateInventory()');
|
|
||||||
const actualResume = resume;
|
|
||||||
const recovery = sourceBetween(core, 'const ORIGIN_RECOVERY_KEY', 'const PANEL_PATHS');
|
|
||||||
const startup = sourceBetween(runtime, 'function verifyAdminModules()', 'function certificateUsesExternalFiles');
|
|
||||||
|
|
||||||
function fixture() {
|
|
||||||
const events = new Map(), windows = new Map(), pending = [], streams = [], nodes = new Map(), timers = new Map();
|
|
||||||
const classes = new Set(), originMarker = new Map();
|
|
||||||
let timerId = 0, reloads = 0, lockCount = 0, renderSessionCalls = 0;
|
|
||||||
const node = id => {
|
|
||||||
if (!nodes.has(id)) {
|
|
||||||
const handlers = new Map();
|
|
||||||
nodes.set(id, { hidden: id !== 'workspace', disabled: false, textContent: '',
|
|
||||||
addEventListener(type, fn) { handlers.set(type, fn); },
|
|
||||||
click() { handlers.get('click')?.(); },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return nodes.get(id);
|
|
||||||
};
|
|
||||||
class FakeEventSource {
|
|
||||||
constructor(url) { this.url = url; this.closed = false; streams.push(this); }
|
|
||||||
addEventListener() {}
|
|
||||||
close() { this.closed = true; }
|
|
||||||
}
|
|
||||||
const classList = {
|
|
||||||
contains(k) { return classes.has(k); },
|
|
||||||
add(...keys) { keys.forEach(k => classes.add(k)); },
|
|
||||||
remove(...keys) { keys.forEach(k => classes.delete(k)); },
|
|
||||||
toggle(k, forced) { if (forced) classes.add(k); else classes.delete(k); },
|
|
||||||
};
|
|
||||||
const document = {
|
|
||||||
hidden: false, body: {}, documentElement: { classList },
|
|
||||||
addEventListener(name, handler) { assert(!events.has(name)); events.set(name, handler); },
|
|
||||||
removeEventListener(name, handler) { assert.equal(events.get(name), handler); events.delete(name); },
|
|
||||||
};
|
|
||||||
const window = {
|
|
||||||
rustProxySettings: { current: () => 'listeners', render() {} },
|
|
||||||
addEventListener(name, handler) { assert(!windows.has(name)); windows.set(name, handler); },
|
|
||||||
removeEventListener(name, handler) { assert.equal(windows.get(name), handler); windows.delete(name); },
|
|
||||||
};
|
|
||||||
const ctx = {
|
|
||||||
document, window, $: node, AbortController, EventSource: FakeEventSource,
|
|
||||||
MutationObserver: class { observe() {} },
|
|
||||||
location: { origin: 'https://panel.example', pathname: '/overview', search: '',
|
|
||||||
reload() { reloads++; }, replace() { throw new Error('Unexpected replace'); } },
|
|
||||||
sessionStorage: { getItem: key => originMarker.get(key), setItem: (key, value) => originMarker.set(key, value), removeItem: key => originMarker.delete(key) },
|
|
||||||
performance: { now: () => 1 },
|
|
||||||
fetch: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
|
|
||||||
setTimeout(fn, ms) { const id = ++timerId; timers.set(id, { fn, ms }); return id; },
|
|
||||||
clearTimeout(id) { timers.delete(id); },
|
|
||||||
console: { warn() {} },
|
|
||||||
csrf: 'old', authenticated: true, authEpoch: 1, sessionInfo: null, sessionCheckPromise: null,
|
|
||||||
languagePacks: new Map([['en_US', {}]]), latestStatus: null,
|
|
||||||
currentPanel: 'overview', liveEvents: null, liveInterval: null, liveConnected: false, liveFallback: false,
|
|
||||||
t: key => key === 'sessionValidationFailed' ? 'Could not verify your session' : key,
|
|
||||||
renderSession() { renderSessionCalls++; },
|
|
||||||
renderFooter() {}, renderStatus() {}, renderLogs() {}, renderDiagnostics() {},
|
|
||||||
toast() { throw new Error('Resume errors must not be transient toasts'); },
|
|
||||||
loadTunnels: async () => {},
|
|
||||||
lock() { lockCount++; ctx.authEpoch++; ctx.authenticated = false; ctx.window.rustProxyRefresh.stop(); },
|
|
||||||
};
|
|
||||||
runInNewContext(`${recovery}\n${actualApi}\n${actualLive}\n${actualResume}\n${refresh}`, ctx,
|
|
||||||
{ filename: 'actual-rustproxy-admin-source.js' });
|
|
||||||
const tick = async () => { for (let i = 0; i < 25; i++) await Promise.resolve(); };
|
|
||||||
const answer = (status, value) => {
|
|
||||||
const request = pending.shift();
|
|
||||||
assert(request && request.path === '/api/auth/session', `Unexpected request: ${request?.path}`);
|
|
||||||
request.resolve({ ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(value) });
|
|
||||||
return request;
|
|
||||||
};
|
|
||||||
const visibility = hidden => { document.hidden = hidden; events.get('visibilitychange')?.(); };
|
|
||||||
const pagehide = () => windows.get('pagehide')?.({});
|
|
||||||
const pageshow = persisted => windows.get('pageshow')?.({ persisted });
|
|
||||||
const activeStreams = () => streams.filter(stream => !stream.closed);
|
|
||||||
return { ctx, events, windows, pending, streams, timers, originMarker, nodes, classes, tick, answer,
|
|
||||||
visibility, pagehide, pageshow, activeStreams, node, get reloads() { return reloads; },
|
|
||||||
get lockCount() { return lockCount; }, get renderSessionCalls() { return renderSessionCalls; } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const normal = fixture();
|
|
||||||
normal.ctx.window.rustProxyRefresh.start();
|
|
||||||
assert.equal(normal.activeStreams().length, 1, 'authenticated shell starts one SSE stream');
|
|
||||||
normal.visibility(true);
|
|
||||||
assert.equal(normal.activeStreams().length, 0, 'hidden document pauses SSE');
|
|
||||||
normal.visibility(false);
|
|
||||||
normal.pageshow(true);
|
|
||||||
assert.equal(normal.pending.length, 1, 'pageshow and visibilitychange share one session GET');
|
|
||||||
assert.equal(normal.activeStreams().length, 0, 'SSE cannot resume before session verification');
|
|
||||||
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
|
|
||||||
normal.answer(403, { error: 'auth.invalid_origin' }); await normal.tick();
|
|
||||||
assert.equal(normal.reloads, 0, '403 during resume must not reload');
|
|
||||||
assert.equal(normal.originMarker.size, 0, 'resume must not update startup reload limit');
|
|
||||||
assert(normal.classes.has('app-loading') && normal.classes.has('app-startup-failed'));
|
|
||||||
assert(normal.classes.has('app-session-failed'), 'show an actionable resume failure');
|
|
||||||
assert.equal(normal.node('workspace').hidden, true);
|
|
||||||
assert.equal(normal.node('app-session-retry').hidden, false);
|
|
||||||
assert.equal(normal.node('app-startup-reload').hidden, true);
|
|
||||||
assert.equal(normal.node('app-startup-title').textContent, 'Could not verify your session');
|
|
||||||
assert.equal(normal.activeStreams().length, 0);
|
|
||||||
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
|
|
||||||
normal.pageshow(true);
|
|
||||||
assert.equal(normal.pending.length, 0, 'no automatic error-loop retries on duplicate resume events');
|
|
||||||
normal.node('app-session-retry').click();
|
|
||||||
assert.equal(normal.pending.length, 1, 'user may explicitly retry without a reload');
|
|
||||||
assert.equal(normal.node('app-session-retry').disabled, true);
|
|
||||||
normal.answer(200, { username: 'admin', csrf_token: 'fresh' }); await normal.tick();
|
|
||||||
assert.equal(normal.ctx.csrf, 'fresh');
|
|
||||||
assert.equal(normal.node('workspace').hidden, false);
|
|
||||||
assert.equal(normal.classes.has('app-loading'), false);
|
|
||||||
assert.equal(normal.node('app-session-retry').hidden, true);
|
|
||||||
assert.equal(normal.node('app-session-retry').disabled, false);
|
|
||||||
assert.equal(normal.activeStreams().length, 1, 'SSE resumes only after verified session');
|
|
||||||
assert.equal(normal.reloads, 0);
|
|
||||||
|
|
||||||
// A real 401 follows the existing lock/redirect flow, never displaying stale data.
|
|
||||||
normal.visibility(true);
|
|
||||||
normal.visibility(false);
|
|
||||||
assert.equal(normal.pending.length, 1);
|
|
||||||
normal.answer(401, { error: 'auth.required' }); await normal.tick();
|
|
||||||
assert.equal(normal.lockCount, 1);
|
|
||||||
assert.equal(normal.activeStreams().length, 0);
|
|
||||||
assert.equal(normal.classes.has('app-session-failed'), false);
|
|
||||||
|
|
||||||
const restored = fixture();
|
|
||||||
restored.ctx.window.rustProxyRefresh.start();
|
|
||||||
restored.pagehide();
|
|
||||||
assert.equal(restored.activeStreams().length, 0);
|
|
||||||
restored.pageshow(true);
|
|
||||||
restored.visibility(false);
|
|
||||||
assert.equal(restored.pending.length, 1, 'BFCache pagehide/pageshow + visibility triggers one session check');
|
|
||||||
restored.answer(200, { username: 'admin', csrf_token: 'bfcache' }); await restored.tick();
|
|
||||||
assert.equal(restored.activeStreams().length, 1);
|
|
||||||
// Ten tab cycles must not add listeners or duplicate SSE sessions.
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
restored.visibility(true); restored.visibility(false); restored.pageshow(true);
|
|
||||||
assert.equal(restored.pending.length, 1);
|
|
||||||
assert.equal(restored.activeStreams().length, 0);
|
|
||||||
restored.answer(200, { username: 'admin', csrf_token: `cycle-${i}` }); await restored.tick();
|
|
||||||
assert.equal(restored.activeStreams().length, 1);
|
|
||||||
assert.equal(restored.windows.size, 2);
|
|
||||||
assert.equal(restored.events.size, 1);
|
|
||||||
}
|
|
||||||
// A response from a prior auth epoch may not restore the dashboard.
|
|
||||||
restored.visibility(true); restored.visibility(false);
|
|
||||||
assert.equal(restored.pending.length, 1);
|
|
||||||
restored.ctx.authEpoch++;
|
|
||||||
restored.ctx.authenticated = false;
|
|
||||||
restored.ctx.window.rustProxyRefresh.stop();
|
|
||||||
restored.answer(200, { username: 'stale', csrf_token: 'stale' }); await restored.tick();
|
|
||||||
assert.equal(restored.activeStreams().length, 0);
|
|
||||||
assert.equal(restored.ctx.csrf, 'cycle-9');
|
|
||||||
assert.equal(restored.windows.size, 0);
|
|
||||||
assert.equal(restored.events.size, 0);
|
|
||||||
|
|
||||||
// Startup reload-marker is released only after the complete panel activation,
|
|
||||||
// never merely when /api/auth/session happened to return successfully.
|
|
||||||
async function checkStartup(activationResult, failLanguage) {
|
|
||||||
let bootstrap, clears = 0, failures = 0;
|
|
||||||
const modules = Object.fromEntries([
|
|
||||||
['rustProxyIntegrations', 'bindings'], ['rustProxyVhosts', 'render'], ['rustProxySettings', 'render'],
|
|
||||||
['rustProxyCachePanel', 'refresh'], ['rustProxyTrafficPanel', 'refresh'],
|
|
||||||
['rustProxyPreferences', 'load'], ['rustProxyRefresh', 'start'],
|
|
||||||
].map(([name, method]) => [name, { [method]() {} }]));
|
|
||||||
const ctx = {
|
|
||||||
window: modules, document: { readyState: 'loading', addEventListener(_name, cb) { bootstrap = cb; } },
|
|
||||||
api: async () => ({ csrf_token: 'valid' }), loadLanguages: async () => { if (failLanguage) throw new Error('Language assets missing'); },
|
|
||||||
translate() {}, setupRequired: false, syncAuthForm() {}, activate: async () => activationResult,
|
|
||||||
clearInvalidOriginRecovery() { clears++; }, recoverInvalidOrigin: () => false,
|
|
||||||
showStartupFailure() { failures++; },
|
|
||||||
location: { pathname: '/overview', search: '', replace() { throw new Error('unexpected redirect'); } },
|
|
||||||
};
|
|
||||||
runInNewContext(startup, ctx);
|
|
||||||
bootstrap(); for (let i = 0; i < 12; i++) await Promise.resolve();
|
|
||||||
return { clears, failures };
|
|
||||||
}
|
|
||||||
assert.deepEqual(await checkStartup(true, false), { clears: 1, failures: 0 });
|
|
||||||
assert.deepEqual(await checkStartup(false, false), { clears: 0, failures: 0 });
|
|
||||||
assert.deepEqual(await checkStartup(true, true), { clears: 0, failures: 1 });
|
|
||||||
console.log('PASS: 0.31.3 real-source resume race, 403 retry, BFCache, 401 lock, SSE pause, stale epochs, startup recovery limit and repeated cycles');
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// 0.32.1 regression: restore pre-0.32.0 content-driven tile heights and
|
|
||||||
// align inter-tile gaps with the same spacing as other Dashboard sections.
|
|
||||||
// These are source/CSS contracts, not a browser or authenticated-UI test.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
|
|
||||||
function rule(selector) {
|
|
||||||
// Target precise blocks rather than accepting an unrelated matching rule.
|
|
||||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const match = css.match(new RegExp(`${escaped}\\s*\\{([^{}]*)\\}`));
|
|
||||||
assert(match, `Missing CSS rule: ${selector}`);
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const density of ['normal', 'compact', 'ultra']) {
|
|
||||||
const declarations = rule(`html[data-dashboard-density="${density}"] #panel-overview .stats-grid`);
|
|
||||||
assert.match(declarations, /gap:\s*var\(--ui-gap\)\s*;/,
|
|
||||||
`${density} tiles must use the same density-aware gap as Dashboard sections`);
|
|
||||||
}
|
|
||||||
const sharedGap = rule('.overview-grid,\n.diagnostics-grid,\n.settings-grid,\n.config-columns');
|
|
||||||
assert.match(sharedGap, /gap:\s*var\(--ui-gap\)/);
|
|
||||||
for (const density of ['dense', 'comfortable', 'compact']) {
|
|
||||||
assert.match(css, new RegExp(`body\\[data-density="${density}"\\]\\s*\\{[^}]*--ui-gap:`),
|
|
||||||
`UI density ${density} must provide the shared gap variable`);
|
|
||||||
}
|
|
||||||
assert.match(css, /@media \(min-width: 1201px\)\s*\{\s*#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/,
|
|
||||||
'six desktop columns are preserved');
|
|
||||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
|
|
||||||
'desktop cards should grow naturally to their legend/content height');
|
|
||||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
|
|
||||||
'card descriptions must remain visible, not hover-only');
|
|
||||||
|
|
||||||
const heading = rule('html[data-dashboard-density="compact"] #panel-overview .stat>span');
|
|
||||||
const metric = rule('html[data-dashboard-density="compact"] #panel-overview .stat strong');
|
|
||||||
const caption = rule('html[data-dashboard-density="compact"] #panel-overview .stat small');
|
|
||||||
const payload = rule('html[data-dashboard-density="compact"] #panel-overview .payload-split');
|
|
||||||
assert.match(heading, /font-size:\s*11px/);
|
|
||||||
assert.match(metric, /font-size:\s*25px/);
|
|
||||||
assert.match(caption, /font-size:\s*10px/);
|
|
||||||
assert.match(payload, /font-size:\s*10px\s*!important/);
|
|
||||||
assert.match(css, /#panel-overview \.stats-grid \.stat-chart>\.stat-chart-legend\s*\{[^}]*flex-wrap:\s*wrap/s);
|
|
||||||
|
|
||||||
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"'));
|
|
||||||
const widgets = [...overview.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(match => match[1]);
|
|
||||||
assert.equal(widgets.length, 62, '49 existing widgets plus 6 small and 7 large opt-in charts');
|
|
||||||
assert.equal(widgets.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_') && !id.startsWith('wide_') && !['traffic_chart','system_chart','connection_chart','cache_diagnostics','process_diagnostics','network_diagnostics','backend_table'].includes(id)).length, 16,
|
|
||||||
'all 16 pre-existing metric/Stream cards must be retained');
|
|
||||||
assert.match(overview, /id="metric-bytes-split"/);
|
|
||||||
assert.match(overview, /id="http-protocol-chart"/);
|
|
||||||
assert.match(overview, /id="ws-protocol-chart"/);
|
|
||||||
assert.match(overview, /id="dashboard-overview-grid"/);
|
|
||||||
assert.doesNotMatch(overview, /class="overview-grid"|class="diagnostics-grid"/, 'all Overview widgets must share one arrangement grid');
|
|
||||||
console.log('PASS: 0.32.1 Overview six-column content height, 90% compact typography, shared tile spacing and inline legends.');
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// Existing status snapshots only: no API calls, bounded histories and honest no-data.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const source = fs.readFileSync('src/admin/web/app/overview-extra-metrics.js', 'utf8');
|
|
||||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
|
|
||||||
const ids = registry.filter(widget => widget.category === 'performance').map(widget => widget.id);
|
|
||||||
assert.equal(ids.length, 18);
|
|
||||||
const widgets = new Map(ids.map(id => [id, { hidden: false }]));
|
|
||||||
const fields = new Map();
|
|
||||||
const field = id => {
|
|
||||||
if (!fields.has(id)) fields.set(id, { textContent: '-', points: '', setAttribute(name, value) { this[name] = value; } });
|
|
||||||
return fields.get(id);
|
|
||||||
};
|
|
||||||
let now = 1000;
|
|
||||||
const context = {
|
|
||||||
window: {}, currentPanel: 'overview', performance: { now: () => now },
|
|
||||||
t: key => key, fmt: (v, places = 0) => Number(v).toFixed(places),
|
|
||||||
fmtBytes: v => `${v} B`, $: field,
|
|
||||||
document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
|
|
||||||
};
|
|
||||||
vm.createContext(context);
|
|
||||||
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
|
|
||||||
vm.runInContext(source, context);
|
|
||||||
const render = context.window.rustProxyExtraOverview.render;
|
|
||||||
const text = id => field(`overview-extra-${id}`).textContent;
|
|
||||||
const points = id => field(`overview-extra-${id}-line`).points;
|
|
||||||
const start = {
|
|
||||||
metrics: { requests: 100, http1_requests: 70, http2_requests: 30, http3_requests: 0, tls_handshake_errors: 3, errors: 10, active_connections: 5, active: 2, websocket_active: 1, mean_duration_ms: 25 },
|
|
||||||
system: { process_cpu_percent: 12, process_rss_bytes: 1000, open_fds: 10, max_fds: 500 },
|
|
||||||
cache: { hits: 10, misses: 10, resident_bytes: 10, hot_limit_bytes: 100 },
|
|
||||||
backend_pools: [{ members: [{status: 'up'}, {status: 'down'}] }],
|
|
||||||
};
|
|
||||||
render(start);
|
|
||||||
assert.equal(text('extra_process_cpu'), '12.0%');
|
|
||||||
assert.equal(text('extra_process_rss'), '1000 B');
|
|
||||||
assert.equal(text('extra_backend_unhealthy'), '1');
|
|
||||||
for (const id of ['extra_tls_error_rate','extra_request_error_rate','extra_cache_hit_rate','extra_h2_share'])
|
|
||||||
assert.equal(text(id), '-', `initial ${id} delta must not be invented`);
|
|
||||||
now = 3000;
|
|
||||||
const next = structuredClone(start);
|
|
||||||
next.metrics = { ...start.metrics, requests: 130, http1_requests: 84, http2_requests: 45, http3_requests: 1, tls_handshake_errors: 5, errors: 16, active_connections: 6, active: 4, websocket_active: 3, mean_duration_ms: 40 };
|
|
||||||
next.system = { ...start.system, process_cpu_percent: 22, process_rss_bytes: 2000, open_fds: 20 };
|
|
||||||
next.cache = { ...start.cache, hits: 20, misses: 15, resident_bytes: 25 };
|
|
||||||
next.backend_pools[0].members[0].status = 'down';
|
|
||||||
render(next);
|
|
||||||
assert.equal(text('extra_tls_error_rate'), '1.0/s');
|
|
||||||
assert.equal(text('extra_request_error_rate'), '3.0/s');
|
|
||||||
assert.equal(text('extra_cache_hit_rate'), '66.7%');
|
|
||||||
assert.equal(text('extra_h2_share'), '50.0%');
|
|
||||||
assert.equal(text('extra_cache_memory'), '25.0%');
|
|
||||||
assert.equal(text('extra_response_latency'), '40.0 ms');
|
|
||||||
assert.equal(text('extra_backend_unhealthy'), '2');
|
|
||||||
assert.equal(text('extra_total_rps'), '15.0/s');
|
|
||||||
assert.equal(text('extra_active_exchanges'), '4');
|
|
||||||
assert.equal(text('extra_h1_share'), '46.7%');
|
|
||||||
assert.equal(text('extra_h3_share'), '3.3%');
|
|
||||||
assert.equal(text('extra_cache_miss_rate'), '2.5/s');
|
|
||||||
assert.equal(text('extra_backend_active'), '0');
|
|
||||||
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 2);
|
|
||||||
// Hiding a chart stops sampling; stale cumulative counters are never used as an instant rate.
|
|
||||||
widgets.get('extra_tls_error_rate').hidden = true;
|
|
||||||
now = 5000;
|
|
||||||
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 50 } });
|
|
||||||
assert.equal(text('extra_tls_error_rate'), '1.0/s');
|
|
||||||
widgets.get('extra_tls_error_rate').hidden = false;
|
|
||||||
now = 6000;
|
|
||||||
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 60 } });
|
|
||||||
assert.equal(text('extra_tls_error_rate'), '-', 'first fresh visible sample establishes a new baseline');
|
|
||||||
// After a reset, don't return a negative rate or denominator percentage.
|
|
||||||
now = 7000;
|
|
||||||
render({ ...next, metrics: { ...next.metrics, requests: 1, http2_requests: 0, tls_handshake_errors: 0, errors: 0 }, cache: { ...next.cache, hits: 0, misses: 0 } });
|
|
||||||
assert.equal(text('extra_h2_share'), '-');
|
|
||||||
assert.equal(text('extra_request_error_rate'), '-');
|
|
||||||
assert.equal(text('extra_tls_error_rate'), '-');
|
|
||||||
// Explicit unavailability is not represented as a measured zero.
|
|
||||||
now = 8000;
|
|
||||||
render({ metrics: {}, system: {}, cache: {} });
|
|
||||||
assert.equal(text('extra_process_cpu'), '-');
|
|
||||||
assert.equal(text('extra_websocket_active'), '-');
|
|
||||||
assert.equal(text('extra_backend_unhealthy'), '-');
|
|
||||||
context.window.rustProxyExtraOverview.reset();
|
|
||||||
now = 9000;
|
|
||||||
for (let index = 0; index < 50; index++) {
|
|
||||||
now += 1000;
|
|
||||||
render({ ...next, system: { ...next.system, process_cpu_percent: index } });
|
|
||||||
}
|
|
||||||
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 30, 'bounded history');
|
|
||||||
context.currentPanel = 'vhosts';
|
|
||||||
const pointBefore = points('extra_process_cpu');
|
|
||||||
now += 1000;
|
|
||||||
render(next);
|
|
||||||
assert.equal(points('extra_process_cpu'), pointBefore, 'no extra sampling outside Overview');
|
|
||||||
assert.match(source, /if \(!tile \|\| tile.hidden\)/);
|
|
||||||
console.log('PASS: 18 extra metric series, time-based deltas, no-data, counter resets, hidden opt-ins and 30-sample bounds');
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Overview row symmetry regression using the shipped CSS/JS and simulated status.
|
|
||||||
|
|
||||||
Browser DOM geometry only; does not exercise the running RustProxy backend.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2] / 'src/admin/web'
|
|
||||||
soup = BeautifulSoup((ROOT / 'index.html').read_text(), 'html.parser')
|
|
||||||
widgets = list(soup.select('#dashboard-overview-grid > [data-dashboard-widget]'))
|
|
||||||
assert len(widgets) == 62
|
|
||||||
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
|
|
||||||
'<head><meta charset="utf-8"><style>' + (ROOT / 'style.css').read_text() +
|
|
||||||
'</style></head><body style="margin:0"><div id="test-shell" '
|
|
||||||
'style="padding:8px;box-sizing:border-box"><div class="brand">'
|
|
||||||
'<div class="brand-mark" id="logo-test">R<span>P</span></div></div>'
|
|
||||||
'<section id="panel-overview"><div id="dashboard-overview-grid" '
|
|
||||||
'class="stats-grid dashboard-overview-grid">' +
|
|
||||||
''.join(str(widget) for widget in widgets) + '</div></section></div></body></html>')
|
|
||||||
|
|
||||||
SNAPSHOT = """() => {
|
|
||||||
const grid = document.getElementById('dashboard-overview-grid');
|
|
||||||
const style = getComputedStyle(grid), outer = grid.getBoundingClientRect();
|
|
||||||
const group = window.rustProxyMasonry.groupFor;
|
|
||||||
return {
|
|
||||||
packed: grid.classList.contains('is-packed'),
|
|
||||||
gap: parseFloat(style.columnGap),
|
|
||||||
columns: style.gridTemplateColumns.trim().split(/\\s+/).length,
|
|
||||||
grid: {left:outer.left,right:outer.right,width:outer.width},
|
|
||||||
scroll: grid.scrollWidth, client: grid.clientWidth,
|
|
||||||
cards: [...grid.children].filter(node=>node.dataset.dashboardWidget && !node.hidden).map(node=>{
|
|
||||||
const r=node.getBoundingClientRect();return {id:node.dataset.dashboardWidget,
|
|
||||||
group:group(node.dataset.dashboardWidget),x:r.left,y:r.top,right:r.right,
|
|
||||||
bottom:r.bottom,width:r.width,height:r.height};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}"""
|
|
||||||
|
|
||||||
|
|
||||||
def validate(data, zoom, width):
|
|
||||||
"""Peers in each row must have one top edge and one bottom edge."""
|
|
||||||
cards = data['cards']; gap = data['gap'] * zoom
|
|
||||||
assert cards and not data['packed'], (width, 'absolute/masonry mode is forbidden')
|
|
||||||
assert data['scroll'] <= data['client'] + 2, (width, 'horizontal overflow')
|
|
||||||
seen_groups = []
|
|
||||||
rows = {}
|
|
||||||
for card in cards:
|
|
||||||
assert card['width'] > 40 and card['height'] > 26, (width, card)
|
|
||||||
assert card['x'] >= data['grid']['left'] - 1, (width, card)
|
|
||||||
assert card['right'] <= data['grid']['right'] + 1, (width, card)
|
|
||||||
if not seen_groups or seen_groups[-1] != card['group']:
|
|
||||||
assert card['group'] not in seen_groups, (width, 'noncontiguous size groups')
|
|
||||||
seen_groups.append(card['group'])
|
|
||||||
row = round(card['y'], 1)
|
|
||||||
rows.setdefault(row, []).append(card)
|
|
||||||
for members in rows.values():
|
|
||||||
assert len({m['group'] for m in members}) == 1, (width, 'mixed sizes in a row', members)
|
|
||||||
assert max(m['bottom'] for m in members) - min(m['bottom'] for m in members) <= 1.5, (width, 'ragged bottom', members)
|
|
||||||
assert max(m['height'] for m in members) - min(m['height'] for m in members) <= 1.5, (width, 'uneven heights', members)
|
|
||||||
for a,b in zip(members,members[1:]):
|
|
||||||
assert abs(b['x']-a['right']-gap) <= 2.0, (width,'wrong horizontal gutter',a,b,gap)
|
|
||||||
line_groups = list(rows.values())
|
|
||||||
for above, below in zip(line_groups,line_groups[1:]):
|
|
||||||
a = above[0]; b=below[0]
|
|
||||||
assert b['y'] >= max(v['bottom'] for v in above) + gap - 2, (width,'vertical gap',a,b)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
with sync_playwright() as playwright:
|
|
||||||
browser = playwright.chromium.launch(headless=True,
|
|
||||||
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
|
|
||||||
args=['--no-sandbox'])
|
|
||||||
page = browser.new_page(viewport={'width':1720,'height':950})
|
|
||||||
page.set_content(html, wait_until='domcontentloaded')
|
|
||||||
page.add_script_tag(content=(ROOT / 'app/overview-masonry.js').read_text())
|
|
||||||
page.evaluate("""() => window._overviewOriginal = [...document.querySelectorAll(
|
|
||||||
'#dashboard-overview-grid > [data-dashboard-widget]')].map(n=>[n.dataset.dashboardWidget,n.hidden])""")
|
|
||||||
data=page.evaluate(SNAPSHOT)
|
|
||||||
assert data['columns']==12
|
|
||||||
assert [card['id'] for card in data['cards'][:6]] == [
|
|
||||||
'requests','active','bandwidth','errors','cache','memory']
|
|
||||||
validate(data,1,1720)
|
|
||||||
assert len({round(v['height']) for v in data['cards'][:6]}) == 1
|
|
||||||
page.locator('#dashboard-overview-grid').screenshot(
|
|
||||||
path=str(Path(__file__).parents[3] / 'overview_uniform_default_preview.png'))
|
|
||||||
|
|
||||||
# Screenshot regression: custom ordering previously produced 3 distinctly
|
|
||||||
# sized tiles on the same horizontal band (a metric + two tall charts).
|
|
||||||
ids=['backend_connection_errors','process_diagnostics','network_diagnostics','cache_diagnostics',
|
|
||||||
'connection_chart','traffic_chart','system_chart','backend_table',
|
|
||||||
'requests','active','http_protocol','websocket_protocol',
|
|
||||||
'wide_cpu','wide_memory']
|
|
||||||
page.evaluate("""ids => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid');
|
|
||||||
for(const node of grid.children) if(node.dataset.dashboardWidget)
|
|
||||||
node.hidden=!ids.includes(node.dataset.dashboardWidget);
|
|
||||||
grid.prepend(...ids.map(id=>grid.querySelector(`[data-dashboard-widget="${id}"]`)));
|
|
||||||
window.rustProxyMasonry.refresh();
|
|
||||||
}""", ids)
|
|
||||||
changed=page.evaluate(SNAPSHOT)
|
|
||||||
validate(changed,1,1720)
|
|
||||||
assert [card['group'] for card in changed['cards']] == sorted(card['group'] for card in changed['cards'])
|
|
||||||
assert not any(card['id']=='backend_connection_errors' and card['y'] == next(v['y'] for v in changed['cards'] if v['id']=='process_diagnostics') for card in changed['cards'])
|
|
||||||
# 3 uniform diagnostic panels per desktop row, 2 large charts per row.
|
|
||||||
panels=[card for card in changed['cards'] if card['group']==3]
|
|
||||||
assert len({round(card['y']) for card in panels[:3]}) == 1
|
|
||||||
assert len({round(card['height']) for card in panels[:3]}) == 1
|
|
||||||
wides=[card for card in changed['cards'] if card['group']==2]
|
|
||||||
assert len(wides)==2 and abs(wides[0]['height']-wides[1]['height']) < 2
|
|
||||||
page.screenshot(path=str(Path(__file__).parents[3] / 'overview_uniform_preview.png'), full_page=True)
|
|
||||||
|
|
||||||
# Runtime content growth automatically expands all peers in its Grid row,
|
|
||||||
# without ResizeObserver, manual coordinates or animation loops.
|
|
||||||
old_height=panels[0]['height']
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const panel=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
|
|
||||||
const child=document.createElement('div');child.id='expanded';child.style.height='175px';panel.append(child);
|
|
||||||
}""")
|
|
||||||
updated=page.evaluate(SNAPSHOT)
|
|
||||||
validate(updated,1,1720)
|
|
||||||
assert next(card for card in updated['cards'] if card['id']=='process_diagnostics')['height'] > old_height + 80
|
|
||||||
page.evaluate("() => document.getElementById('expanded').remove()")
|
|
||||||
validate(page.evaluate(SNAPSHOT),1,1720)
|
|
||||||
|
|
||||||
radii=page.evaluate("""() => {
|
|
||||||
const node=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
|
|
||||||
const bar=document.createElement('div');bar.className='overview-inline-toolbar';
|
|
||||||
bar.innerHTML='<button class="quiet overview-inline-up">←</button><button class="quiet overview-inline-hide">×</button>';
|
|
||||||
node.append(bar);const out=[];
|
|
||||||
for(const preset of ['default','square','subtle','rounded','extra']) {
|
|
||||||
document.documentElement.dataset.cornerStyle=preset;
|
|
||||||
out.push([getComputedStyle(document.getElementById('logo-test')).borderTopLeftRadius,
|
|
||||||
getComputedStyle(bar.querySelector('.overview-inline-up')).borderTopLeftRadius,
|
|
||||||
getComputedStyle(bar.querySelector('.overview-inline-hide')).borderTopLeftRadius]);
|
|
||||||
}bar.remove();return out;
|
|
||||||
}""")
|
|
||||||
assert len({item[0] for item in radii})==1
|
|
||||||
assert all(item[1]==item[2] for item in radii)
|
|
||||||
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid');
|
|
||||||
for(const node of grid.children) if(node.dataset.dashboardWidget) node.hidden=false;
|
|
||||||
window.rustProxyMasonry.refresh();
|
|
||||||
}""")
|
|
||||||
cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12),
|
|
||||||
(1200,260,1,'normal',6),(900,0,1,'compact',6),
|
|
||||||
(900,190,1,'normal',6),(720,190,1,'ultra',2),
|
|
||||||
(620,0,1,'compact',2),(430,0,1,'ultra',2),
|
|
||||||
(1140,200,.9,'compact',6),(1140,200,1.1,'normal',6),
|
|
||||||
(1140,200,1.3,'normal',6)]
|
|
||||||
for width,sidebar,zoom,density,max_columns in cases:
|
|
||||||
page.set_viewport_size({'width':width,'height':950})
|
|
||||||
page.evaluate("""({sidebar,zoom,density})=>{
|
|
||||||
const shell=document.getElementById('test-shell');
|
|
||||||
shell.style.width=`${(innerWidth-sidebar)/zoom}px`;
|
|
||||||
shell.style.marginLeft=`${sidebar}px`;shell.style.zoom=String(zoom);
|
|
||||||
document.documentElement.dataset.dashboardDensity=density;
|
|
||||||
window.rustProxyMasonry.refresh();
|
|
||||||
}""", {'sidebar':sidebar,'zoom':zoom,'density':density})
|
|
||||||
result=page.evaluate(SNAPSHOT)
|
|
||||||
assert len(result['cards'])==62
|
|
||||||
assert result['columns']<=max_columns, (width, result['columns'], max_columns)
|
|
||||||
validate(result,zoom,width)
|
|
||||||
print(f"PASS: {width}px, zoom={zoom}, groups={len(set(c['group'] for c in result['cards']))}, "
|
|
||||||
f"columns={result['columns']}, gap={result['gap']}px")
|
|
||||||
# Unlock mode adds editing toolbars/padding; rows must remain uniform.
|
|
||||||
page.set_viewport_size({'width':900,'height':950})
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid');
|
|
||||||
const shell=document.getElementById('test-shell');
|
|
||||||
shell.style.width='auto';shell.style.zoom='1';shell.style.marginLeft='0';
|
|
||||||
grid.classList.add('is-unlocked');
|
|
||||||
for(const node of grid.children) if(node.dataset.dashboardWidget) {
|
|
||||||
const toolbar=document.createElement('div');toolbar.className='overview-inline-toolbar';
|
|
||||||
toolbar.innerHTML='<button class="quiet overview-inline-grip">⠿</button>'+
|
|
||||||
'<span class="overview-widget-type">Wykres</span>'+
|
|
||||||
'<button class="quiet overview-inline-up">←</button>'+
|
|
||||||
'<button class="quiet overview-inline-down">→</button>'+
|
|
||||||
'<button class="quiet overview-inline-hide">×</button>';
|
|
||||||
node.append(toolbar);
|
|
||||||
}
|
|
||||||
}""")
|
|
||||||
validate(page.evaluate(SNAPSHOT),1,900)
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid');
|
|
||||||
grid.classList.remove('is-unlocked');
|
|
||||||
grid.querySelectorAll('.overview-inline-toolbar').forEach(toolbar=>toolbar.remove());
|
|
||||||
}""")
|
|
||||||
|
|
||||||
page.set_viewport_size({'width':1720,'height':950})
|
|
||||||
page.evaluate("""() => {
|
|
||||||
const grid=document.getElementById('dashboard-overview-grid'),shell=document.getElementById('test-shell');
|
|
||||||
shell.style.width='auto';shell.style.marginLeft='0';shell.style.zoom='1';
|
|
||||||
document.documentElement.dataset.dashboardDensity='compact';
|
|
||||||
grid.append(...window._overviewOriginal.map(([id,hidden])=>{
|
|
||||||
const node=grid.querySelector(`[data-dashboard-widget="${id}"]`);
|
|
||||||
node.hidden=hidden;return node;
|
|
||||||
}));window.rustProxyMasonry.refresh();
|
|
||||||
}""")
|
|
||||||
final=page.evaluate(SNAPSHOT)
|
|
||||||
validate(final,1,1720)
|
|
||||||
assert [node['id'] for node in final['cards'][:6]]==['requests','active','bandwidth','errors','cache','memory']
|
|
||||||
browser.close()
|
|
||||||
print('PASS: 62 cards in aligned rows; no floating heights, safe resize and zoom')
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Browser regression: compact, matching Overview gutters in both directions.
|
|
||||||
|
|
||||||
Fixture uses live CSS and the real grid grouping script, but mocked content.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[2] / "src/admin/web"
|
|
||||||
soup = BeautifulSoup((root / "index.html").read_text(), "html.parser")
|
|
||||||
widgets = soup.select("#dashboard-overview-grid > [data-dashboard-widget]")
|
|
||||||
assert len(widgets) == 62, len(widgets)
|
|
||||||
css = (root / "style.css").read_text()
|
|
||||||
html = ("<!DOCTYPE html><html data-theme='dark' data-dashboard-density='normal'>"
|
|
||||||
"<head><meta charset='utf-8'><style>" + css + "</style></head>"
|
|
||||||
"<body><section id='panel-overview' style='width:1680px'>"
|
|
||||||
"<div id='dashboard-overview-grid' class='stats-grid dashboard-overview-grid'>"
|
|
||||||
+ "".join(map(str, widgets)) + "</div></section></body></html>")
|
|
||||||
|
|
||||||
snapshot = """() => {
|
|
||||||
const grid = document.getElementById('dashboard-overview-grid');
|
|
||||||
const style = getComputedStyle(grid);
|
|
||||||
const parentGap = parseFloat(getComputedStyle(document.body).getPropertyValue('--ui-gap'));
|
|
||||||
const rows = [];
|
|
||||||
for (const node of grid.children) {
|
|
||||||
if (!node.dataset.dashboardWidget || node.hidden) continue;
|
|
||||||
const rect = node.getBoundingClientRect();
|
|
||||||
const group = window.rustProxyMasonry.groupFor(node.dataset.dashboardWidget);
|
|
||||||
let row = rows.find(r => Math.abs(r.top - rect.top) < 0.75);
|
|
||||||
if (!row) {
|
|
||||||
row = {top: rect.top, bottom: rect.bottom, group, cards: []};
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
row.bottom = Math.max(row.bottom, rect.bottom);
|
|
||||||
row.cards.push({left: rect.left, right: rect.right, bottom: rect.bottom});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
rowGap: parseFloat(style.rowGap), columnGap: parseFloat(style.columnGap),
|
|
||||||
parentGap, margin: style.marginBottom,
|
|
||||||
rows: rows.sort((a, b) => a.top - b.top)
|
|
||||||
};
|
|
||||||
}"""
|
|
||||||
|
|
||||||
with sync_playwright() as playwright:
|
|
||||||
browser = playwright.chromium.launch(
|
|
||||||
headless=True, executable_path="/usr/bin/chromium", args=["--no-sandbox"])
|
|
||||||
page = browser.new_page(viewport={"width": 1800, "height": 1100})
|
|
||||||
page.set_content(html)
|
|
||||||
page.add_script_tag(content=(root / "app/overview-masonry.js").read_text())
|
|
||||||
checks = 0
|
|
||||||
for width in (900, 1220, 1680):
|
|
||||||
page.evaluate("w => document.getElementById('panel-overview').style.width = w + 'px'", width)
|
|
||||||
for ui_density, old_gap in (("normal", 5), ("dense", 4), ("comfortable", 13)):
|
|
||||||
page.evaluate("d => {document.body.dataset.density = d === 'normal' ? '' : d;}", ui_density)
|
|
||||||
for card_density in ("normal", "compact", "ultra"):
|
|
||||||
page.evaluate("d => document.documentElement.dataset.dashboardDensity = d", card_density)
|
|
||||||
data = page.evaluate(snapshot)
|
|
||||||
expected = old_gap
|
|
||||||
assert abs(data["parentGap"] - old_gap) < 0.01, data
|
|
||||||
assert abs(data["rowGap"] - expected) < 0.01, data
|
|
||||||
assert abs(data["columnGap"] - expected) < 0.01, data
|
|
||||||
assert data["margin"] in ("0px", "0"), data
|
|
||||||
rows = data["rows"]
|
|
||||||
assert len(rows) >= 4, (width, ui_density, card_density)
|
|
||||||
for before, after in zip(rows[:10], rows[1:11]):
|
|
||||||
distance = after["top"] - before["bottom"]
|
|
||||||
assert abs(distance - expected) < 1.05, (width, ui_density, card_density, distance, expected)
|
|
||||||
for row in rows[:10]:
|
|
||||||
cards = sorted(row["cards"], key=lambda card: card["left"])
|
|
||||||
assert max(c["bottom"] for c in cards) - min(c["bottom"] for c in cards) < 1.1
|
|
||||||
for a, b in zip(cards, cards[1:]):
|
|
||||||
assert abs(b["left"] - a["right"] - expected) < 1.1, (
|
|
||||||
width, ui_density, card_density, a, b, expected)
|
|
||||||
first_large = next((i for i, row in enumerate(rows) if row["group"] != 0), None)
|
|
||||||
assert first_large is not None and first_large > 0
|
|
||||||
transition = rows[first_large]["top"] - rows[first_large-1]["bottom"]
|
|
||||||
assert abs(transition - expected) < 1.05, (width, ui_density, card_density, transition)
|
|
||||||
checks += 1
|
|
||||||
print(f"PASS: {width}px/{ui_density}: uniform {expected}px gutters, all Dashboard densities")
|
|
||||||
browser.close()
|
|
||||||
print(f"PASS: {checks} Chromium layout checks including small-to-large transitions")
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// Regression for shared server/browser Overview Cards metadata and future opt-in widgets.
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
|
|
||||||
const prefs = fs.readFileSync('src/admin/web/app/preferences.js','utf8');
|
|
||||||
const constants = prefs.slice(prefs.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefs.indexOf('const CHART_STYLE_KEYS'));
|
|
||||||
const normalize = prefs.slice(prefs.indexOf('function normalizeDashboardWidgets'),prefs.indexOf('function browserLanguage'));
|
|
||||||
const label = prefs.slice(prefs.indexOf('function dashboardWidgetLabel'),prefs.indexOf('function renderDashboardWidgetSettings'));
|
|
||||||
assert(constants.startsWith('const ADMIN_DASHBOARD_WIDGETS'));
|
|
||||||
assert(normalize.startsWith('function normalizeDashboardWidgets'));
|
|
||||||
assert(label.startsWith('function dashboardWidgetLabel'));
|
|
||||||
|
|
||||||
function client(definitions) {
|
|
||||||
const context = {t: key => `loc:${key}`};
|
|
||||||
vm.createContext(context);
|
|
||||||
vm.runInContext(`const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${JSON.stringify(definitions)});\n${constants}\n${normalize}\n${label}\n`,context);
|
|
||||||
return {
|
|
||||||
order: value => [...vm.runInContext(`normalizeDashboardWidgets(${JSON.stringify(value)})`,context)],
|
|
||||||
hidden: (value,saved) => [...vm.runInContext(`normalizeHiddenDashboardWidgets(${JSON.stringify(value)},${JSON.stringify(saved)})`,context)],
|
|
||||||
label: id => vm.runInContext(`dashboardWidgetLabel(${JSON.stringify(id)})`,context),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const original = client(registry);
|
|
||||||
const previous = registry.filter(widget => widget.category === 'overview').map(widget => widget.id);
|
|
||||||
assert.deepEqual(original.order(previous),registry.map(widget => widget.id));
|
|
||||||
assert.deepEqual(original.hidden([],previous),registry.filter(widget=>widget.default_hidden).map(widget=>widget.id));
|
|
||||||
assert.equal(original.label('stream_overview'),'loc:streamWidgetOverview');
|
|
||||||
assert.equal(original.label('backend_health'),'loc:backendWidgetHealth');
|
|
||||||
assert.equal(original.label('backend_table'),'loc:vhHealthTitle');
|
|
||||||
assert.equal(registry.length, 62);
|
|
||||||
const types = new Set(['value', 'value_chart', 'chart', 'panel', 'table']);
|
|
||||||
assert(registry.every(widget => types.has(widget.display_type)), 'Every Overview block needs explicit type metadata');
|
|
||||||
assert.equal(registry.find(w => w.id === 'requests').display_type, 'value');
|
|
||||||
assert.equal(registry.find(w => w.id === 'backend_requests').display_type, 'value_chart');
|
|
||||||
assert.equal(registry.find(w => w.id === 'traffic_chart').display_type, 'chart');
|
|
||||||
assert.equal(registry.find(w => w.id === 'cache_diagnostics').display_type, 'panel');
|
|
||||||
assert.equal(registry.find(w => w.id === 'backend_table').display_type, 'table');
|
|
||||||
// Upgrading from the previous 30-widget 0.32.3 archive keeps its exact
|
|
||||||
// visible/order preferences: only twelve new metrics are opt-in, while
|
|
||||||
// seven existing large panels remain visible in the factory layout.
|
|
||||||
const previouslySaved = registry.slice(0, 30).map(widget => widget.id);
|
|
||||||
const migratedOrder = original.order(previouslySaved);
|
|
||||||
const migratedHidden = original.hidden(registry.slice(0, 30).filter(w => w.default_hidden).map(w => w.id), previouslySaved);
|
|
||||||
assert.deepEqual(migratedOrder.slice(0, 30), previouslySaved);
|
|
||||||
assert.deepEqual(migratedOrder.slice(30), registry.slice(30).map(widget => widget.id));
|
|
||||||
for (const widget of registry.filter(widget => widget.category === 'performance'))
|
|
||||||
assert(migratedHidden.includes(widget.id), `${widget.id} must be opt-in after upgrade`);
|
|
||||||
for (const widget of registry.filter(widget => widget.category === 'panels' && !widget.default_hidden))
|
|
||||||
assert(!migratedHidden.includes(widget.id), `${widget.id} must retain default visibility`);
|
|
||||||
for (const widget of registry.filter(widget => widget.id.startsWith('wide_')))
|
|
||||||
assert(migratedHidden.includes(widget.id), `${widget.id} is a new opt-in chart`);
|
|
||||||
assert.equal(registry.filter(widget => widget.category === 'panels').length, 14);
|
|
||||||
for (const widget of registry.filter(widget => widget.category === 'performance')) assert(widget.default_hidden);
|
|
||||||
for (const id of ['backend_health','backend_activity','backend_errors','backend_latency','backend_traffic','backend_protocols','backend_requests','backend_capacity','backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations'])
|
|
||||||
assert(original.hidden([],previous).includes(id), `${id} must remain opt-in for older preferences`);
|
|
||||||
assert.deepEqual(original.order(['errors','unknown-0.33.0','requests','errors']).slice(0,2),['errors','requests']);
|
|
||||||
assert(original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('http3'));
|
|
||||||
assert(!original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('unknown-0.33.0'));
|
|
||||||
|
|
||||||
// Adding a new registry card, with an existing UI element and translation key,
|
|
||||||
// automatically offers it in the ordered preference list and hides it by default.
|
|
||||||
const extra={...registry[0], id:'future_upstream', label_key:'futureUpstreamProtocol',
|
|
||||||
description_key:null, element_selector:'[data-dashboard-widget="future_upstream"]', default_hidden:true};
|
|
||||||
const expanded=client([...registry,extra]);
|
|
||||||
assert.equal(expanded.order(previous).at(-1),'future_upstream');
|
|
||||||
assert(expanded.hidden([],previous).includes('future_upstream'));
|
|
||||||
assert.equal(expanded.label('future_upstream'),'loc:futureUpstreamProtocol');
|
|
||||||
assert(!expanded.hidden([],registry.map(widget=>widget.id).concat('future_upstream')).includes('future_upstream'),
|
|
||||||
'explicitly opted-in widget must not become hidden after a later release');
|
|
||||||
console.log(`PASS: shared widget registry UI preserves order, visibility and ${registry.length} existing cards`);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Source-level guard against reintroducing broken absolute/masonry layout.
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
const source = fs.readFileSync('src/admin/web/app/overview-masonry.js','utf8');
|
|
||||||
const css = fs.readFileSync('src/admin/web/style.css','utf8');
|
|
||||||
assert.match(source,/\.sort\(\(a, b\) => group\(a\.node\) - group\(b\.node\)/);
|
|
||||||
assert.match(source,/grid\.classList\.remove\("is-packed"\)/);
|
|
||||||
assert.match(source,/node\.classList\.add\("overview-row-start"\)/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
|
|
||||||
assert(!/style\.position\s*=\s*["']absolute["']|requestAnimationFrame\(|new ResizeObserver\(/.test(source),
|
|
||||||
'No absolute positioning, animation loop or ResizeObserver should manage Overview geometry');
|
|
||||||
assert(!/\.dashboard-overview-grid\.is-aligned\.is-packed\s*\{/.test(css));
|
|
||||||
assert(!source.includes('fetch(') && !source.includes('setInterval('));
|
|
||||||
console.log('PASS: regular symmetric CSS Grid for all 62 Overview blocks');
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Optional large Overview charts: real status schema, counter resets, no polling.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
const source = fs.readFileSync('src/admin/web/app/overview-wide-metrics.js','utf8');
|
|
||||||
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
|
|
||||||
const ids = registry.filter(w => w.id.startsWith('wide_')).map(w => w.id);
|
|
||||||
assert.equal(ids.length, 7);
|
|
||||||
assert(ids.every(id => registry.find(w => w.id === id).default_hidden));
|
|
||||||
const nodes = new Map(ids.map(id => [id,{ hidden: false }]));
|
|
||||||
const fields = new Map();
|
|
||||||
function $(id) {
|
|
||||||
if (!fields.has(id)) fields.set(id,{ textContent:'-', d:'', setAttribute(name,value) {this[name]=value;} });
|
|
||||||
return fields.get(id);
|
|
||||||
}
|
|
||||||
let now = 1000;
|
|
||||||
const ctx = {window:{},currentPanel:'overview',performance:{now:()=>now},fmt:(n,places=0)=>Number(n).toFixed(places),fmtBytes:n=>`${n} B`,$,
|
|
||||||
document:{querySelector:selector=>nodes.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1])||null}};
|
|
||||||
vm.createContext(ctx);vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx);
|
|
||||||
vm.runInContext(source,ctx);
|
|
||||||
const render=ctx.window.rustProxyWideOverview.render;
|
|
||||||
const text=id=>$(id).textContent;
|
|
||||||
const baseline = { revision:1,
|
|
||||||
metrics:{ http1_requests:50,http2_requests:30,http3_requests:20,active_connections:9,websocket_active:3,mean_duration_ms:15 },
|
|
||||||
system:{cpu_percent:45,process_cpu_percent:16,process_rss_bytes:1500},
|
|
||||||
cache:{hits:20,misses:10,resident_bytes:220},
|
|
||||||
backend_pools:[{members:[{status:'up',active:1,last_probe_ms:100,latency_ms:12,health_mode:'active'},{status:'down',active:0}]}]};
|
|
||||||
render(baseline);
|
|
||||||
assert.equal(text('overview-wide-wide_protocol_rates-summary'),'-');
|
|
||||||
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%');
|
|
||||||
assert.equal(text('overview-wide-wide_memory-process-value'),'1500 B');
|
|
||||||
assert.equal(text('overview-wide-wide_backend_health-down-value'),'1');
|
|
||||||
assert.equal(text('overview-wide-wide_latency-probe-value'),'12.0 ms');
|
|
||||||
now = 3000;
|
|
||||||
const second=structuredClone(baseline);
|
|
||||||
second.metrics.http1_requests=70; second.metrics.http2_requests=40; second.metrics.http3_requests=22;
|
|
||||||
second.cache.hits=30; second.cache.misses=18;
|
|
||||||
render(second);
|
|
||||||
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'10.0/s');
|
|
||||||
assert.equal(text('overview-wide-wide_protocol_rates-h2-value'),'5.0/s');
|
|
||||||
assert.equal(text('overview-wide-wide_protocol_rates-h3-value'),'1.0/s');
|
|
||||||
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'5.0/s');
|
|
||||||
assert.equal(text('overview-wide-wide_cache_activity-misses-value'),'4.0/s');
|
|
||||||
assert.equal(text('overview-wide-wide_connections-open-value'),'9');
|
|
||||||
now = 4000;
|
|
||||||
render({...second,metrics:{...second.metrics,http1_requests:0,http2_requests:0,http3_requests:0},cache:{...second.cache,hits:0,misses:0}});
|
|
||||||
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'-', 'counter resets must not create negative traffic');
|
|
||||||
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'-');
|
|
||||||
nodes.get('wide_cpu').hidden=true;
|
|
||||||
now=5000;render({...second, system:{}});
|
|
||||||
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%', 'hidden chart should not re-render');
|
|
||||||
nodes.get('wide_cpu').hidden=false;
|
|
||||||
now=6000;render({metrics:{},system:{},cache:{}});
|
|
||||||
assert.equal(text('overview-wide-wide_cpu-system-value'),'-', 'unavailable metrics are not zero');
|
|
||||||
assert.equal(text('overview-wide-wide_backend_health-up-value'),'-', 'missing backend array is no data');
|
|
||||||
ctx.window.rustProxyWideOverview.reset();
|
|
||||||
for(let i=0;i<75;i++){now+=1000;render({...baseline,system:{...baseline.system,cpu_percent:i}});}
|
|
||||||
assert(source.includes('const limit = 60'));
|
|
||||||
assert(source.includes('if (!node || node.hidden)'));
|
|
||||||
assert(!/fetch\s*\(|setInterval\s*\(/.test(source),'no extra API calls or polling');
|
|
||||||
const css=fs.readFileSync('src/admin/web/style.css','utf8');
|
|
||||||
const aligned = fs.readFileSync('src/admin/web/app/overview-masonry.js', 'utf8');
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-rows:\s*auto;/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-flow:\s*row;/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*--overview-gap:\s*max\(3px, calc\(var\(--ui-gap\) - 1px\)\);/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*gap:\s*var\(--overview-gap\);/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\.stats-grid\s*\{[^}]*gap:\s*var\(--overview-gap\);/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
|
|
||||||
assert.match(css, /#panel-overview\s*\{\s*container:\s*overview-canvas \/ inline-size;/);
|
|
||||||
assert.match(css, /@container overview-canvas \(max-width:\s*700px\)/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
|
|
||||||
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\.overview-row-start\s*\{[^}]*grid-column-start:\s*1;/);
|
|
||||||
assert.match(aligned, /node\.classList\.remove\("overview-row-start"\)/);
|
|
||||||
assert.match(aligned, /node\.classList\.add\("overview-row-start"\)/);
|
|
||||||
assert.match(aligned, /grid\.classList\.remove\("is-packed"\)/);
|
|
||||||
assert(!/style\.position\s*=\s*["']absolute["']/.test(aligned));
|
|
||||||
assert(!aligned.includes('fetch(') && !aligned.includes('setInterval('));
|
|
||||||
console.log('PASS: 7 optional wide charts, regular rows, no uneven masonry');
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Real browser form callbacks with fake DOM; protocol/security checks are
|
|
||||||
// source-level assertions only. Rust unit and live HTTP tests need Cargo.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve, dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { runInNewContext } from 'node:vm';
|
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
||||||
const read = path => readFileSync(resolve(root, path), 'utf8');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const css = read('src/admin/web/style.css');
|
|
||||||
const administration = read('src/admin/web/settings.js');
|
|
||||||
const cache = read('src/admin/web/app/settings.js');
|
|
||||||
const router = read('src/admin/router.rs');
|
|
||||||
const assets = read('src/admin/panel_assets.rs');
|
|
||||||
const compression = read('src/admin/panel_content.rs');
|
|
||||||
const ip = read('src/cache/model/http_invalidation.rs');
|
|
||||||
const cacheMethods = read('src/proxy/cache_http_invalidation.rs');
|
|
||||||
|
|
||||||
for (const codec of ['zstd', 'br', 'gzip']) {
|
|
||||||
assert.match(html, new RegExp(`id="admin-compression-${codec}"[^>]*checked`));
|
|
||||||
assert(assets.includes('"/assets/app.js"'));
|
|
||||||
}
|
|
||||||
for (const path of ['app.js','management.js','vhosts.js','settings.js','cache-panel.js','traffic-panel.js','style.css','langs.json']) {
|
|
||||||
if (path === "langs.json") assert(router.includes(`"/assets/${path}", get(panel_assets::serve)`), path);
|
|
||||||
else assert(router.includes('"/assets/{fingerprinted}"'), path);
|
|
||||||
assert(assets.includes(`"/assets/${path}"`), path);
|
|
||||||
}
|
|
||||||
assert(compression.includes('preferred_encoding(headers, &settings)'));
|
|
||||||
assert(compression.includes('HeaderValue::from_static("Accept-Encoding")'));
|
|
||||||
assert(compression.includes('header::CONTENT_LENGTH'));
|
|
||||||
assert(compression.includes('header::CONTENT_ENCODING'));
|
|
||||||
assert(compression.includes('crate::compression::no_transform(headers)'));
|
|
||||||
assert(html.includes('id="cache-http-invalidation-cidrs"'));
|
|
||||||
assert(html.includes('class="metric-info cache-maintenance-info"'));
|
|
||||||
assert(html.includes('data-i18n-aria="cacheHttpInvalidationCurlLabel"'));
|
|
||||||
for (const snippet of ["curl -i -X PURGE", "curl -i -X BAN", "X-RustProxy-Confirm-Wide: 1"]) {
|
|
||||||
assert(html.includes(snippet), snippet);
|
|
||||||
}
|
|
||||||
assert(css.includes('.cache-maintenance-info .metric-info-popover'));
|
|
||||||
|
|
||||||
assert(cache.includes('http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs")'));
|
|
||||||
assert(ip.includes('http_invalidation_allowed_cidrs.len() <= 32'));
|
|
||||||
assert(ip.includes('net.prefix_len() > 0'));
|
|
||||||
assert(cacheMethods.includes('snapshot.envelope.config.cache.permits_http_invalidation(client_ip)'));
|
|
||||||
assert(cacheMethods.includes('state.store.audit('));
|
|
||||||
assert(cacheMethods.includes('spawn_blocking'));
|
|
||||||
assert(cacheMethods.includes('x-rustproxy-confirm-wide'));
|
|
||||||
assert(cacheMethods.includes('InvalidationSelector::Exact'));
|
|
||||||
assert(cacheMethods.includes('InvalidationSelector::Prefix'));
|
|
||||||
assert(read('src/proxy/forward_entry.rs').includes('return cache_http_invalidation('));
|
|
||||||
const heading = html.lastIndexOf('data-i18n="systemInformation"');
|
|
||||||
assert(heading > 0);
|
|
||||||
const headerArea = html.slice(heading, heading + 600);
|
|
||||||
assert(headerArea.includes('<span class="pill">API</span>'));
|
|
||||||
assert(headerArea.indexOf('<span class="pill">API</span>') < headerArea.indexOf('id="system-info-refresh"'));
|
|
||||||
assert.match(css, /\.system-info-heading-actions\s*\{[^}]*display:\s*flex/);
|
|
||||||
for (const lang of ['en_US','pl_pl']) {
|
|
||||||
const strings = JSON.parse(read(`langs/${lang}.json`));
|
|
||||||
const t = strings.translations;
|
|
||||||
assert(t.panelCompressionTitle && t.panelCompressionHelp && t.cacheHttpInvalidationHelp, lang);
|
|
||||||
assert(t.cacheHttpInvalidationCurlLabel && t.cacheHttpInvalidationCurlIntro && t.cacheHttpInvalidationCurlNote, lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the actual callbacks captured from the shipped frontend settings JS.
|
|
||||||
const fields = new Map();
|
|
||||||
const field = name => {
|
|
||||||
if (!fields.has(name)) fields.set(name, { checked: false, value: '' });
|
|
||||||
return fields.get(name);
|
|
||||||
};
|
|
||||||
for (const name of ['zstd', 'br', 'gzip']) field(`admin-compression-${name}`).checked = true;
|
|
||||||
field('admin-listen').value = '127.0.0.1:9090';
|
|
||||||
field('admin-session-days').value = '30';
|
|
||||||
let handlers = new Map();
|
|
||||||
const bindSettingsForm = (id, handler) => handlers.set(id, handler);
|
|
||||||
const adminStart = administration.indexOf('bindSettingsForm("administration-form", next => {');
|
|
||||||
const adminEnd = administration.indexOf('bindSettingsForm("tls-policy-form",', adminStart);
|
|
||||||
assert(adminStart > 0 && adminEnd > adminStart);
|
|
||||||
// Use the shipped helper in the extracted callback environment as well.
|
|
||||||
const hostHelperStart = administration.indexOf(' function pinnedAdminHostname(value) {');
|
|
||||||
const hostHelperEnd = administration.indexOf(' const normalizedRequestedTab', hostHelperStart);
|
|
||||||
assert(hostHelperStart > 0 && hostHelperEnd > hostHelperStart);
|
|
||||||
runInNewContext(administration.slice(hostHelperStart, hostHelperEnd) + administration.slice(adminStart, adminEnd), {
|
|
||||||
$: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } }, URL,
|
|
||||||
});
|
|
||||||
const adminNext = {};
|
|
||||||
handlers.get('administration-form')(adminNext);
|
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(adminNext.administration.panel_compression)),
|
|
||||||
{ zstd: true, br: true, gzip: true });
|
|
||||||
field('admin-public-origin').value = 'https://panel.example.test';
|
|
||||||
field('admin-hosts').value = 'panel.example.test\nother.example.test';
|
|
||||||
const pinnedNext = {};
|
|
||||||
handlers.get('administration-form')(pinnedNext);
|
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(pinnedNext.administration.allowed_hosts)), ['other.example.test']);
|
|
||||||
field('admin-compression-br').checked = false;
|
|
||||||
field('admin-compression-zstd').checked = false;
|
|
||||||
const offNext = {};
|
|
||||||
handlers.get('administration-form')(offNext);
|
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(offNext.administration.panel_compression)),
|
|
||||||
{ zstd: false, br: false, gzip: true });
|
|
||||||
|
|
||||||
const cacheStart = cache.indexOf('bindSettingsForm("cache-form", next => {');
|
|
||||||
const cacheEnd = cache.indexOf('bindSettingsForm("transform-form",', cacheStart);
|
|
||||||
assert(cacheStart > 0 && cacheEnd > cacheStart);
|
|
||||||
handlers = new Map();
|
|
||||||
field('cache-http-invalidation-cidrs').value = ' 192.0.2.50\n 2001:db8:2::/64, 198.51.100.9 \n';
|
|
||||||
field('cache-sie-statuses').value = '500,502,503,504';
|
|
||||||
runInNewContext(cache.slice(cacheStart, cacheEnd), {
|
|
||||||
$: field, bindSettingsForm, selectedCacheMemoryPreset: null, selectedCacheColdPreset: null,
|
|
||||||
});
|
|
||||||
const cacheNext = { cache: { max_old_setting: 'kept' } };
|
|
||||||
handlers.get('cache-form')(cacheNext);
|
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)),
|
|
||||||
['192.0.2.50', '2001:db8:2::/64', '198.51.100.9']);
|
|
||||||
assert.equal(cacheNext.cache.max_old_setting, 'kept');
|
|
||||||
field('cache-http-invalidation-cidrs').value = '\n';
|
|
||||||
handlers.get('cache-form')(cacheNext);
|
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)), []);
|
|
||||||
console.log('PASS: cache CIDR/administration codec UI roundtrip, headers, security source wiring, API badge layout');
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Source-only regression assertions; NOT a compiled Rust or live H2 test."""
|
|
||||||
from pathlib import Path
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[2]
|
|
||||||
read = lambda name: (root / name).read_text(encoding='utf-8')
|
|
||||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7'
|
|
||||||
client = read('src/upstream/client.rs')
|
|
||||||
forward = read('src/proxy/forward/upstream.rs')
|
|
||||||
helper = read('src/proxy/h2_graceful.rs')
|
|
||||||
lease = read('src/balance/lease_runtime.rs')
|
|
||||||
health = read('src/balance/health.rs')
|
|
||||||
tests = read('src/balance/tests.rs')
|
|
||||||
for marker in ('GoAway(', 'NO_ERROR', 'Remote', 'note_graceful_goaway(generation)', 'is_graceful_h2_goaway(error)'):
|
|
||||||
assert marker in client, marker
|
|
||||||
for marker in ('graceful_retry_done', 'retry_safe_method(&retry_method)', 'request_body_empty', 'handshake.is_none()', 'retry_request(', 'graceful_retry_done = true', 'continue;'):
|
|
||||||
assert marker in forward, marker
|
|
||||||
for marker in ('is_related_h2_close', 'recently_saw_graceful_goaway()', 'h2_graceful_goaway', 'h2_graceful_close'):
|
|
||||||
assert marker in helper, marker
|
|
||||||
for marker in ('first_error_detail', 'first_error_at_ms', 'trigger_error_detail', 'backend excluded from traffic', 'tracing::warn!(', 'h2_graceful_goaway'):
|
|
||||||
assert marker in lease, marker
|
|
||||||
for marker in ('passive_first_failure', 'active_first_failure', 'passive_probe_successes', 'passive_ejected_at', 'Duration::from_millis(250)', 'fn clear_passive_ejection', 'pub fn healthy'):
|
|
||||||
assert marker in health, marker
|
|
||||||
for marker in ('passive_ejection_remembers_first_real_error_and_trigger', 'passive_io_burst_is_one_observation', 'passive_only_quarantine_expires', 'active_probe_successes_recover'):
|
|
||||||
assert marker in tests, marker
|
|
||||||
print('PASS: version, H2 graceful recycle/retry, passive health, first-error WARN source contracts (NOT compiled)')
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify the security-sensitive transport split without claiming a Rust execution."""
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
root=Path(__file__).resolve().parents[2]
|
|
||||||
read=lambda file:(root/file).read_text(encoding='utf-8')
|
|
||||||
backend=read('src/config/backend.rs')
|
|
||||||
forward=read('src/proxy/forward/upstream.rs')
|
|
||||||
proxy=read('src/proxy/types.rs')
|
|
||||||
client=read('src/upstream/client.rs')
|
|
||||||
conn=read('src/upstream.rs')
|
|
||||||
assert not re.search(r'ensure!\([^;]*!backend\.preserve_host',backend)
|
|
||||||
assert 'preserve_host: backend.preserve_host' in backend
|
|
||||||
assert 'fn upstream_request_target(' in proxy
|
|
||||||
assert 'parts.authority = Some(authority)' in proxy
|
|
||||||
assert 'if strict_h2_request' in proxy and 'headers.remove(HOST)' in proxy
|
|
||||||
assert 'let Some(upstream_target) = upstream_request_target(' in forward
|
|
||||||
assert 'retry_headers = request.headers().clone()' in forward
|
|
||||||
assert 'upstream_request_target(&uri, request.headers_mut(), backend, false, false)' in proxy
|
|
||||||
assert 'let destination: http::Uri' in client
|
|
||||||
assert 'let uri = self.destination.clone()' in conn
|
|
||||||
assert 'fn call(&mut self, _request_uri: Uri)' in conn
|
|
||||||
assert 'connect_host' in client and 'connect_host' in conn
|
|
||||||
assert 'policy.check(ip)' in conn
|
|
||||||
assert '.retry_canceled_requests(false)' in client
|
|
||||||
assert 'preserve_host_is_valid_with_every_upstream_protocol' in read('src/config/tests.rs')
|
|
||||||
for test in (
|
|
||||||
'preserve_host_sets_http2_authority_without_changing_network_origin',
|
|
||||||
'retry_prepares_authority_for_the_selected_backend_not_the_previous_one',
|
|
||||||
'invalid_outgoing_host_is_rejected_instead_of_used_for_dialing',
|
|
||||||
):
|
|
||||||
assert test in read('src/proxy/tests/authority_tests.rs'),test
|
|
||||||
for test in (
|
|
||||||
'strict_h2_preserves_public_authority_while_tcp_and_sni_use_private_origin',
|
|
||||||
'auto_h1_fallback_preserves_public_host_without_resolving_it',
|
|
||||||
'auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin',
|
|
||||||
'cleartext_h2c_preserves_public_authority_and_pins_socket',
|
|
||||||
):
|
|
||||||
assert test in read('src/upstream/protocol_tests.rs'),test
|
|
||||||
css=read('src/admin/web/style.css')
|
|
||||||
match=re.search(r'#panel-overview \.dashboard-overview-grid\.is-aligned\s*\{([^}]*)\}',css)
|
|
||||||
assert match
|
|
||||||
rules=match[1]
|
|
||||||
for marker in ['margin: 0;', '--overview-gap: var(--ui-gap);',
|
|
||||||
'row-gap: var(--overview-gap);', 'column-gap: var(--overview-gap);',
|
|
||||||
'gap: var(--overview-gap);']:
|
|
||||||
assert marker in rules, marker
|
|
||||||
# Regression guard for E0382: the retry builder must not consume the URI
|
|
||||||
# before upstream_request_target() borrows it to prepare HTTP/2 authority.
|
|
||||||
retry = re.search(r'fn retry_request\([\s\S]*?\n}\n', proxy)
|
|
||||||
assert retry and '.uri(uri.clone())' in retry[0]
|
|
||||||
assert 'upstream_request_target(&uri, request.headers_mut()' in retry[0]
|
|
||||||
assert json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.7'
|
|
||||||
assert 'version = "0.32.7"' in read('Cargo.toml')
|
|
||||||
print('PASS: 0.32.7 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)')
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
const ui = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
|
|
||||||
const head = 'function debugOriginIssues(members) {';
|
|
||||||
const tail = '\nfunction aclEditor(';
|
|
||||||
const start = ui.indexOf(head);
|
|
||||||
const end = ui.indexOf(tail, start);
|
|
||||||
assert(start >= 0 && end > start, 'debug origin verification helper is present');
|
|
||||||
const warn = vm.runInNewContext('(' + ui.slice(start, end).trim() + ')', { URL });
|
|
||||||
|
|
||||||
function member(protocol, more={}) { return {
|
|
||||||
url:'https://10.87.20.2:443',protocol,preserve_host:true,
|
|
||||||
pool_eligible:true,allow_private:true,tls_verify:false,...more,
|
|
||||||
}; }
|
|
||||||
for (const protocol of ['http1', 'auto', 'http2', 'h2']) {
|
|
||||||
assert.equal(warn([member(protocol)]).length, 0,
|
|
||||||
`${protocol} is accepted without forcing H1 or disabling preserved Host`);
|
|
||||||
assert.equal(warn([member(protocol, {tls_server_name:'unitraklub.pl',tls_verify:true})]).length, 0,
|
|
||||||
`${protocol} supports explicitly verified certificate DNS names`);
|
|
||||||
}
|
|
||||||
assert.equal(warn([member('h2',{pool_eligible:false,allow_private:false})]).length,0,
|
|
||||||
'pool and private-origin flags must not be mistaken for protocol requirements');
|
|
||||||
assert.equal(warn([member('auto',{tls_verify:true})]).length,1,
|
|
||||||
'HTTPS-by-IP without TLS identity receives a nonblocking warning');
|
|
||||||
assert.equal(warn([{...member('h2',{tls_verify:true}),url:'https://site.example'}]).length,0,
|
|
||||||
'DNS HTTPS origin needs no IP-certificate warning');
|
|
||||||
assert.equal(warn([{...member('h2',{tls_verify:true}),url:'http://10.87.20.2:80'}]).length,0);
|
|
||||||
assert(!/preserve\.disabled\s*=|preserve\.checked\s*=\s*false/.test(ui),
|
|
||||||
'Protocol selector may not disable or clear Preserve Host');
|
|
||||||
assert(ui.includes('value.preserve_host === true'), 'Existing backend preference remains visible');
|
|
||||||
assert(ui.includes('protocol.value = "http1"; preserve.checked = true'),
|
|
||||||
'Optional explicit H1 troubleshooting preset is retained');
|
|
||||||
assert(!ui.includes('syncProtocol()'), 'Do not call removed protocol-only Host limiter');
|
|
||||||
assert(ui.includes('protocol.dispatchEvent(new Event("change"))'), 'Preset refreshes protocol help text');
|
|
||||||
for (const lang of ['en_US','pl_pl']) {
|
|
||||||
const t=JSON.parse(fs.readFileSync(`langs/${lang}.json`,'utf8')).translations;
|
|
||||||
for (const key of ['vhPreserveHost','vhDebugOriginPreset','vhOriginPresetHelp','vhDebugOriginMismatch'])
|
|
||||||
assert(t[key] && t[key].length>30,`${lang}.${key}`);
|
|
||||||
}
|
|
||||||
console.log('PASS: Preserve Host is available for four modes; IP/TLS hint is conditional and optional');
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
// Counter-derived chart regression: pauses must not become fake 0/s samples.
|
|
||||||
// Real Cache / Transform chart functions run against simulated DOM and clock.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const samplingSource = fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8');
|
|
||||||
const panels = {
|
|
||||||
cache: {
|
|
||||||
path: 'src/admin/web/cache-panel.js', stop: ' function configure()',
|
|
||||||
counters: (hit, miss) => ({ hits: hit, misses: miss, bypasses: 0 }),
|
|
||||||
graph: 'cache-live-hit-line', rate: 'cache-live-hit-rate',
|
|
||||||
keys: ['hits', 'misses', 'bypasses'], seconds: 5,
|
|
||||||
},
|
|
||||||
transform: {
|
|
||||||
path: 'src/admin/web/transform-panel.js', stop: ' function countersFor(data)',
|
|
||||||
counters: (hit, miss) => ({ applied: hit, cache_hits: miss, skipped: 0, errors: 0 }),
|
|
||||||
graph: 'transform-live-applied-line', rate: 'transform-live-applied-rate',
|
|
||||||
keys: ['applied', 'cache_hits', 'skipped', 'errors'], seconds: 3,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function fixture(name, config) {
|
|
||||||
const source = fs.readFileSync(config.path, 'utf8');
|
|
||||||
const end = source.indexOf(config.stop);
|
|
||||||
assert(end > 0, `unable to isolate ${name} live chart`);
|
|
||||||
const documentListeners = new Map(), windowListeners = new Map(), nodes = new Map();
|
|
||||||
let now = 1000;
|
|
||||||
const node = id => {
|
|
||||||
if (!nodes.has(id)) nodes.set(id, {
|
|
||||||
value: id.endsWith('-scope') ? '*' : '', textContent: '',
|
|
||||||
attrs: {}, setAttribute(key, value) { this.attrs[key] = value; },
|
|
||||||
});
|
|
||||||
return nodes.get(id);
|
|
||||||
};
|
|
||||||
const document = {
|
|
||||||
hidden: false,
|
|
||||||
addEventListener(name, listener) { documentListeners.set(name, listener); },
|
|
||||||
};
|
|
||||||
const window = {
|
|
||||||
addEventListener(name, listener) { windowListeners.set(name, listener); },
|
|
||||||
rustProxyRefresh: { current: () => ({ overview_seconds: 2, cache_seconds: config.seconds }) },
|
|
||||||
};
|
|
||||||
const ctx = {
|
|
||||||
document, window, $: node, performance: { now: () => now },
|
|
||||||
sparklinePoints: samples => samples.join(','),
|
|
||||||
};
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(samplingSource, ctx);
|
|
||||||
vm.runInContext(source.slice(0, end) + '\nwindow.liveChartTest = { renderLiveChart, resetLiveChart, liveChartSamples };\n})();', ctx);
|
|
||||||
const chart = window.liveChartTest;
|
|
||||||
const line = () => node(config.graph).attrs.points;
|
|
||||||
const rate = () => node(config.rate).textContent;
|
|
||||||
const length = () => chart.liveChartSamples[config.keys[0]].length;
|
|
||||||
const sample = (stamp, hits, misses) => {
|
|
||||||
now = stamp;
|
|
||||||
chart.renderLiveChart(config.counters(hits, misses));
|
|
||||||
};
|
|
||||||
return { ctx, chart, line, rate, length, sample, document, documentListeners, windowListeners };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, config] of Object.entries(panels)) {
|
|
||||||
const f = fixture(name, config);
|
|
||||||
f.sample(1000, 100, 50); // no deltas exist before the first observation
|
|
||||||
assert.equal(f.length(), 0, `${name}: first reading is a baseline, not 0/s`);
|
|
||||||
assert.equal(f.rate(), '-');
|
|
||||||
f.sample(1000 + config.seconds * 1000, 130, 65);
|
|
||||||
assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`);
|
|
||||||
assert.equal(f.length(), 1);
|
|
||||||
f.sample(1000 + config.seconds * 2000, 130, 65);
|
|
||||||
assert.equal(f.rate(), '0.0/s', `${name}: real idle interval is still zero`);
|
|
||||||
assert.equal(f.length(), 2);
|
|
||||||
|
|
||||||
const graphBefore = f.line();
|
|
||||||
const delayed = 1000 + config.seconds * 2000 + 120000;
|
|
||||||
f.sample(delayed, 500, 200);
|
|
||||||
assert.equal(f.rate(), '-', `${name}: long pause cannot be averaged into a misleading rate`);
|
|
||||||
assert.equal(f.length(), 2, `${name}: do not append fake drop`);
|
|
||||||
assert.equal(f.line(), graphBefore, `${name}: keep old chart path`);
|
|
||||||
f.sample(delayed + config.seconds * 1000, 530, 215);
|
|
||||||
assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`, `${name}: next fresh interval resumes`);
|
|
||||||
assert.equal(f.length(), 3);
|
|
||||||
|
|
||||||
f.document.hidden = true;
|
|
||||||
f.documentListeners.get('visibilitychange')?.();
|
|
||||||
f.document.hidden = false;
|
|
||||||
f.documentListeners.get('visibilitychange')?.();
|
|
||||||
const afterBackground = delayed + config.seconds * 1000 + 200;
|
|
||||||
f.sample(afterBackground, 533, 219);
|
|
||||||
assert.equal(f.length(), 3, `${name}: even a short background hide must not append a rate`);
|
|
||||||
assert.equal(f.rate(), '-');
|
|
||||||
f.sample(afterBackground + config.seconds * 1000, 533, 219);
|
|
||||||
assert.equal(f.rate(), '0.0/s', `${name}: genuine zero after resume`);
|
|
||||||
assert.equal(f.length(), 4);
|
|
||||||
|
|
||||||
f.windowListeners.get('pagehide')?.();
|
|
||||||
f.sample(afterBackground + config.seconds * 2000, 540, 220);
|
|
||||||
assert.equal(f.length(), 4, `${name}: pagehide / BFCache must rebase`);
|
|
||||||
f.sample(afterBackground + config.seconds * 3000, 550, 230);
|
|
||||||
assert.equal(f.length(), 5);
|
|
||||||
f.sample(afterBackground + config.seconds * 4000, 1, 1);
|
|
||||||
assert.equal(f.length(), 5, `${name}: counters reset must not be interpreted as 0/s`);
|
|
||||||
f.sample(afterBackground + config.seconds * 5000, 3, 3);
|
|
||||||
assert.equal(f.length(), 6, `${name}: counter reset recovery`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All remaining rate charts share this exact continuity check, including
|
|
||||||
// configured 30-second Cache refresh (not falsely treated as a pause).
|
|
||||||
{
|
|
||||||
const listeners = new Map(), document = { hidden: false, addEventListener(key, fn) { listeners.set(key, fn); } };
|
|
||||||
const context = { window: {}, document };
|
|
||||||
vm.createContext(context);
|
|
||||||
vm.runInContext(samplingSource, context);
|
|
||||||
const sample = vm.runInContext('rustProxyRateSampling', context);
|
|
||||||
let baseline = sample.difference(null, [100], 1000, 30).next;
|
|
||||||
let value = sample.difference(baseline, [130], 31000, 30);
|
|
||||||
assert.equal(value.deltas[0], 30, '30-second chart interval is valid');
|
|
||||||
baseline = value.next;
|
|
||||||
value = sample.difference(baseline, [200], 124000, 30);
|
|
||||||
assert.equal(value.deltas, null, 'large delay is a gap');
|
|
||||||
document.hidden = true;
|
|
||||||
listeners.get('visibilitychange')?.();
|
|
||||||
document.hidden = false;
|
|
||||||
assert.equal(sample.continuous(value.next, 124001, 30), false, 'hide invalidates even a short elapsed period');
|
|
||||||
}
|
|
||||||
console.log('PASS: Cache/Transform history survives hidden tabs, long pauses, BFCache and counter resets; real 0/s and configured cadence remain intact.');
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Deterministic browser-coordinator regression without a production browser or network.
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { runInNewContext } from "node:vm";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { resolve, dirname } from "node:path";
|
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
||||||
const scheduler = readFileSync(resolve(root, "src/admin/web/app/refresh.js"), "utf8");
|
|
||||||
const core = readFileSync(resolve(root, "src/admin/web/app/core.js"), "utf8");
|
|
||||||
const bridge = core.slice(core.indexOf("function renderLiveStatus(value)"), core.indexOf("async function refreshCertificateInventory()"));
|
|
||||||
assert.match(bridge, /function startLive\(/);
|
|
||||||
|
|
||||||
function fixture({ withoutSse = false } = {}) {
|
|
||||||
let time = 0, nextId = 1, renderCount = 0, logCount = 0, diagCount = 0;
|
|
||||||
const timers = new Map(), listeners = new Map(), windowListeners = new Map(), streams = [], requests = [], rendered = [], failures = [];
|
|
||||||
let response = async () => ({});
|
|
||||||
class FakeEventSource {
|
|
||||||
constructor(url) { this.url = url; this.closed = false; this.handlers = new Map(); streams.push(this); }
|
|
||||||
addEventListener(type, callback) { this.handlers.set(type, callback); }
|
|
||||||
close() { this.closed = true; }
|
|
||||||
emit(type, value) { this.handlers.get(type)?.({ data: JSON.stringify(value) }); }
|
|
||||||
fail() { this.onerror?.({ type: "error" }); }
|
|
||||||
}
|
|
||||||
const nodes = { "connection-label": { textContent: "" } };
|
|
||||||
const document = {
|
|
||||||
hidden: false,
|
|
||||||
addEventListener(type, callback) { assert(!listeners.has(type)); listeners.set(type, callback); },
|
|
||||||
removeEventListener(type, callback) { assert.equal(listeners.get(type), callback); listeners.delete(type); }
|
|
||||||
};
|
|
||||||
const ctx = {
|
|
||||||
document, AbortController, URLSearchParams,
|
|
||||||
EventSource: withoutSse ? undefined : FakeEventSource,
|
|
||||||
setTimeout(fn, ms) { const id = nextId++; timers.set(id, { at: time + ms, fn }); return id; },
|
|
||||||
clearTimeout(id) { timers.delete(id); },
|
|
||||||
console: { warn(...args) { failures.push(args); } },
|
|
||||||
authenticated: true, authEpoch: 1, currentPanel: "overview", liveConnected: false, liveFallback: false,
|
|
||||||
liveEvents: null, liveInterval: null, latestStatus: null,
|
|
||||||
window: {
|
|
||||||
rustProxySettings: { current: () => "listeners", render: () => undefined },
|
|
||||||
addEventListener(type, callback) { assert(!windowListeners.has(type)); windowListeners.set(type, callback); },
|
|
||||||
removeEventListener(type, callback) { assert.equal(windowListeners.get(type), callback); windowListeners.delete(type); },
|
|
||||||
},
|
|
||||||
$: name => nodes[name] || null,
|
|
||||||
t: name => name,
|
|
||||||
api: (url, args = {}) => { requests.push({ url, args, time }); return response(url, args); },
|
|
||||||
renderStatus(value) { renderCount++; rendered.push(value); },
|
|
||||||
renderLogs() { logCount++; },
|
|
||||||
renderDiagnostics() { diagCount++; },
|
|
||||||
renderLiveStatus() { throw new Error("bridge not loaded"); },
|
|
||||||
revalidateSession: async () => {
|
|
||||||
ctx.window.rustProxyRefresh.suspendForSessionValidation();
|
|
||||||
await Promise.resolve();
|
|
||||||
ctx.window.rustProxyRefresh.completeSessionValidation(true);
|
|
||||||
},
|
|
||||||
loadTunnels: async () => undefined
|
|
||||||
};
|
|
||||||
runInNewContext(bridge + "\n" + scheduler, ctx, { filename: "coordinator-browser-bundle.js" });
|
|
||||||
const refresh = ctx.window.rustProxyRefresh;
|
|
||||||
const settled = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
|
|
||||||
async function advance(ms = 0) {
|
|
||||||
const until = time + ms;
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
const due = [...timers.entries()].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
|
|
||||||
if (!due) { time = until; await settled(); return; }
|
|
||||||
const [id, timer] = due; time = timer.at; timers.delete(id); timer.fn(); await settled();
|
|
||||||
}
|
|
||||||
throw new Error("unbounded polling timer loop");
|
|
||||||
}
|
|
||||||
function switchPanel(name) { ctx.currentPanel = name; refresh.setPanel(name); }
|
|
||||||
function changeVisibility(hidden) { document.hidden = hidden; listeners.get("visibilitychange")?.(); }
|
|
||||||
function count(url) { return requests.filter(item => item.url.startsWith(url)).length; }
|
|
||||||
function registerViews() {
|
|
||||||
for (const name of ["traffic", "cache"]) {
|
|
||||||
refresh.register(name, {
|
|
||||||
interval: () => refresh.current()[`${name}_seconds`],
|
|
||||||
active: () => ctx.currentPanel === name,
|
|
||||||
run: async ({ signal, isCurrent }) => {
|
|
||||||
const value = await ctx.api(`/api/${name}`, { signal });
|
|
||||||
if (isCurrent()) rendered.push({ name, value });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ctx, refresh, requests, rendered, streams, listeners, windowListeners, timers, failures, advance, switchPanel, count,
|
|
||||||
registerViews, changeVisibility,
|
|
||||||
setResponse(fn) { response = fn; },
|
|
||||||
get renderCount() { return renderCount; },
|
|
||||||
get logCount() { return logCount; },
|
|
||||||
get diagCount() { return diagCount; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = fixture();
|
|
||||||
h.registerViews();
|
|
||||||
assert.equal(h.refresh.current().overview_seconds, 3);
|
|
||||||
assert.equal(h.refresh.current().system_info_seconds, 60);
|
|
||||||
assert.equal(h.refresh.current().traffic_seconds, 5);
|
|
||||||
assert.equal(h.refresh.current().cache_seconds, 5);
|
|
||||||
assert.equal(h.refresh.current().live_enabled, true);
|
|
||||||
assert.equal(h.streams.length, 0);
|
|
||||||
h.refresh.start(); await h.advance();
|
|
||||||
assert.equal(h.streams.length, 1, "one shared SSE connection");
|
|
||||||
assert.equal(h.streams[0].url, "/api/events?interval=3");
|
|
||||||
assert.equal(h.count("/api/traffic"), 0, "no Traffic on Overview");
|
|
||||||
assert.equal(h.count("/api/cache"), 0, "no Cache on Overview");
|
|
||||||
assert.equal(h.count("/api/system-info"), 1, "Overview uses slow diagnostics");
|
|
||||||
assert.equal(h.count("/api/status"), 0, "no duplicate status polling while SSE connects");
|
|
||||||
assert.equal(h.count("/api/logs"), 0);
|
|
||||||
await h.advance(10_000);
|
|
||||||
assert.equal(h.count("/api/system-info"), 1, "diagnostics not collected at live interval");
|
|
||||||
|
|
||||||
h.switchPanel("traffic"); await h.advance();
|
|
||||||
assert.equal(h.count("/api/traffic"), 1);
|
|
||||||
assert.equal(h.count("/api/cache"), 0);
|
|
||||||
for (let i = 0; i < 20; i++) { h.switchPanel("overview"); h.switchPanel("traffic"); }
|
|
||||||
assert.equal(h.refresh.diagnostics().filter(row => row.id === "traffic").length, 1);
|
|
||||||
await h.advance(0);
|
|
||||||
assert.equal(h.count("/api/traffic"), 2, "tab switches do not multiply immediate requests");
|
|
||||||
await h.advance(15_000);
|
|
||||||
assert.equal(h.count("/api/traffic"), 5, "one request each 5 seconds");
|
|
||||||
assert.equal(h.count("/api/cache"), 0);
|
|
||||||
|
|
||||||
h.refresh.update({ traffic_seconds: 3, cache_seconds: 0, system_info_seconds: 0, overview_seconds: 5 });
|
|
||||||
assert(h.streams[0].closed, "SSE is replaced when the administrator updates their interval");
|
|
||||||
assert.equal(h.streams.at(-1).url, "/api/events?interval=5");
|
|
||||||
await h.advance();
|
|
||||||
assert.equal(h.count("/api/traffic"), 6);
|
|
||||||
await h.advance(6_000);
|
|
||||||
assert.equal(h.count("/api/traffic"), 8);
|
|
||||||
h.switchPanel("cache"); await h.advance(20_000);
|
|
||||||
assert.equal(h.count("/api/cache"), 0, "Cache manual mode cancels polling");
|
|
||||||
await h.refresh.manual("cache");
|
|
||||||
assert.equal(h.count("/api/cache"), 1, "manual Cache works with interval=0");
|
|
||||||
await h.advance(40_000);
|
|
||||||
assert.equal(h.count("/api/cache"), 1);
|
|
||||||
assert.equal(h.count("/api/traffic"), 8, "Traffic is stopped in Cache view");
|
|
||||||
|
|
||||||
// SSE failures activate both fallbacks and a recovered snapshot switches them off.
|
|
||||||
h.streams.at(-1).fail(); await h.advance();
|
|
||||||
assert.equal(h.count("/api/status?view=live"), 1);
|
|
||||||
assert.equal(h.count("/api/logs"), 1);
|
|
||||||
await h.advance(5_000);
|
|
||||||
assert.equal(h.count("/api/status?view=live"), 2);
|
|
||||||
h.streams.at(-1).emit("snapshot", { status: { metrics: { requests: 5 } }, logs: { entries: [] } });
|
|
||||||
assert.equal(h.ctx.liveConnected, true);
|
|
||||||
await h.advance(20_000);
|
|
||||||
assert.equal(h.count("/api/status?view=live"), 2, "SSE recovery stops fallback polling");
|
|
||||||
assert.equal(h.count("/api/logs"), 2);
|
|
||||||
assert.equal(h.renderCount, 3, "two fallback status renders followed by recovered SSE");
|
|
||||||
|
|
||||||
// Hidden browser tabs pause even previously active sources, then resume cleanly.
|
|
||||||
h.refresh.update({ traffic_seconds: 3, cache_seconds: 3, system_info_seconds: 15 });
|
|
||||||
h.switchPanel("traffic"); await h.advance();
|
|
||||||
const beforeHidden = h.count("/api/traffic");
|
|
||||||
h.changeVisibility(true);
|
|
||||||
assert(h.streams.at(-1).closed, "SSE closes when hidden");
|
|
||||||
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
|
|
||||||
await h.advance(120_000);
|
|
||||||
assert.equal(h.count("/api/traffic"), beforeHidden);
|
|
||||||
h.changeVisibility(false); await h.advance(); await h.advance();
|
|
||||||
assert.equal(h.count("/api/traffic"), beforeHidden + 1);
|
|
||||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
|
|
||||||
|
|
||||||
// Off pauses all scheduled work, but retains explicit manual requests.
|
|
||||||
h.refresh.update({ live_enabled: false, traffic_seconds: 3 });
|
|
||||||
const requestsBeforeOff = h.requests.length;
|
|
||||||
await h.advance(120_000);
|
|
||||||
assert.equal(h.requests.length, requestsBeforeOff);
|
|
||||||
await h.refresh.manual("traffic");
|
|
||||||
assert.equal(h.count("/api/traffic"), beforeHidden + 2);
|
|
||||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
|
|
||||||
h.refresh.update({ live_enabled: true, traffic_seconds: 3 }); await h.advance();
|
|
||||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
|
|
||||||
|
|
||||||
// Aborting an old view prevents the obsolete response from modifying UI.
|
|
||||||
let resolveDelayed;
|
|
||||||
h.setResponse(url => url === "/api/traffic" ? new Promise(resolve => { resolveDelayed = resolve; }) : Promise.resolve({ ok: true }));
|
|
||||||
await h.advance(3_000);
|
|
||||||
const staleCount = h.rendered.length;
|
|
||||||
const request = h.requests.at(-1);
|
|
||||||
assert.equal(request.url, "/api/traffic");
|
|
||||||
h.switchPanel("cache");
|
|
||||||
assert.equal(request.args.signal.aborted, true);
|
|
||||||
resolveDelayed({ stale: true }); await h.advance();
|
|
||||||
assert.equal(h.rendered.filter(v => v.name === "traffic").length, h.rendered.slice(0, staleCount).filter(v => v.name === "traffic").length);
|
|
||||||
assert.equal(h.count("/api/cache"), 2);
|
|
||||||
|
|
||||||
// Manual diagnostics still work when their own automatic interval is zero.
|
|
||||||
h.refresh.update({ live_enabled: false, system_info_seconds: 0 });
|
|
||||||
const diagnosticRequests = h.count("/api/system-info");
|
|
||||||
await h.refresh.manual("system-info");
|
|
||||||
assert.equal(h.count("/api/system-info"), diagnosticRequests + 1);
|
|
||||||
h.refresh.update({ live_enabled: true });
|
|
||||||
|
|
||||||
// A failed source does not stop other source registrations.
|
|
||||||
const e = fixture({ withoutSse: true });
|
|
||||||
e.registerViews();
|
|
||||||
e.setResponse(url => url === "/api/traffic" ? Promise.reject(new Error("Traffic disconnected")) : Promise.resolve({ ok: true }));
|
|
||||||
e.refresh.start(); e.switchPanel("traffic"); await e.advance();
|
|
||||||
assert.equal(e.count("/api/traffic"), 1);
|
|
||||||
assert.equal(e.count("/api/status?view=live"), 1, "browsers without EventSource use status polling");
|
|
||||||
assert.equal(e.count("/api/logs"), 1, "browsers without EventSource use logs polling");
|
|
||||||
e.switchPanel("cache"); await e.advance();
|
|
||||||
assert.equal(e.count("/api/cache"), 1);
|
|
||||||
assert.equal(e.failures.length, 1);
|
|
||||||
await e.advance(5_000);
|
|
||||||
assert.equal(e.count("/api/cache"), 2);
|
|
||||||
|
|
||||||
// Multiple registrations with identical IDs must be rejected.
|
|
||||||
assert.throws(() => h.refresh.register("traffic", { interval: () => 5, run: async () => {} }), /Duplicate refresh task/);
|
|
||||||
// Logout clears timers, active SSE streams, observer and pending fetches.
|
|
||||||
h.refresh.stop();
|
|
||||||
assert.equal(h.listeners.has("visibilitychange"), false);
|
|
||||||
assert.equal(h.windowListeners.size, 0, "pagehide/pageshow listeners are removed on logout");
|
|
||||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
|
|
||||||
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
|
|
||||||
const afterLogout = h.requests.length;
|
|
||||||
h.ctx.authenticated = false;
|
|
||||||
await h.advance(600_000);
|
|
||||||
assert.equal(h.requests.length, afterLogout);
|
|
||||||
await h.refresh.manual("traffic");
|
|
||||||
assert.equal(h.requests.length, afterLogout);
|
|
||||||
|
|
||||||
// Validate incoming preferences in the browser, including malformed partial records.
|
|
||||||
const invalid = runInNewContext(scheduler + ";normalizeRefreshPreferences({ live_enabled: 1, overview_seconds: 0, traffic_seconds: 17, cache_seconds: 0, system_info_seconds: 300 });", {
|
|
||||||
document: { hidden: true, addEventListener() {}, removeEventListener() {} },
|
|
||||||
AbortController, window: {}, authenticated: false, authEpoch: 0, currentPanel: "overview", liveConnected: false,
|
|
||||||
liveFallback: false, stopLive() {}, startLive() {}, $: () => null
|
|
||||||
});
|
|
||||||
assert.equal(invalid.live_enabled, true);
|
|
||||||
assert.equal(invalid.overview_seconds, 3);
|
|
||||||
assert.equal(invalid.traffic_seconds, 5);
|
|
||||||
assert.equal(invalid.cache_seconds, 0);
|
|
||||||
assert.equal(invalid.system_info_seconds, 300);
|
|
||||||
console.log("PASS: per-account refresh validation, SSE/fallback, 5/3/0-s scheduling, tab/visibility/logout lifecycle, isolation, no duplicate/inflight/stale requests, manual mode.");
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""0.31.7 offline update-retention regression without systemd or root privileges."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
UPDATER = ROOT / "update.sh"
|
|
||||||
|
|
||||||
|
|
||||||
def mkdir_age(path: Path, days: int) -> None:
|
|
||||||
path.mkdir(parents=True)
|
|
||||||
stamp = time.time() - days * 86400
|
|
||||||
os.utime(path, (stamp, stamp))
|
|
||||||
|
|
||||||
|
|
||||||
def call(update_prefix: Path, cargo_target: Path, mockbin: Path, *, fail: bool = False, plan: bool = False) -> subprocess.CompletedProcess[str]:
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["PATH"] = f"{mockbin}:{env.get('PATH', '')}"
|
|
||||||
env["CARGO_TARGET_DIR"] = str(cargo_target)
|
|
||||||
env["RP_TEST_UPDATE_FAIL"] = "1" if fail else "0"
|
|
||||||
args = ["bash", str(UPDATER), "--prefix", str(update_prefix)]
|
|
||||||
if plan:
|
|
||||||
args.append("--plan")
|
|
||||||
return subprocess.run(args, env=env, capture_output=True, text=True, timeout=15, check=False)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
subprocess.run(["bash", "-n", str(UPDATER)], check=True)
|
|
||||||
with tempfile.TemporaryDirectory(prefix="rp-release-0317-") as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
prefix = root / "installed rustproxy"
|
|
||||||
releases = prefix / "releases"
|
|
||||||
backups = prefix / "backups"
|
|
||||||
releases.mkdir(parents=True)
|
|
||||||
backups.mkdir()
|
|
||||||
for name, age in [("r-new", 0), ("r-second", 2), ("r-live", 8), ("r-ancient", 30), ("r-older extra", 40)]:
|
|
||||||
mkdir_age(releases / name, age)
|
|
||||||
(prefix / "current").symlink_to(releases / "r-live", target_is_directory=True)
|
|
||||||
for days in range(1, 6):
|
|
||||||
mkdir_age(backups / f"before-{days}", days)
|
|
||||||
target = root / "target"
|
|
||||||
for profile in ["release", "debug"]:
|
|
||||||
(target / profile / "deps").mkdir(parents=True)
|
|
||||||
(target / profile / ".fingerprint").mkdir()
|
|
||||||
(target / profile / "build").mkdir()
|
|
||||||
(target / profile / "rust-proxy").write_text("compiled binary")
|
|
||||||
mkdir_age(target / profile / "incremental" / "old", 50)
|
|
||||||
mkdir_age(target / profile / "incremental" / "new", 1)
|
|
||||||
mockbin = root / "bin"
|
|
||||||
mockbin.mkdir()
|
|
||||||
stub = mockbin / "python3"
|
|
||||||
stub.write_text("#!/bin/sh\n[ \"${RP_TEST_UPDATE_FAIL:-0}\" != 1 ] || exit 17\nexit 0\n")
|
|
||||||
stub.chmod(0o755)
|
|
||||||
|
|
||||||
result = call(prefix, target, mockbin, fail=True)
|
|
||||||
assert result.returncode == 17, result.stderr
|
|
||||||
assert (releases / "r-older extra").exists(), "failed update cleaned releases"
|
|
||||||
assert (target / "release" / "incremental" / "old").exists(), "failed update cleaned target"
|
|
||||||
|
|
||||||
result = call(prefix, target, mockbin, plan=True)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
assert (releases / "r-older extra").exists(), "planned update cleaned releases"
|
|
||||||
|
|
||||||
result = call(prefix, target, mockbin)
|
|
||||||
assert result.returncode == 0, (result.stdout, result.stderr)
|
|
||||||
assert sorted(path.name for path in releases.iterdir()) == ["r-live", "r-new"], result.stdout
|
|
||||||
assert sorted(path.name for path in backups.iterdir()) == ["before-1", "before-2", "before-3"], result.stdout
|
|
||||||
assert (prefix / "current").resolve() == releases / "r-live", "current symlink changed"
|
|
||||||
for profile in ["debug", "release"]:
|
|
||||||
assert not (target / profile / "incremental" / "old").exists(), "old incremental retained"
|
|
||||||
assert (target / profile / "incremental" / "new").exists(), "recent incremental removed"
|
|
||||||
for artifact in ["deps", ".fingerprint", "build", "rust-proxy", ".cargo-lock"]:
|
|
||||||
assert (target / profile / artifact).exists(), f"removed Cargo artifact: {profile}/{artifact}"
|
|
||||||
|
|
||||||
# Safety: do not touch target if a parent directory is a symlink.
|
|
||||||
target_symlink_root = root / "symlink"
|
|
||||||
target_symlink_root.symlink_to(target, target_is_directory=True)
|
|
||||||
mkdir_age(target / "release" / "incremental" / "symlink-test", 50)
|
|
||||||
result = call(prefix, target_symlink_root, mockbin)
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
assert (target / "release" / "incremental" / "symlink-test").exists(), "followed symlinked target"
|
|
||||||
|
|
||||||
# Static source contracts for the managed-update cache handoff.
|
|
||||||
deploy = (ROOT / "scripts/deploy/deploy_runtime.py").read_text()
|
|
||||||
runtime = (ROOT / "src/main/admin_runtime.rs").read_text()
|
|
||||||
demote = (ROOT / "src/cache/cold/demotion.rs").read_text()
|
|
||||||
assert "stop_for_managed_update(data)" in deploy
|
|
||||||
assert "run(['systemctl', 'stop', SERVICE])" in deploy
|
|
||||||
assert "data_dir.join(\".managed-update\").is_file()" in runtime
|
|
||||||
assert "state.cache.flush_hot_to_cold_for_update()" in runtime
|
|
||||||
assert "self.flush_hot_to_cold_inner(true).await" in demote
|
|
||||||
print("PASS: 0.31.7 failed/plan/successful update, two releases + three backups, Cargo safety/symlink guards, managed-update flush wiring.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// 0.31.7 DOM/source + executable revision-diff tests (no Rust runtime).
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
const read = path => fs.readFileSync(path, 'utf8');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const css = read('src/admin/web/style.css');
|
|
||||||
const settings = read('src/admin/web/settings.js');
|
|
||||||
const hist = read('src/admin/web/app/config-history.js');
|
|
||||||
const management = read('src/admin/web/management.js');
|
|
||||||
const listener = read('src/admin/web/integrations.js');
|
|
||||||
const router = read('src/admin/router.rs');
|
|
||||||
const core = read('src/admin/web/vhosts/core.js');
|
|
||||||
const editor = read('src/admin/web/vhosts/editor.js');
|
|
||||||
const rates = read('src/admin/web/vhosts/rates.js');
|
|
||||||
const model = read('src/rate_limit.rs');
|
|
||||||
const api = JSON.parse(read('src/admin/openapi.json'));
|
|
||||||
|
|
||||||
assert.match(html, /data-settings-tab="policies"/);
|
|
||||||
for (const name of ['user-agent', 'headers', 'response']) {
|
|
||||||
assert.match(html, new RegExp(`data-policy-settings-tab="${name}"`));
|
|
||||||
assert.match(html, new RegExp(`data-policy-settings-page="${name}"`));
|
|
||||||
}
|
|
||||||
for (const form of ['global-ua-block-form', 'headers-form', 'response-form']) {
|
|
||||||
assert.equal(html.split(`id="${form}"`).length, 2, `${form} must be unique`);
|
|
||||||
}
|
|
||||||
assert.match(settings, /selectPolicyTab\("user-agent"\)/);
|
|
||||||
assert.match(html, /id="config-history-json-dialog"/);
|
|
||||||
assert.match(html, /id="config-history-compare-open"/);
|
|
||||||
const actionStart = html.indexOf('<div class="config-history-detail-actions">');
|
|
||||||
const actionEnd = html.indexOf('</div>', actionStart);
|
|
||||||
const restorePos = html.indexOf('id="config-history-restore"', actionStart);
|
|
||||||
const comparePos = html.indexOf('id="config-history-compare-open"', actionStart);
|
|
||||||
assert.ok(actionStart > -1 && restorePos > actionStart && comparePos > restorePos && comparePos < actionEnd,
|
|
||||||
'JSON compare button must appear directly under restore inside the same action group');
|
|
||||||
assert.equal(html.split('id="config-history-compare-open"').length, 2);
|
|
||||||
assert.match(html, /id="config-json-compare-title"[\s\S]*?<div class="window-tools">[\s\S]*?id="config-history-json-close"/);
|
|
||||||
assert.match(css, /dialog\.config-history-dialog,\s*dialog\.config-history-json-dialog\s*\{/);
|
|
||||||
assert.match(css, /\.config-history-dialog\[open\]\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/);
|
|
||||||
assert.match(css, /\.config-history-diff\s*\{[^}]*overflow-y:\s*auto/);
|
|
||||||
assert.match(css, /\.config-history-layout\s*\{[^}]*overflow:\s*hidden/);
|
|
||||||
assert.match(css, /@media \(max-width:\s*860px\)\s*\{[\s\S]*?\.config-history-layout\s*\{[^}]*overflow-y:\s*auto/);
|
|
||||||
assert.match(css, /\.config-history-json-columns pre\s*\{[^}]*border:\s*0/);
|
|
||||||
assert.match(hist, /"config-history-json-before"\)\.textContent/);
|
|
||||||
assert.match(hist, /"config-history-json-after"\)\.textContent/);
|
|
||||||
assert.match(css, /\.listener-protocol-settings-actions\s*\{[^}]*gap:\s*14px/);
|
|
||||||
assert.match(listener, /listener-protocol-settings-actions/);
|
|
||||||
assert.match(management, /el\("details", undefined, "acl-profile-attached"\)/);
|
|
||||||
assert.doesNotMatch(management, /attached\.open\s*=\s*true/);
|
|
||||||
assert.match(router, /include_str!\("web\/vhosts\/rates\.js"\)/);
|
|
||||||
assert.match(editor, /user_agent_limits: uaRatesReader\(\)/);
|
|
||||||
assert.match(editor, /userAgentRateEditor\(g, rate\.user_agent_limits \|\| \[\]\)/);
|
|
||||||
assert.match(core, /uaRatesReader/);
|
|
||||||
assert.match(rates, /function userAgentRateEditor/);
|
|
||||||
assert.match(model, /pub user_agent_limits: Vec<UserAgentLimit>/);
|
|
||||||
assert.ok(api.components.schemas.VhostRateLimit.properties.user_agent_limits);
|
|
||||||
|
|
||||||
const start = hist.indexOf('function configDiff('), end = hist.indexOf('function renderConfigRevisionDetail(', start);
|
|
||||||
assert.ok(start > 0 && end > start, 'executable diff function must exist');
|
|
||||||
const context = { before: null, after: null };
|
|
||||||
vm.createContext(context);
|
|
||||||
vm.runInContext(`${hist.slice(start, end)}\nglobalThis.diffResult = () => configDiff(before, after);`, context);
|
|
||||||
const diff = (before, after) => { context.before=before; context.after=after; return JSON.parse(JSON.stringify(context.diffResult())); };
|
|
||||||
let result = diff({port:80, x:{a:true, b:2}, vhosts:[{id:'v1', host:'a'},{id:'v2', host:'b'}]},
|
|
||||||
{port:443, x:{a:true, b:3}, vhosts:[{id:'v2', host:'b'},{id:'v1', host:'a'}]});
|
|
||||||
assert.deepEqual(result.map(x => x.path), ['$["port"]','$["x"]["b"]']);
|
|
||||||
result = diff({a:1, headers:[]}, {a:1, headers:[{id:'one',value:'ok'}], policy:true});
|
|
||||||
assert.deepEqual(result.map(x => x.path), ['$["headers"]["one"]["id"]','$["headers"]["one"]["value"]','$["policy"]']);
|
|
||||||
result = diff({items: [{id:'one', active: true}]}, {items: [{id:'one',active:false}]});
|
|
||||||
assert.equal(result.length,1);
|
|
||||||
assert.equal(result[0].path,'$["items"]["one"]["active"]');
|
|
||||||
assert.ok(diff({}, Object.fromEntries(Array.from({length:350},(_,i) => [i, i]))).length <= 200);
|
|
||||||
console.log('PASS: 0.31.7 Settings sections, vhost UA rate editor/API, ACL/listener layout, changed-field revision diff, bounded scrolling, conventional full JSON modal.');
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""0.31.9 source-only regression; does NOT claim Rust compilation or memory profiling."""
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import tomllib
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
|
||||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7'
|
|
||||||
assert 'name = "rust-proxy"\nversion = "0.32.7"' in read('Cargo.lock')
|
|
||||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.7'
|
|
||||||
|
|
||||||
pages = read('src/pages.rs')
|
|
||||||
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
|
||||||
file = f'src/admin/web/error_{code}.html'
|
|
||||||
markup = read(file)
|
|
||||||
assert markup.lstrip().startswith('<!doctype html>'), file
|
|
||||||
assert 'background: #131615' in markup and 'max-width: 620px' in markup, file
|
|
||||||
assert re.search(rf'<h1>{code} - [^<]+</h1>', markup), file
|
|
||||||
assert f'include_str!("admin/web/error_{code}.html")' in pages, file
|
|
||||||
assert 'status_200.html' in pages and '<h1>200 - OK</h1>' in read('src/admin/web/status_200.html')
|
|
||||||
assert 'RustProxy generated this response.' in pages and 'background: #131615' in pages
|
|
||||||
assert 'matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED)' in pages
|
|
||||||
|
|
||||||
geoip = read('src/geoip.rs')
|
|
||||||
assert 'ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]>' in geoip
|
|
||||||
assert 'ipv4.into_boxed_slice()' in geoip and 'ipv6.into_boxed_slice()' in geoip
|
|
||||||
assert 'start: [u8; 16]' in geoip and 'end: [u8; 16]' in geoip
|
|
||||||
assert 'u128::from(ip).to_be_bytes()' in geoip and 'ip <= item.end' in geoip
|
|
||||||
assert 'size_of::<V6Range>(), 34' in read('src/geoip/tests.rs')
|
|
||||||
|
|
||||||
cached = read('src/cache/fill.rs') + read('src/cache/lookup/tiered.rs')
|
|
||||||
for marker in ('bytes.try_into_mut()', 'mutable.capacity()', 'candidate.shared = Some(bytes.clone())',
|
|
||||||
'shared_allocated', 'candidate.fill_gauge.grow(allocation', 'if transformed { 0 }',
|
|
||||||
'candidate.data.extend_from_slice(data)'):
|
|
||||||
assert marker in cached, marker
|
|
||||||
|
|
||||||
q = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs')
|
|
||||||
for marker in ('sort_by: Option<String>', 'sort_descending: Option<bool>', 'valid_raw_field_key(field)',
|
|
||||||
'fn sort_file_log_records(', 'stored_log_sort_key(&record, field)',
|
|
||||||
'file_log_field(record, field)', 'None, Some(_)', 'sort_file_log_records(&mut records'):
|
|
||||||
assert marker in q, marker
|
|
||||||
assert q.index('sort_file_log_records(&mut records') < q.index('let page = records')
|
|
||||||
print('PASS: 0.31.9 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// JS-only regression of the Stored Logs sorting interaction (no Rust server).
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
const read = name => fs.readFileSync(name, 'utf8');
|
|
||||||
const ui = read('src/admin/web/file-logs.js');
|
|
||||||
const css = read('src/admin/web/style.css');
|
|
||||||
const api = JSON.parse(read('src/admin/openapi.json'));
|
|
||||||
const params = api.paths['/api/file-logs/query'].get.parameters.map(param => param.name);
|
|
||||||
assert.ok(params.includes('sort_by') && params.includes('sort_descending'));
|
|
||||||
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_sort);
|
|
||||||
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_descending);
|
|
||||||
assert.match(ui, /params\.set\("sort_by", sortBy\)/);
|
|
||||||
assert.match(ui, /params\.set\("sort_descending", String\(sortDescending\)\)/);
|
|
||||||
assert.match(css, /file-log-sort-button/);
|
|
||||||
|
|
||||||
class FakeNode {
|
|
||||||
constructor(tag, text = '', cls = '') { this.tag = tag; this.textContent = text; this.className = cls; this.children = []; this.dataset = {}; this.attrs = {}; this.events = {}; }
|
|
||||||
setAttribute(name, value) { this.attrs[name] = value; }
|
|
||||||
append(...nodes) { this.children.push(...nodes); }
|
|
||||||
replaceChildren(...nodes) { this.children = nodes; }
|
|
||||||
addEventListener(name, handler) { this.events[name] = handler; }
|
|
||||||
click() { this.events.click?.(); }
|
|
||||||
}
|
|
||||||
const row = new FakeNode('tr');
|
|
||||||
const sort = ui.slice(ui.indexOf(' function syncHeaders() {'), ui.indexOf(' function syncColumnPicker()'));
|
|
||||||
assert.ok(sort.startsWith(' function syncHeaders() {') && sort.includes('file-log-sort-button'));
|
|
||||||
const ctx = {
|
|
||||||
el: (tag, text, cls) => new FakeNode(tag, text, cls),
|
|
||||||
$: () => ({querySelector: () => row}),
|
|
||||||
orderedColumns: () => ['time', 'status'],
|
|
||||||
visibleColumns: new Set(['time', 'status']),
|
|
||||||
specialLabels: new Map([['time', 'time'], ['status', 'status']]),
|
|
||||||
columnLabel: id => id,
|
|
||||||
t: key => key,
|
|
||||||
sortBy: 'time', sortDescending: true, selectedIndex: 3, offset: 100,
|
|
||||||
saved: 0, refreshed: 0,
|
|
||||||
savePreferences() { this.saved++; },
|
|
||||||
refresh() { this.refreshed++; },
|
|
||||||
};
|
|
||||||
// Methods capture explicit counters rather than relying on vm's `this` binding.
|
|
||||||
ctx.savePreferences = () => { ctx.saved++; };
|
|
||||||
ctx.refresh = reset => { if (reset) ctx.refreshed++; };
|
|
||||||
vm.createContext(ctx);
|
|
||||||
vm.runInContext(`${sort}\nglobalThis.draw = syncHeaders;`, ctx);
|
|
||||||
ctx.draw();
|
|
||||||
assert.equal(row.children.length, 2);
|
|
||||||
assert.equal(row.children[0].attrs['aria-sort'], 'descending');
|
|
||||||
assert.equal(row.children[1].attrs['aria-sort'], 'none');
|
|
||||||
row.children[1].children[0].click();
|
|
||||||
assert.equal(ctx.sortBy, 'status');
|
|
||||||
assert.equal(ctx.sortDescending, false);
|
|
||||||
assert.equal(ctx.offset, 0);
|
|
||||||
assert.equal(ctx.saved, 1);
|
|
||||||
assert.equal(ctx.refreshed, 1);
|
|
||||||
assert.equal(row.children[1].attrs['aria-sort'], 'ascending');
|
|
||||||
row.children[1].children[0].click();
|
|
||||||
assert.equal(ctx.sortDescending, true);
|
|
||||||
assert.equal(row.children[1].attrs['aria-sort'], 'descending');
|
|
||||||
assert.equal(ctx.saved, 2);
|
|
||||||
assert.equal(ctx.refreshed, 2);
|
|
||||||
console.log('PASS: 0.31.9 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Static structure contracts for the split Rust source tree.
|
|
||||||
|
|
||||||
These checks are intentionally compiler-independent. They prevent the module split
|
|
||||||
from regressing into large implementation monoliths, allow full-sized test files,
|
|
||||||
and catch the class of dangling-attribute/broken-include mistakes that can otherwise
|
|
||||||
surface only at cargo check time.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
from rust_source import rust_source
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
SRC = ROOT / "src"
|
|
||||||
MAX_LINES = 500
|
|
||||||
|
|
||||||
|
|
||||||
def is_test_source(path: Path) -> bool:
|
|
||||||
relative = path.relative_to(ROOT)
|
|
||||||
if relative.parts[0] == "tests":
|
|
||||||
return True
|
|
||||||
return relative.parts[0] == "src" and (
|
|
||||||
path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts[1:]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
include_re = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)')
|
|
||||||
test_attr_re = re.compile(r'(?m)^\s*#\[(?:tokio::)?test(?:\([^\]]*\))?\]')
|
|
||||||
dangling_doc_before_close_re = re.compile(r'(?ms)(?:^\s*///[^\n]*\n){1,8}\s*}')
|
|
||||||
dangling_attr_before_close_re = re.compile(r'(?ms)^\s*#\[[^\n]+\]\s*\n\s*}')
|
|
||||||
|
|
||||||
def delimiters_balanced(text: str) -> bool:
|
|
||||||
stack: list[str] = []
|
|
||||||
pairs = {"}": "{", ")": "(", "]": "["}
|
|
||||||
i = 0
|
|
||||||
state = "code"
|
|
||||||
block_depth = 0
|
|
||||||
raw_hashes = 0
|
|
||||||
while i < len(text):
|
|
||||||
char = text[i]
|
|
||||||
nxt = text[i + 1] if i + 1 < len(text) else ""
|
|
||||||
if state == "code":
|
|
||||||
if char == "/" and nxt == "/":
|
|
||||||
state = "line"; i += 2; continue
|
|
||||||
if char == "/" and nxt == "*":
|
|
||||||
state = "block"; block_depth = 1; i += 2; continue
|
|
||||||
if char == '"':
|
|
||||||
state = "string"; i += 1; continue
|
|
||||||
if char == "r":
|
|
||||||
raw = re.match(r'r(#{0,16})"', text[i:])
|
|
||||||
if raw:
|
|
||||||
raw_hashes = len(raw.group(1)); state = "raw"; i += len(raw.group(0)); continue
|
|
||||||
if char == "'":
|
|
||||||
# Lifetimes are identifiers; only enter char mode when a nearby closing quote exists.
|
|
||||||
cursor = i + 1
|
|
||||||
closing = False
|
|
||||||
while cursor < min(len(text), i + 8):
|
|
||||||
if text[cursor] == "\\": cursor += 2; continue
|
|
||||||
if text[cursor] == "'": closing = True; break
|
|
||||||
if text[cursor] in "\r\n": break
|
|
||||||
cursor += 1
|
|
||||||
if closing:
|
|
||||||
state = "char"; i += 1; continue
|
|
||||||
if char in "({[": stack.append(char)
|
|
||||||
elif char in "})]":
|
|
||||||
if not stack or stack[-1] != pairs[char]: return False
|
|
||||||
stack.pop()
|
|
||||||
i += 1
|
|
||||||
elif state == "line":
|
|
||||||
if char == "\n": state = "code"
|
|
||||||
i += 1
|
|
||||||
elif state == "block":
|
|
||||||
if char == "/" and nxt == "*": block_depth += 1; i += 2
|
|
||||||
elif char == "*" and nxt == "/":
|
|
||||||
block_depth -= 1; i += 2
|
|
||||||
if block_depth == 0: state = "code"
|
|
||||||
else: i += 1
|
|
||||||
elif state == "string":
|
|
||||||
if char == "\\": i += 2
|
|
||||||
elif char == '"': state = "code"; i += 1
|
|
||||||
else: i += 1
|
|
||||||
elif state == "char":
|
|
||||||
if char == "\\": i += 2
|
|
||||||
elif char == "'": state = "code"; i += 1
|
|
||||||
else: i += 1
|
|
||||||
elif state == "raw":
|
|
||||||
terminator = '"' + "#" * raw_hashes
|
|
||||||
if text.startswith(terminator, i): i += len(terminator); state = "code"
|
|
||||||
else: i += 1
|
|
||||||
return not stack and state in {"code", "line"}
|
|
||||||
|
|
||||||
|
|
||||||
# The 500-line rule applies to production Rust sources, not unit/integration tests.
|
|
||||||
for path in sorted(ROOT.rglob("*.rs")):
|
|
||||||
if any(part in {"target", ".git"} for part in path.parts):
|
|
||||||
continue
|
|
||||||
if is_test_source(path):
|
|
||||||
continue
|
|
||||||
lines = path.read_text(encoding="utf-8").splitlines()
|
|
||||||
assert len(lines) <= MAX_LINES, f"{path.relative_to(ROOT)} has {len(lines)} lines (max {MAX_LINES})"
|
|
||||||
|
|
||||||
for path in sorted(SRC.rglob("*.rs")):
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
lines = text.splitlines()
|
|
||||||
assert delimiters_balanced(text), f"unbalanced delimiters in split Rust source: {path.relative_to(ROOT)}"
|
|
||||||
|
|
||||||
nonblank = [line.strip() for line in lines if line.strip()]
|
|
||||||
if nonblank:
|
|
||||||
assert not nonblank[-1].startswith(("#[", "///", "//!")), f"dangling Rust attribute/doc comment at EOF: {path.relative_to(ROOT)}"
|
|
||||||
assert not dangling_doc_before_close_re.search(text), f"doc comment before closing brace does not document an item: {path.relative_to(ROOT)}"
|
|
||||||
assert not dangling_attr_before_close_re.search(text), f"attribute before closing brace does not decorate an item: {path.relative_to(ROOT)}"
|
|
||||||
|
|
||||||
for target in include_re.findall(text):
|
|
||||||
included = path.parent / target
|
|
||||||
assert included.is_file(), f"broken include! in {path.relative_to(ROOT)}: {target}"
|
|
||||||
included_text = included.read_text(encoding="utf-8")
|
|
||||||
# include! expands into the caller's existing module/item scope. Inner module docs
|
|
||||||
# (`//!`) in a split fragment are therefore invalid once another item precedes them.
|
|
||||||
assert not re.search(r'(?m)^\s*//!', included_text), (
|
|
||||||
f"inner module doc comment in include! fragment: {included.relative_to(ROOT)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_test_source(path):
|
|
||||||
assert not test_attr_re.search(text), f"unit test remains in implementation source: {path.relative_to(ROOT)}"
|
|
||||||
# Test-only implementation must live in a separate file and be pulled in explicitly.
|
|
||||||
for match in re.finditer(r'(?m)^\s*#\[cfg\(test\)\]\s*$', text):
|
|
||||||
tail = text[match.end():].lstrip()
|
|
||||||
assert tail.startswith("include!(") or tail.startswith("mod tests;"), (
|
|
||||||
f"test cfg in implementation must target a separate test file: {path.relative_to(ROOT)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Unit tests are intentionally consolidated rather than mechanically numbered.
|
|
||||||
for folder in (
|
|
||||||
"admin/tests", "balance/tests", "config/tests", "web_server/tests",
|
|
||||||
"cache/tests/tests", "cache/tests/v022_cache_tests", "cache/tests/cold_tier_tests",
|
|
||||||
):
|
|
||||||
assert not (SRC / folder).exists(), f"obsolete split-test directory: {folder}"
|
|
||||||
for relative in (
|
|
||||||
"admin/tests.rs", "balance/tests.rs", "config/tests.rs", "web_server/tests.rs",
|
|
||||||
"cache/tests/tests.rs", "cache/tests/v022_cache_tests.rs", "cache/tests/cold_tier_tests.rs",
|
|
||||||
):
|
|
||||||
assert (SRC / relative).is_file(), f"missing consolidated test file: {relative}"
|
|
||||||
assert 'include_str!("web/login-keys.json")' in (SRC / "admin/tests.rs").read_text(), (
|
|
||||||
"admin test asset path must be relative to the consolidated test file"
|
|
||||||
)
|
|
||||||
assert (SRC / "admin/web/login-keys.json").is_file()
|
|
||||||
|
|
||||||
# Explicit regressions from the first Web Server split.
|
|
||||||
response = (SRC / "web_server/response.rs").read_text(encoding="utf-8")
|
|
||||||
assert not response.rstrip().endswith("#[derive(Debug, Clone)]"), "dangling derive returned in Web Server response module"
|
|
||||||
fastcgi = rust_source(ROOT, "src/web_server/fastcgi.rs")
|
|
||||||
request = (SRC / "web_server/request.rs").read_text(encoding="utf-8")
|
|
||||||
assert "use anyhow::{Context" in fastcgi or "Context," in fastcgi or ", Context" in fastcgi
|
|
||||||
assert "htaccess," in fastcgi
|
|
||||||
assert "request::server_protocol" in fastcgi
|
|
||||||
assert "pub(super) fn server_protocol" in request
|
|
||||||
headers = (SRC / "web_server/response/headers.rs").read_text(encoding="utf-8")
|
|
||||||
assert "htaccess," in headers, "Web Server response headers must import sibling htaccess after module split"
|
|
||||||
assert "ACCEPT_ENCODING," not in headers, "stale ACCEPT_ENCODING import reintroduced in response headers"
|
|
||||||
|
|
||||||
# Regressions exposed by a real `cargo check --locked --all-targets` after the 0.25.1 split.
|
|
||||||
assert "pub(super) trait FastCgiIo" in fastcgi, "FastCgiIo visibility must match sibling-visible FastCGI helpers"
|
|
||||||
for relative in ("src/proxy/websocket/tests.rs", "tests/http3.rs"):
|
|
||||||
text = (ROOT / relative).read_text(encoding="utf-8")
|
|
||||||
assert 'parse::<h3::ext::Protocol>().unwrap()' not in text, (
|
|
||||||
f"{relative} uses Result::unwrap() for h3::ext::Protocol even though InvalidProtocol has no Debug"
|
|
||||||
)
|
|
||||||
server_tests = (SRC / "server/serve/tests.rs").read_text(encoding="utf-8")
|
|
||||||
assert server_tests.count("serve_inner(") == server_tests.count("Http2Connections::default()"), (
|
|
||||||
"direct serve_inner test calls must pass the Http2Connections argument"
|
|
||||||
)
|
|
||||||
cache_policy_tests = (SRC / "cache/tests/tests.rs").read_text(encoding="utf-8")
|
|
||||||
# Older cache tests legitimately use `let plan = plan(...)`. The regression
|
|
||||||
# applies to the Vary/revalidation group formerly stored in part_03.rs.
|
|
||||||
revalidation_tests = cache_policy_tests.split(
|
|
||||||
"async fn vary_selects_variants_without_fragmenting_the_primary_hash", 1
|
|
||||||
)[1]
|
|
||||||
assert "let plan = plan(" not in revalidation_tests, "cache policy test shadows the plan() function"
|
|
||||||
cold_tests = (SRC / "cache/tests/cold_tier_tests.rs").read_text(encoding="utf-8")
|
|
||||||
for field in ("scheme:", "authority:", "target:", "tags:", "created_at:"):
|
|
||||||
assert field in cold_tests, f"ColdEntry test fixture is missing current field {field}"
|
|
||||||
admin_tests = (SRC / "admin/tests.rs").read_text(encoding="utf-8")
|
|
||||||
assert "let configuration = router\n .clone()\n .oneshot(" in admin_tests, (
|
|
||||||
"admin persistence test must retain Router before Tower::oneshot consumes a clone"
|
|
||||||
)
|
|
||||||
web_tests_1 = (SRC / "web_server/tests.rs").read_text(encoding="utf-8")
|
|
||||||
assert "ETAG, EXPIRES," not in web_tests_1, "unused EXPIRES import returned in Web Server tests"
|
|
||||||
|
|
||||||
print(f"PASS: production Rust modules are <= {MAX_LINES} lines (tests exempt), numbered test fragments are absent, include! targets resolve, and module regressions are guarded.")
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Helpers for source-level contracts over split Rust modules.
|
|
||||||
|
|
||||||
The project intentionally keeps Rust source files small and composes several modules
|
|
||||||
with include!("..."). Static contracts must inspect the logical source, not only the
|
|
||||||
thin facade file.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
_INCLUDE = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)\s*;?')
|
|
||||||
|
|
||||||
|
|
||||||
def expanded_source(path: Path, _stack: tuple[Path, ...] = ()) -> str:
|
|
||||||
path = path.resolve()
|
|
||||||
if path in _stack:
|
|
||||||
chain = " -> ".join(str(item) for item in (*_stack, path))
|
|
||||||
raise RuntimeError(f"cyclic Rust include!: {chain}")
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def replace(match: re.Match[str]) -> str:
|
|
||||||
child = (path.parent / match.group(1)).resolve()
|
|
||||||
if not child.is_file():
|
|
||||||
raise FileNotFoundError(f"{path}: include target does not exist: {match.group(1)}")
|
|
||||||
return expanded_source(child, (*_stack, path))
|
|
||||||
|
|
||||||
return _INCLUDE.sub(replace, text)
|
|
||||||
|
|
||||||
|
|
||||||
def rust_source(root: Path, relative: str) -> str:
|
|
||||||
"""Read one logical Rust source file, recursively expanding include! fragments."""
|
|
||||||
return expanded_source(root / relative)
|
|
||||||
|
|
||||||
|
|
||||||
def rust_tree(root: Path, relative_dir: str) -> str:
|
|
||||||
"""Read every Rust file beneath a module directory (including mod-declared children)."""
|
|
||||||
directory = root / relative_dir
|
|
||||||
return "\n".join(
|
|
||||||
expanded_source(path)
|
|
||||||
for path in sorted(directory.rglob("*.rs"))
|
|
||||||
if path.is_file()
|
|
||||||
)
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Source-level security invariants that must hold even before Rust runtime tests."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from pathlib import Path
|
|
||||||
from rust_source import rust_source, rust_tree
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
|
|
||||||
def read(path: str) -> str:
|
|
||||||
target = ROOT / path
|
|
||||||
return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
def need(text: str, *needles: str) -> None:
|
|
||||||
for needle in needles:
|
|
||||||
if needle not in text:
|
|
||||||
raise AssertionError(f"missing security invariant: {needle}")
|
|
||||||
|
|
||||||
web = rust_tree(ROOT, "src/web_server")
|
|
||||||
htaccess = read("src/web_server/htaccess.rs")
|
|
||||||
acl = read("src/acl.rs")
|
|
||||||
admin = read("src/admin/mod.rs")
|
|
||||||
auth = read("src/auth/mod.rs")
|
|
||||||
web_ui = read("src/admin/web/web-server.js")
|
|
||||||
|
|
||||||
# Preset regression: selecting a preset must actually materialize normal config fields.
|
|
||||||
need(web_ui,
|
|
||||||
"function presetChanged()",
|
|
||||||
'if ($("web-service-preset").value !== "custom") applyPreset();',
|
|
||||||
'$("web-service-preset").addEventListener("change", presetChanged);')
|
|
||||||
|
|
||||||
# Request target/path canonicalization: reject double decoding, encoded separators and dot traversal.
|
|
||||||
need(acl,
|
|
||||||
"ambiguous encoded path character",
|
|
||||||
"ambiguous path segments",
|
|
||||||
"!matches!(decoded, b'/' | b'\\\\' | b'%' | b'?' | b'#' | b';')",
|
|
||||||
"path.len() <= 8192")
|
|
||||||
|
|
||||||
# FastCGI must not inherit client proxy settings (httpoxy) and must not control HTTP framing.
|
|
||||||
need(web,
|
|
||||||
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
|
|
||||||
'if name.as_str() == "proxy"',
|
|
||||||
'"content-length"',
|
|
||||||
'"transfer-encoding"',
|
|
||||||
'"connection"',
|
|
||||||
'"upgrade"',
|
|
||||||
'"proxy-authenticate"',
|
|
||||||
'"proxy-authorization"',
|
|
||||||
"(200..=599).contains(&code)",
|
|
||||||
"unsupported informational/invalid status",
|
|
||||||
"reserved_fastcgi_param",
|
|
||||||
'"PHP_ADMIN_VALUE"',
|
|
||||||
"pass_authorization",
|
|
||||||
"max_request_body_bytes",
|
|
||||||
"StatusCode::PAYLOAD_TOO_LARGE",
|
|
||||||
"max_response_header_bytes",
|
|
||||||
"stderr_log_bytes")
|
|
||||||
|
|
||||||
# File-system confinement and .htaccess loading must not follow attacker-controlled symlinks.
|
|
||||||
need(web, "libc::openat", "libc::O_NOFOLLOW", "secure filesystem confinement")
|
|
||||||
need(htaccess,
|
|
||||||
"read_htaccess_nofollow",
|
|
||||||
"libc::O_NOFOLLOW",
|
|
||||||
"fs::symlink_metadata",
|
|
||||||
".htaccess scan escaped the Web Service document root",
|
|
||||||
"must not be a symlink",
|
|
||||||
"MAX_HTACCESS_FILES",
|
|
||||||
"MAX_HTACCESS_TOTAL_BYTES")
|
|
||||||
need(web, "htaccess_configuration_file_cannot_be_a_symlink")
|
|
||||||
|
|
||||||
# PHP source must fail closed instead of becoming a static download when FPM is unavailable.
|
|
||||||
need(web,
|
|
||||||
"php_source_extensions_are_not_served_without_php_fpm",
|
|
||||||
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
|
|
||||||
"fastcgi_never_forwards_proxy_header_as_http_proxy",
|
|
||||||
"fastcgi_response_cannot_control_downstream_framing_or_hop_headers",
|
|
||||||
"fastcgi_response_rejects_informational_status_codes",
|
|
||||||
"php_custom_fastcgi_params_cannot_override_core_or_php_admin_values",
|
|
||||||
"php_request_body_limit_returns_413_before_fastcgi_connect")
|
|
||||||
|
|
||||||
# Admin plane: exact anonymous allowlist + cookie/CSRF/origin/browser marker + CSP/frame denial.
|
|
||||||
need(admin,
|
|
||||||
"fn anonymous_allowed",
|
|
||||||
'single_header(headers, "origin")',
|
|
||||||
'single_header(headers, "x-rustproxy-request") != Some("1")',
|
|
||||||
'single_header(headers, "x-csrf-token")',
|
|
||||||
'"auth.csrf"',
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"object-src 'none'",
|
|
||||||
"anonymous_operational_paths_fail_closed_before_extractors",
|
|
||||||
"duplicate_csrf_or_marker_is_rejected_and_session_survives")
|
|
||||||
need(auth,
|
|
||||||
"HttpOnly; SameSite=Strict",
|
|
||||||
'if secure { "; Secure" } else { "" }',
|
|
||||||
"Argon2id",
|
|
||||||
"(12..=128).contains(&value.len())")
|
|
||||||
|
|
||||||
# Web serving/admin code must not execute shell commands as part of request/config handling.
|
|
||||||
for path, text in {
|
|
||||||
"src/web_server/*.rs": web,
|
|
||||||
"src/web_server/htaccess.rs": htaccess,
|
|
||||||
"src/admin/mod.rs": admin,
|
|
||||||
}.items():
|
|
||||||
if "Command::new" in text or "std::process::Command" in text:
|
|
||||||
raise AssertionError(f"unexpected process execution primitive in {path}")
|
|
||||||
|
|
||||||
print("PASS: production security source invariants (paths, FastCGI, htaccess, admin auth/CSRF, no shell execution) are present.")
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Offline minimum-version gates for network-facing RustSec advisories reviewed 2026-10-04.
|
|
||||||
|
|
||||||
This is deliberately not a replacement for `cargo audit`: it prevents known-fixed
|
|
||||||
packages from being accidentally downgraded when the lockfile changes.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
LOCK = (ROOT / "Cargo.lock").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def versions() -> dict[str, list[str]]:
|
|
||||||
found: dict[str, list[str]] = {}
|
|
||||||
for block in LOCK.split("[[package]]")[1:]:
|
|
||||||
match = re.search(r'\n?name = "([^"]+)"\nversion = "([^"]+)"', block)
|
|
||||||
if match:
|
|
||||||
found.setdefault(match.group(1), []).append(match.group(2))
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def triplet(value: str) -> tuple[int, int, int]:
|
|
||||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", value)
|
|
||||||
if not match:
|
|
||||||
raise AssertionError(f"unsupported version syntax: {value}")
|
|
||||||
return tuple(map(int, match.groups()))
|
|
||||||
|
|
||||||
|
|
||||||
packages = versions()
|
|
||||||
minimums = {
|
|
||||||
# RUSTSEC-2026-0285
|
|
||||||
"rustls": (0, 23, 45),
|
|
||||||
# RUSTSEC-2026-0185 and RUSTSEC-2026-0037
|
|
||||||
"quinn-proto": (0, 11, 15),
|
|
||||||
# RUSTSEC-2026-0104 (also covers 0049/0098/0099)
|
|
||||||
"rustls-webpki": (0, 103, 13),
|
|
||||||
# RUSTSEC-2025-0009
|
|
||||||
"ring": (0, 17, 12),
|
|
||||||
# RUSTSEC-2024-0363
|
|
||||||
"sqlx": (0, 8, 1),
|
|
||||||
# RUSTSEC-2026-0258
|
|
||||||
"h2": (0, 4, 16),
|
|
||||||
# RUSTSEC-2026-0007
|
|
||||||
"bytes": (1, 11, 1),
|
|
||||||
# RUSTSEC-2026-0009
|
|
||||||
"time": (0, 3, 47),
|
|
||||||
}
|
|
||||||
for package, minimum in minimums.items():
|
|
||||||
locked = packages.get(package)
|
|
||||||
if not locked:
|
|
||||||
raise AssertionError(f"expected security-critical dependency {package} in Cargo.lock")
|
|
||||||
for value in locked:
|
|
||||||
if triplet(value) < minimum:
|
|
||||||
raise AssertionError(f"{package} {value} is below reviewed patched minimum {'.'.join(map(str, minimum))}")
|
|
||||||
|
|
||||||
# 2026 malicious-crate campaign snapshot reviewed against RustSec on 2026-10-04.
|
|
||||||
# Keep the exact arrayref version gate because earlier arrayref releases are legitimate.
|
|
||||||
for value in packages.get("arrayref", []):
|
|
||||||
if value == "0.3.10":
|
|
||||||
raise AssertionError("Cargo.lock contains compromised arrayref 0.3.10")
|
|
||||||
|
|
||||||
malicious_packages = {
|
|
||||||
"greentic-setup", "greentic-setup-dev", "append-only-vec", "proc-macro1",
|
|
||||||
"internment", "proc-macro-en", "onering", "logflux", "exploration",
|
|
||||||
"sui-execution-cut", "mysten-metrics", "microsoftsystem64",
|
|
||||||
"pretty-changelog-logger", "safe-agent-rs", "logtrace", "jfrog_quotes",
|
|
||||||
"tree-sitter-pkl", "custom-req-on-workers", "sophosfirewall-python",
|
|
||||||
"statsrelay-protobuf", "time_calibrator", "tracings", "tracing_checks",
|
|
||||||
"rpc-check", "tracing-check", "clob-sdk", "polymarkets-rs-clob-client",
|
|
||||||
"polymarkets-client-sdk", "polymarket-client-sdks", "finch-rst",
|
|
||||||
}
|
|
||||||
present = sorted(malicious_packages.intersection(packages))
|
|
||||||
if present:
|
|
||||||
raise AssertionError(f"Cargo.lock contains RustSec-listed malicious package(s): {', '.join(present)}")
|
|
||||||
|
|
||||||
print("PASS: Cargo.lock stays above reviewed RustSec minimums and excludes the reviewed 2026 malicious-crate set (snapshot 2026-10-04).")
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
|
|
||||||
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
|
|
||||||
const json = path => JSON.parse(read(path));
|
|
||||||
|
|
||||||
const manifest = read('Cargo.toml');
|
|
||||||
const lib = read('src/lib.rs');
|
|
||||||
const model = read('src/config/model.rs');
|
|
||||||
const vhost = read('src/vhost/model.rs');
|
|
||||||
const forward = read('src/proxy/forward_entry.rs');
|
|
||||||
const loggingApi = read('src/admin/logging_api.rs');
|
|
||||||
const ua = read('src/user_agent.rs');
|
|
||||||
const stored = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs') + read('src/admin/file_log_search.rs');
|
|
||||||
const html = read('src/admin/web/index.html');
|
|
||||||
const core = read('src/admin/web/app/core.js');
|
|
||||||
const editor = read('src/admin/web/vhosts/editor.js');
|
|
||||||
const traffic = read('src/admin/web/traffic-panel.js');
|
|
||||||
const fileLogs = read('src/admin/web/file-logs.js');
|
|
||||||
const openapi = json('src/admin/openapi.json');
|
|
||||||
const en = json('langs/en_US.json').translations;
|
|
||||||
const pl = json('langs/pl_pl.json').translations;
|
|
||||||
|
|
||||||
assert.match(manifest, /version = "0\.32\.6"/);
|
|
||||||
assert.match(lib, /pub mod user_agent;/);
|
|
||||||
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
|
||||||
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
|
||||||
assert.match(loggingApi, /"user-agent-blocking"/);
|
|
||||||
assert.match(ua, /MAX_PATTERNS: usize = 128/);
|
|
||||||
assert.match(ua, /candidate\.contains\(pattern\)/);
|
|
||||||
assert.match(ua, /pattern\.contains\('\*'\)/);
|
|
||||||
const denyPos = forward.indexOf('denied by User-Agent policy');
|
|
||||||
const redirectPos = forward.indexOf('let redirect = snapshot.redirect_on');
|
|
||||||
assert.ok(denyPos > 0 && redirectPos > denyPos, 'UA policy must run before redirect/origin processing');
|
|
||||||
|
|
||||||
assert.match(html, /id="global-ua-block-form"/);
|
|
||||||
assert.match(core, /saveSection\("user-agent-blocking"/);
|
|
||||||
assert.match(editor, /"ua-block-patterns"/);
|
|
||||||
assert.match(traffic, /traffic-copy-value/);
|
|
||||||
assert.match(traffic, /navigator\.clipboard\?\.writeText/);
|
|
||||||
|
|
||||||
assert.match(html, /id="file-log-consolidate-vhosts"/);
|
|
||||||
assert.match(html, /id="file-log-aggregate-form"/);
|
|
||||||
assert.match(stored, /group_id/);
|
|
||||||
assert.match(stored, /consolidate_vhosts/);
|
|
||||||
assert.match(stored, /aggregate_field/);
|
|
||||||
assert.match(stored, /FILE_LOG_MAX_AGGREGATE_VALUES: usize = 4096/);
|
|
||||||
assert.match(fileLogs, /member_count/);
|
|
||||||
assert.match(fileLogs, /rp-file-logs-consolidate-vhosts/);
|
|
||||||
assert.match(fileLogs, /localStorage\.getItem\(CONSOLIDATION_STORAGE_KEY\)/);
|
|
||||||
assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
|
||||||
assert.match(fileLogs, /aggregate_min_count/);
|
|
||||||
assert.match(fileLogs, /file-log-aggregate-copy/);
|
|
||||||
|
|
||||||
assert.equal(openapi.info.version, '0.32.7');
|
|
||||||
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
|
||||||
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
|
||||||
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
|
||||||
const params = openapi.paths['/api/file-logs/query'].get.parameters.map(item => item.name);
|
|
||||||
for (const name of ['consolidate_vhosts', 'aggregate_field', 'aggregate_min_count', 'aggregate_limit']) assert.ok(params.includes(name), name);
|
|
||||||
const sections = openapi.paths['/api/config/{section}'].put.parameters.find(item => item.name === 'section').schema.enum;
|
|
||||||
assert.ok(sections.includes('user-agent-blocking'));
|
|
||||||
|
|
||||||
for (const pack of [en, pl]) {
|
|
||||||
for (const key of ['globalUaBlockingTitle', 'vhUaBlockEnabled', 'fileLogsConsolidateVhosts', 'fileLogsAdvancedParser', 'copied', 'copyValue']) {
|
|
||||||
assert.equal(typeof pack[key], 'string', key);
|
|
||||||
assert.ok(pack[key].length > 0, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('PASS: 0.31.7 UA blocking, copy controls, repeated-value parser, vhost log consolidation, OpenAPI and locales.');
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Compiler-independent source-size and test-isolation contracts for non-Rust modules."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
MAX_LINES = 500
|
|
||||||
|
|
||||||
|
|
||||||
def lines(path: Path) -> int:
|
|
||||||
return len(path.read_text(encoding="utf-8").splitlines())
|
|
||||||
|
|
||||||
# Browser implementation modules are bounded. QA harnesses under tools/checks are checked separately.
|
|
||||||
for path in sorted((ROOT / "src/admin/web").rglob("*.js")):
|
|
||||||
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
|
|
||||||
|
|
||||||
# Docker Agent implementation and Go tests are separate files and all remain bounded.
|
|
||||||
for path in sorted((ROOT / "docker-agent").glob("*.go")):
|
|
||||||
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
|
|
||||||
if not path.name.endswith("_test.go"):
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
assert not re.search(r"(?m)^func\s+Test[A-Z]", text), f"Go test remains in implementation file: {path.relative_to(ROOT)}"
|
|
||||||
|
|
||||||
# Application-coupled Python entry points are split from external QA/dev tooling.
|
|
||||||
for directory in ("scripts/runtime", "scripts/build", "scripts/deploy", "scripts/migration"):
|
|
||||||
for path in sorted((ROOT / directory).glob("*.py")):
|
|
||||||
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
|
|
||||||
|
|
||||||
for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"):
|
|
||||||
assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}"
|
|
||||||
for required in (
|
|
||||||
"src/admin/web/app/core.js", "src/admin/web/app/session-resume.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/vhosts/core.js", "src/admin/web/vhosts/protocol.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js",
|
|
||||||
"docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go",
|
|
||||||
"docker-agent/certificate.go", "docker-agent/server.go",
|
|
||||||
"scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py",
|
|
||||||
"scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py",
|
|
||||||
):
|
|
||||||
assert (ROOT / required).is_file(), f"missing split source module: {required}"
|
|
||||||
|
|
||||||
|
|
||||||
# When Node is available, parse every standalone browser module and both assembled public bundles.
|
|
||||||
node = shutil.which("node")
|
|
||||||
if node:
|
|
||||||
env = dict(os.environ, TERM=os.environ.get("TERM", "xterm"))
|
|
||||||
standalone = list((ROOT / "src/admin/web").rglob("*.js"))
|
|
||||||
for path in sorted(standalone):
|
|
||||||
subprocess.run([node, "--check", str(path)], check=True, env=env, stdout=subprocess.DEVNULL)
|
|
||||||
from web_assets import read_app_js, read_vhosts_js
|
|
||||||
browser_markup = (ROOT / "src/admin/web/index.html").read_text(encoding="utf-8")
|
|
||||||
assert browser_markup.count('<script defer="" src="/assets/') == 1, "one private JS bundle prevents module race"
|
|
||||||
assert '/assets/app___RUSTPROXY_ASSET_ID__.js' in browser_markup
|
|
||||||
with tempfile.TemporaryDirectory(prefix="rustproxy-js-contract-") as directory:
|
|
||||||
temporary = Path(directory)
|
|
||||||
for name, content in (("app.js", read_app_js(ROOT)), ("vhosts.js", read_vhosts_js(ROOT))):
|
|
||||||
target = temporary / name
|
|
||||||
target.write_text(content, encoding="utf-8")
|
|
||||||
subprocess.run([node, "--check", str(target)], check=True, env=env, stdout=subprocess.DEVNULL)
|
|
||||||
# The public vhosts asset wraps three individually valid source modules in one private IIFE.
|
|
||||||
# Every source part is parsed above; this additional check keeps the assembled browser asset valid.
|
|
||||||
|
|
||||||
print(f"PASS: browser, Docker Agent and application-coupled Python modules are <= {MAX_LINES} lines, tooling is separated, tests are isolated, and browser source boundaries parse cleanly.")
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify removed history is optional, but live embedded documents remain required."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
from preflight import check
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
|
|
||||||
# A compact 0.31.7 source tree may omit historical version-labelled documents.
|
|
||||||
# It must still pass the same preflight used by every ./dev.sh command.
|
|
||||||
assert check(ROOT) == []
|
|
||||||
|
|
||||||
with TemporaryDirectory(prefix="rustproxy-pruned-source-") as tmp:
|
|
||||||
stripped = Path(tmp) / "RustProxy"
|
|
||||||
shutil.copytree(
|
|
||||||
ROOT, stripped,
|
|
||||||
ignore=shutil.ignore_patterns(".git", "target", "data", ".upgrade-backup", "__pycache__"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# This functional document is in the Reader registry, but intentionally not
|
|
||||||
# in the legacy hand-maintained required-file list. It must still be caught.
|
|
||||||
reader_doc = stripped / "docs/REFERENCES.md"
|
|
||||||
assert reader_doc.is_file(), "Missing test fixture: docs/REFERENCES.md"
|
|
||||||
reader_doc.unlink()
|
|
||||||
try:
|
|
||||||
check(stripped)
|
|
||||||
except ValueError as error:
|
|
||||||
assert "Missing embedded Reader document: docs/REFERENCES.md" in str(error), error
|
|
||||||
else:
|
|
||||||
raise AssertionError("Preflight accepted missing compile-time embedded document")
|
|
||||||
|
|
||||||
shutil.copy2(ROOT / "docs/REFERENCES.md", reader_doc)
|
|
||||||
required_module = stripped / "src/cache/mod.rs"
|
|
||||||
required_module.unlink()
|
|
||||||
try:
|
|
||||||
check(stripped)
|
|
||||||
except ValueError as error:
|
|
||||||
assert "missing src/cache/mod.rs" in str(error), error
|
|
||||||
else:
|
|
||||||
raise AssertionError("Preflight accepted missing runtime source")
|
|
||||||
|
|
||||||
print("PASS: pruned historical documents are optional; embedded Reader files and runtime sources are mandatory.")
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// Verify the real opt-in Stream Dashboard component against a lightweight DOM.
|
|
||||||
// No server, browser layout engine or Rust executable is exercised here.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const src = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
|
|
||||||
const preferencesSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
|
||||||
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
|
|
||||||
assert(!/\b(setInterval|setTimeout|fetch|XMLHttpRequest)\s*\(/.test(src), 'widgets must not create timers/fetches');
|
|
||||||
const cards = new Map(ids.map(id => [id, { hidden: true }]));
|
|
||||||
const elements = new Map();
|
|
||||||
for (const name of ['overview', 'overview-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'traffic', 'traffic-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
|
|
||||||
elements.set(`stream-dashboard-${name}`, { textContent: '-', attrs: new Map(), setAttribute(k, v) { this.attrs.set(k, v); } });
|
|
||||||
}
|
|
||||||
const preferences = { dashboard_hidden_widgets: [...ids] };
|
|
||||||
const window = { rustProxyPreferences: { current: () => preferences } };
|
|
||||||
let clock = 1000;
|
|
||||||
const scriptContext = { window, document: { querySelector(selector) {
|
|
||||||
const match = selector.match(/^\[data-dashboard-widget="([^\"]+)"\]$/);
|
|
||||||
return match ? cards.get(match[1]) || null : null;
|
|
||||||
} }, $: name => elements.get(name), ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: ids },
|
|
||||||
envelope: { config: { streams: [
|
|
||||||
{ id: 'tcp', enabled: true, protocol: 'tcp' },
|
|
||||||
{ id: 'udp', enabled: true, protocol: 'udp' }
|
|
||||||
] } }, latestStatus: null, currentPanel: 'overview',
|
|
||||||
performance: { now: () => clock }, t: key => key, fmt: n => `${n}`, bytes: n => `${n} B`, Math,
|
|
||||||
};
|
|
||||||
vm.runInNewContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8') + '\n' + src, scriptContext);
|
|
||||||
const widget = window.rustProxyStreamDashboard;
|
|
||||||
const status = { streams: { active: [
|
|
||||||
{ id: 'tcp', enabled: true, protocol: 'tcp' },
|
|
||||||
{ id: 'udp', enabled: true, protocol: 'udp' }
|
|
||||||
], metrics: {
|
|
||||||
tcp: { active: 3, bytes_in: 500, bytes_out: 1000, backends: [
|
|
||||||
{ id: 'a', enabled: true, drain: false, status: 'healthy' },
|
|
||||||
{ id: 'b', enabled: true, drain: false, status: 'unhealthy' }
|
|
||||||
] },
|
|
||||||
udp: { active: 2, bytes_in: 100, bytes_out: 200, backends: [
|
|
||||||
{ id: 'a', enabled: true, drain: false, status: 'unverified' }
|
|
||||||
] }
|
|
||||||
} } };
|
|
||||||
scriptContext.latestStatus = status;
|
|
||||||
widget.render(status);
|
|
||||||
assert(ids.every(id => cards.get(id).hidden), 'new widgets are hidden by default');
|
|
||||||
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'hidden chart must not sample');
|
|
||||||
preferences.dashboard_hidden_widgets = [];
|
|
||||||
widget.render(status);
|
|
||||||
assert(ids.every(id => !cards.get(id).hidden));
|
|
||||||
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5');
|
|
||||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '2 / 2');
|
|
||||||
assert.equal(elements.get('stream-dashboard-health').textContent, '1 / 1');
|
|
||||||
clock += 1000;
|
|
||||||
status.streams.metrics.tcp.bytes_in += 1024;
|
|
||||||
status.streams.metrics.tcp.bytes_out += 512;
|
|
||||||
widget.render(status);
|
|
||||||
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '1024 B/s');
|
|
||||||
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '512 B/s');
|
|
||||||
assert(elements.get('stream-dashboard-rx').attrs.get('points')?.includes(','));
|
|
||||||
preferences.dashboard_hidden_widgets = ['stream_traffic'];
|
|
||||||
widget.syncVisibility();
|
|
||||||
assert(cards.get('stream_traffic').hidden);
|
|
||||||
const previous = elements.get('stream-dashboard-rx').attrs.get('points');
|
|
||||||
clock += 1000;
|
|
||||||
widget.render(status);
|
|
||||||
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), previous, 'hidden chart must not update');
|
|
||||||
// Explicitly enabled cards remain visible even without active/configured streams.
|
|
||||||
// The user should see a neutral state, not a widget that ignores Appearance.
|
|
||||||
preferences.dashboard_hidden_widgets = [];
|
|
||||||
scriptContext.envelope.config.streams = [];
|
|
||||||
scriptContext.latestStatus = { streams: { active: [], metrics: {} } };
|
|
||||||
widget.render(scriptContext.latestStatus);
|
|
||||||
assert(ids.every(id => !cards.get(id).hidden), 'appearance must control Stream card visibility even with no listeners');
|
|
||||||
for (const name of ['overview', 'sessions', 'health', 'traffic']) {
|
|
||||||
assert.equal(elements.get(`stream-dashboard-${name}`).textContent, '-');
|
|
||||||
if (name !== 'traffic') assert.equal(elements.get(`stream-dashboard-${name}-detail`).textContent, 'streamWidgetUnavailable');
|
|
||||||
}
|
|
||||||
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '-');
|
|
||||||
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '-');
|
|
||||||
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), '0,55 300,55', 'empty status must clear the graph');
|
|
||||||
// A desired but not yet running Stream must still make the overview useful.
|
|
||||||
scriptContext.envelope.config.streams = [{ id: 'pending', enabled: true, protocol: 'tcp' }];
|
|
||||||
preferences.dashboard_hidden_widgets = [];
|
|
||||||
widget.render({ streams: { active: [], metrics: {} } });
|
|
||||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '0 / 1');
|
|
||||||
assert(!cards.get('stream_overview').hidden, 'restart-pending listener does not suppress the card');
|
|
||||||
preferences.dashboard_hidden_widgets = [...ids];
|
|
||||||
widget.render(scriptContext.latestStatus);
|
|
||||||
assert(ids.every(id => cards.get(id).hidden), 'only the Appearance switch hides opt-in cards');
|
|
||||||
widget.reset();
|
|
||||||
|
|
||||||
// Reuse original Appearance normalization logic to protect 0.31.x saved layouts.
|
|
||||||
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
|
|
||||||
const definitions = preferencesSource.slice(preferencesSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), preferencesSource.indexOf('const CHART_STYLE_KEYS'));
|
|
||||||
const normalize = preferencesSource.slice(preferencesSource.indexOf('function normalizeDashboardWidgets'), preferencesSource.indexOf('function browserLanguage'));
|
|
||||||
const sandbox = {};
|
|
||||||
vm.runInNewContext(`${registryPrelude}\n${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
|
|
||||||
const oldOrder = ['requests', 'errors'];
|
|
||||||
const migratedOrder = [...sandbox.normalizeDashboardWidgets(oldOrder)];
|
|
||||||
const migratedHidden = [...sandbox.normalizeHiddenDashboardWidgets([], oldOrder)];
|
|
||||||
assert.deepEqual(migratedOrder.slice(0, 2), oldOrder);
|
|
||||||
assert(ids.every(id => migratedOrder.includes(id) && migratedHidden.includes(id)), 'legacy order must add cards as hidden');
|
|
||||||
console.log('PASS: opt-in Stream dashboard uses shared data with no polling, handles user visibility/empty-status states, and migrates old Appearance layouts.');
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
// Regression for persisted Appearance preferences and Stream widgets.
|
|
||||||
// Uses the actual JS functions with a minimal DOM, no network or timers.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const prefsSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
|
||||||
const streamSource = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
|
||||||
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
|
|
||||||
assert(html.includes('id="panel-overview"'));
|
|
||||||
for (const id of ids) assert(html.includes(`data-dashboard-widget="${id}"`));
|
|
||||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
|
|
||||||
'Overview details must never become hover/focus popovers');
|
|
||||||
assert.match(css, /#panel-overview \.stats-grid \.stat-chart\s*>\s*\.stat-chart-legend/,
|
|
||||||
'in-card chart legends must be visible, including on touch devices');
|
|
||||||
assert.match(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);\s*\}/,
|
|
||||||
'desktop Overview must retain the original six cards per row');
|
|
||||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\);\s*\}/,
|
|
||||||
'do not replace the original six-column Overview with a four-column layout');
|
|
||||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
|
|
||||||
'0.32.0 fixed height produced empty tiles; let inline metric legends size the row');
|
|
||||||
assert.match(html, /id="stream-dashboard-traffic-detail"/);
|
|
||||||
assert.match(html, /id="stream-dashboard-rx-rate"/);
|
|
||||||
assert.match(html, /id="stream-dashboard-tx-rate"/);
|
|
||||||
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"')), /<article[^>]*tabindex="0"/,
|
|
||||||
'metric cards must not introduce redundant keyboard stops');
|
|
||||||
|
|
||||||
const cardIds = [...html.matchAll(/<article class="stat[^"]*" data-dashboard-widget="([^"]+)"/g)].map(m => m[1]);
|
|
||||||
assert.equal(cardIds.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_')).length, 16, 'original Overview cards must be retained');
|
|
||||||
assert.equal(cardIds.length, 48, '30 existing metric/Stream/backend tiles and 18 optional small charts must be registered');
|
|
||||||
const cards = new Map(cardIds.map(id => [id, { dataset: { dashboardWidget: id }, hidden: id.startsWith('stream_') }]));
|
|
||||||
const grid = { querySelectorAll: () => [...cards.values()], append(node) { assert(cards.has(node.dataset.dashboardWidget)); } };
|
|
||||||
const elements = new Map();
|
|
||||||
for (const name of ['overview', 'overview-detail', 'traffic', 'traffic-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
|
|
||||||
elements.set('stream-dashboard-' + name, { textContent: '-', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; } });
|
|
||||||
}
|
|
||||||
const status = { streams: {
|
|
||||||
active: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
|
|
||||||
desired: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
|
|
||||||
metrics: { alpha: { active: 5, bytes_in: 1024, bytes_out: 2048, backends: [{ id: 'a', enabled: true, status: 'healthy' }] } }
|
|
||||||
} };
|
|
||||||
const window = { rustProxyRefresh: { update() {} } };
|
|
||||||
const context = {
|
|
||||||
window, latestStatus: status, envelope: { config: { streams: status.streams.desired } },
|
|
||||||
currentPanel: 'overview', authenticated: true, languagePacks: { size: 0 },
|
|
||||||
$: id => elements.get(id), document: { querySelector(selector) {
|
|
||||||
if (selector === '#panel-overview .stats-grid') return grid;
|
|
||||||
const match = selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/);
|
|
||||||
return match ? cards.get(match[1]) : null;
|
|
||||||
} }, performance: { now: () => 1000 }, t: key => key,
|
|
||||||
fmt: v => String(v), bytes: v => v + ' B', Math,
|
|
||||||
normalizeRefreshPreferences: v => v || {}, renderRefreshPreferences() {},
|
|
||||||
applyAdminTheme: v => v, applyAdminLanguage: v => v, applyAdminUiScale: v => v,
|
|
||||||
applyAdminTimeFormat: v => v, applyAdminChartStyle: v => v,
|
|
||||||
applyAdminButtonStyle: v => v, applyAdminCornerStyle: v => v, applyAdminTableDensity: v => v,
|
|
||||||
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
|
|
||||||
};
|
|
||||||
vm.createContext(context);
|
|
||||||
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
|
|
||||||
const constants = prefsSource.slice(prefsSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefsSource.indexOf('const CHART_STYLE_KEYS'));
|
|
||||||
const normalize = prefsSource.slice(prefsSource.indexOf('function normalizeDashboardWidgets'), prefsSource.indexOf('function browserLanguage'));
|
|
||||||
const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboardLayout'), prefsSource.indexOf('function dashboardWidgetLabel'));
|
|
||||||
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme('));
|
|
||||||
vm.runInContext(`${registryPrelude}\n${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
|
|
||||||
window.rustProxyPreferences = { current: () => context.getPrefs() };
|
|
||||||
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
|
|
||||||
vm.runInContext(streamSource, context);
|
|
||||||
|
|
||||||
// First load (or page reload): defaults hide the new widgets.
|
|
||||||
const initial = { dashboard_widgets: cardIds, dashboard_hidden_widgets: ids };
|
|
||||||
context.applyAdminPreferencesTest(initial);
|
|
||||||
assert(ids.every(id => cards.get(id).hidden), 'opt-in cards must start hidden');
|
|
||||||
|
|
||||||
// Server responds to a previously saved opt-in; at this point the old in-memory
|
|
||||||
// prefs are still hidden. The new layout must win before adminPreferences is set.
|
|
||||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
|
||||||
assert(ids.every(id => !cards.get(id).hidden), 'server-saved opt-in must reveal cards immediately');
|
|
||||||
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5', 'no need to wait for polling');
|
|
||||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '1 / 1');
|
|
||||||
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'cached repaint is not a traffic sample');
|
|
||||||
|
|
||||||
// Hide one widget and restore it, all within the same session. Normal cards stay put.
|
|
||||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ['stream_traffic'] });
|
|
||||||
assert(cards.get('stream_traffic').hidden);
|
|
||||||
assert(!cards.get('requests').hidden);
|
|
||||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
|
||||||
assert(!cards.get('stream_traffic').hidden);
|
|
||||||
|
|
||||||
// A disabled/absent Stream must not override an explicit Appearance opt-in.
|
|
||||||
context.envelope.config.streams = [];
|
|
||||||
context.latestStatus = { streams: { active: [], desired: [], metrics: {} } };
|
|
||||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
|
||||||
assert(ids.every(id => !cards.get(id).hidden), 'show requested cards even with no configured Stream');
|
|
||||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '-');
|
|
||||||
assert.equal(elements.get('stream-dashboard-overview-detail').textContent, 'streamWidgetUnavailable');
|
|
||||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ids });
|
|
||||||
assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
|
|
||||||
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
|
|
||||||
// Native title tooltips do not participate in layout or create horizontal scroll.
|
|
||||||
assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed');
|
|
||||||
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*overflow-x: auto;/s,
|
|
||||||
'Appearance must use a compact horizontal ribbon instead of long half-width columns');
|
|
||||||
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list > \.dashboard-widget-setting\s*\{[^}]*min-width: 226px;/s,
|
|
||||||
'Appearance ribbon must retain readable labels and right-aligned reorder buttons');
|
|
||||||
assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
|
|
||||||
'old popover DOM and associations must be removed');
|
|
||||||
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
|
|
||||||
const makeElement = (tag, text, className) => ({
|
|
||||||
tag, textContent: text || '', className, dataset: {}, children: [], attributes: {},
|
|
||||||
classList: { toggle() {} }, setAttribute(name, value) { this.attributes[name] = value; },
|
|
||||||
append(...children) { this.children.push(...children); }, addEventListener() {},
|
|
||||||
});
|
|
||||||
for (const card of cards.values()) card.querySelector = () => ({ textContent: card.dataset.dashboardWidget });
|
|
||||||
elements.set('dashboard-widget-list', widgetList);
|
|
||||||
context.el = makeElement;
|
|
||||||
context.CSS = { escape: value => value };
|
|
||||||
vm.runInContext(prefsSource.slice(prefsSource.indexOf('function dashboardWidgetLabel'),
|
|
||||||
prefsSource.indexOf('function refreshChartStylePalette')), context);
|
|
||||||
context.renderDashboardWidgetSettings();
|
|
||||||
assert.equal(widgetList.children.length, 62, 'all cards and full-width panels retain settings controls');
|
|
||||||
for (const id of ids) {
|
|
||||||
const row = widgetList.children.find(item => item.dataset.dashboardWidgetSetting === id);
|
|
||||||
const button = row.children[0];
|
|
||||||
const key = ({ stream_overview: 'streamWidgetOverviewDescription',
|
|
||||||
stream_traffic: 'streamWidgetTrafficDescription', stream_sessions: 'streamWidgetSessionsDescription',
|
|
||||||
stream_backend_health: 'streamWidgetHealthDescription' })[id];
|
|
||||||
assert.equal(row.title, key, `${id} must have a localized browser tooltip`);
|
|
||||||
assert.equal(button.attributes['aria-description'], key, 'the tooltip must remain accessible');
|
|
||||||
assert.equal(button.children.length, 2, 'Appearance shows only the visibility indicator and label');
|
|
||||||
assert.equal(row.children.length, 2, 'Appearance keeps badge metadata in the visual editor, not in each setting');
|
|
||||||
assert.equal(row.children[1].className, 'dashboard-widget-setting-actions', 'move actions remain on the right');
|
|
||||||
}
|
|
||||||
assert.equal(widgetList.children.filter(row => row.title).length, 50,
|
|
||||||
'Stream, backend, performance and full-panel entries have translated descriptions');
|
|
||||||
// Editor-specific regressions: avoid the redundant enable hint and label the
|
|
||||||
// two distinct inheritance layers instead of saying "Inherit" everywhere.
|
|
||||||
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
|
|
||||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
|
||||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
|
||||||
assert(editor.includes('enabledLabel?.querySelector(".vh-enable-hint")?.remove()'));
|
|
||||||
const vhostEditor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
|
||||||
assert.match(vhostEditor, /window\.rustProxyEnableControl\(enabledControl, "vhEnableHint"\)/);
|
|
||||||
assert.match(vhostEditor, /enabledLabel\?\.querySelector\("\.vh-enable-hint"\)\?\.remove\(\)/, 'vhost editor must not show the redundant active hint');
|
|
||||||
assert.match(vhostEditor, /enabledLabel\?\.classList\.add\("vh-enable-no-hint"\)/);
|
|
||||||
|
|
||||||
assert(editor.includes('["inherit", "streamMetricsDefaultOff"]'));
|
|
||||||
assert(editor.includes('["inherit", "streamMetricsFromStream"]'));
|
|
||||||
for (const locale of [pl, en]) {
|
|
||||||
for (const key of ['streamMetricsDefaultOff', 'streamMetricsFromStream', 'streamMetricsHelp', 'streamBackendDetailMetrics'])
|
|
||||||
assert(locale[key] && locale[key].length > 5, `missing ${key}`);
|
|
||||||
}
|
|
||||||
assert.equal(pl.streamMetricsFromStream, 'Z ustawień Stream');
|
|
||||||
assert.equal(pl.streamMetricsDefaultOff, 'Domyślnie wyłączone');
|
|
||||||
console.log('PASS: Stream visibility, inline Overview legends, scroll-safe native Appearance tooltips and Vhost enable label.');
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Simulated status and tab DOM. No Rust process or visual browser is run here.
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
|
||||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
|
||||||
const js = fs.readFileSync('src/admin/web/settings.js', 'utf8');
|
|
||||||
const core = fs.readFileSync('src/admin/web/app/core.js', 'utf8');
|
|
||||||
const api = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
|
|
||||||
const has = (haystack, needle) => assert.ok(haystack.includes(needle), `Missing ${needle}`);
|
|
||||||
|
|
||||||
// The two mini charts share the exact H1/H2/H3 palette as their SVG lines.
|
|
||||||
for (const version of ['h1', 'h2', 'h3']) {
|
|
||||||
assert.match(css, new RegExp(`\\.stat-chart\\s+\\.stat-chart-legend\\s+\\.legend-${version}\\s+i\\s*\\{\\s*background:\\s*var\\(--traffic-${version}\\);`));
|
|
||||||
assert.match(css, new RegExp(`\\.proto-${version}-line\\s*\\{[^}]*stroke:\\s*var\\(--traffic-${version}\\);`));
|
|
||||||
has(html, `id="http-protocol-${version}"`);
|
|
||||||
has(html, `id="ws-protocol-${version}"`);
|
|
||||||
}
|
|
||||||
assert.equal((html.match(/class="stat-chart-legend"/g) || []).length, 2);
|
|
||||||
|
|
||||||
const about = html.split('<dialog aria-labelledby="about-title"')[1].split('</dialog>')[0];
|
|
||||||
for (const forbidden of ['about-acme-mode', 'about-python-runtime', 'about-python-details', 'runtimeAcmeMode']) {
|
|
||||||
assert.ok(!about.includes(forbidden), `ACME details left in About: ${forbidden}`);
|
|
||||||
}
|
|
||||||
assert.ok(!core.includes('renderAboutRuntime('), 'Obsolete About runtime renderer must be removed');
|
|
||||||
for (const marker of ['aboutAuthor', 'aboutLicense', 'aboutRepository', 'aboutPackagesRepository']) has(about, marker);
|
|
||||||
|
|
||||||
has(html, 'data-settings-tab="system-info"');
|
|
||||||
has(html, 'id="settings-page-system-info"');
|
|
||||||
const tab = html.split('id="settings-page-system-info"')[1].split('id="settings-page-certificate-api"')[0];
|
|
||||||
for (const field of ['runtime-operating-system', 'runtime-kernel-version', 'runtime-architecture',
|
|
||||||
'runtime-logical-cpus', 'runtime-system-memory', 'runtime-process-memory',
|
|
||||||
'runtime-acme-mode', 'runtime-python', 'runtime-pip', 'runtime-certbot',
|
|
||||||
'runtime-acme-directory', 'runtime-data-directory', 'runtime-database-path']) has(tab, `id="${field}"`);
|
|
||||||
assert.ok(!tab.includes('<form'), 'The System information page must be read-only');
|
|
||||||
const admin = html.split('id="settings-page-administration"')[1].split('id="settings-page-system-info"')[0];
|
|
||||||
assert.ok(!admin.includes('runtime-information-card'), 'Runtime data must be relocated, not duplicated');
|
|
||||||
|
|
||||||
// Execute the real status-to-settings rendering code against a synthetic status payload.
|
|
||||||
const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {};'), js.indexOf(' const policy = envelope.config.tls?', js.indexOf(' const runtime = latestStatus?.runtime || {};')));
|
|
||||||
const elements = new Map();
|
|
||||||
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
|
||||||
const status = {
|
|
||||||
version: '0.32.1', uptime_seconds: 4201,
|
|
||||||
runtime: {
|
|
||||||
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
|
||||||
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
|
||||||
certificate_runtime: { status: 'ready', mode: 'managed', message: 'acme.runtime_ready',
|
|
||||||
python: true, pip: true, certbot: true, python_version: '3.13.5', pip_version: '25.2',
|
|
||||||
certbot_version: '5.8.0', managed_directory: '/var/lib/rustproxy/acme-python' }
|
|
||||||
},
|
|
||||||
system: { logical_cpus: 8, cpu_percent: 17.2, memory_total_bytes: 16_384, memory_used_bytes: 8_192,
|
|
||||||
process_rss_bytes: 2_048, system_uptime_seconds: 100, process_threads: 9, open_fds: 67 }
|
|
||||||
};
|
|
||||||
const context = {
|
|
||||||
latestStatus: status, $: dollar, locale: () => 'en-US',
|
|
||||||
t: key => ({ runtimeAvailable: 'Available', runtimeUnavailable: 'Unavailable',
|
|
||||||
certificateRuntimeManaged: 'App-managed Python', 'acme.runtime_ready': 'Ready', unavailable: 'N/A' })[key] || key,
|
|
||||||
bytes: value => `${value} B`, uptime: value => `${value}s`,
|
|
||||||
};
|
|
||||||
vm.runInNewContext(code, context);
|
|
||||||
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
|
||||||
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
|
||||||
assert.equal(dollar('runtime-version').textContent, '0.32.1');
|
|
||||||
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
|
||||||
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
|
||||||
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
|
||||||
assert.equal(dollar('runtime-pip').textContent, 'Available · 25.2');
|
|
||||||
assert.equal(dollar('runtime-certbot').textContent, 'Available · 5.8.0');
|
|
||||||
assert.equal(dollar('runtime-acme-directory').textContent, '/var/lib/rustproxy/acme-python');
|
|
||||||
|
|
||||||
// A working ACME runtime can have no trustworthy version metadata on some OSes.
|
|
||||||
elements.clear();
|
|
||||||
vm.runInNewContext(code, { ...context,
|
|
||||||
latestStatus: { ...status, runtime: { ...status.runtime, certificate_runtime: {
|
|
||||||
...status.runtime.certificate_runtime, python_version: null, pip_version: null, certbot_version: null
|
|
||||||
} } }
|
|
||||||
});
|
|
||||||
for (const field of ['runtime-python', 'runtime-pip', 'runtime-certbot']) {
|
|
||||||
assert.equal(dollar(field).textContent, 'Available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status fields remain safely unavailable rather than displaying fake zeros.
|
|
||||||
elements.clear();
|
|
||||||
vm.runInNewContext(code, { ...context,
|
|
||||||
latestStatus: { runtime: { certificate_runtime: { status: 'unavailable', mode: 'none',
|
|
||||||
message: 'acme.runtime_python_missing', python: false, pip: false, certbot: false } }, system: {} }
|
|
||||||
});
|
|
||||||
for (const field of ['runtime-cpu-usage', 'runtime-logical-cpus', 'runtime-system-memory', 'runtime-python']) {
|
|
||||||
assert.ok(!/NaN|0 B/.test(dollar(field).textContent));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exercise real tab switching, deep-linking and search code without a browser.
|
|
||||||
const entry = js.slice(js.indexOf(' const buttons ='), js.indexOf(' const cacheTabs ='));
|
|
||||||
const buttons = ['administration', 'system-info', 'certificate-api'].map(name => ({
|
|
||||||
dataset: {settingsTab:name}, textContent:name, hidden:false, tabIndex:-1,
|
|
||||||
listeners:{}, setAttribute(name,value){this[name]=value;}, addEventListener(name,fn){this.listeners[name]=fn;}, focus(){} }));
|
|
||||||
const pages = buttons.map(b => ({dataset:{settingsPage:b.dataset.settingsTab}, hidden:true,textContent:b.dataset.settingsTab}));
|
|
||||||
const search={value:'',listeners:{},addEventListener(name,fn){this.listeners[name]=fn;}};
|
|
||||||
const searchStatus={textContent:''};
|
|
||||||
const loc = new URL('https://localhost/settings?tab=system-info');
|
|
||||||
let lastUrl='';
|
|
||||||
vm.runInNewContext(`(() => { ${entry} return true; })()`, {
|
|
||||||
document: {querySelectorAll: sel => sel === '[data-settings-tab]' ? buttons : pages,
|
|
||||||
querySelector: sel => pages.find(p => sel.includes(p.dataset.settingsPage))},
|
|
||||||
$: id => id === 'settings-search' ? search : searchStatus,
|
|
||||||
location: loc, history: {state:null,replaceState:(_state,_unused,url) => {lastUrl=url;}},
|
|
||||||
CSS:{escape:x=>x}, URL, URLSearchParams, t:x=>x,
|
|
||||||
});
|
|
||||||
buttons[1].listeners.click();
|
|
||||||
assert.equal(pages[1].hidden,false);
|
|
||||||
assert.equal(pages[0].hidden,true);
|
|
||||||
assert.equal(buttons[1]['aria-selected'],'true');
|
|
||||||
assert.equal(lastUrl,''); // Already at canonical deep link; no unnecessary history rewrite.
|
|
||||||
buttons[0].listeners.click();
|
|
||||||
assert.equal(pages[1].hidden,true);
|
|
||||||
assert.equal(lastUrl,'/settings?tab=administration');
|
|
||||||
|
|
||||||
const runtimeSchema = api.components.schemas.RuntimeInformation;
|
|
||||||
for (const key of ['operating_system','kernel_version','architecture']) assert.ok(runtimeSchema.properties[key]);
|
|
||||||
for (const key of ['python_version','pip_version','certbot_version']) {
|
|
||||||
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
|
|
||||||
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
|
|
||||||
}
|
|
||||||
console.log('PASS: 0.31.9 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Offline source guard for subprocess-free system information; not a Rust runtime test."""
|
|
||||||
from pathlib import Path
|
|
||||||
import tomllib
|
|
||||||
from rust_source import rust_source
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
|
||||||
for dependency in ('sysinfo', 'metrics-process'):
|
|
||||||
assert dependency in manifest['dependencies'], dependency
|
|
||||||
sysinfo_features = manifest['dependencies']['sysinfo']['features']
|
|
||||||
assert {'network', 'system'} <= set(sysinfo_features), (
|
|
||||||
'sysinfo::System and sysinfo::Networks require both system and network features'
|
|
||||||
)
|
|
||||||
assert any('systemstat' in target.get('dependencies', {}) for target in manifest.get('target', {}).values())
|
|
||||||
|
|
||||||
status = rust_source(ROOT, 'src/admin/status.rs')
|
|
||||||
assert 'sysinfo::System::long_os_version()' in status
|
|
||||||
assert 'sysinfo::System::kernel_version()' in status
|
|
||||||
assert 'std::sync::OnceLock' in status
|
|
||||||
for forbidden in ('Command::', 'std::process::Command', '/etc/os-release', '/proc/sys/kernel/osrelease'):
|
|
||||||
assert forbidden not in status, f'Status OS identification cannot use {forbidden}'
|
|
||||||
|
|
||||||
metadata = (ROOT / 'src/acme/runtime_versions.rs').read_text()
|
|
||||||
for marker in ('pyvenv.cfg', '.dist-info', 'METADATA', 'MAX_VERSION_METADATA_BYTES', 'MAX_PACKAGES_PER_SITE', 'fn runtime_versions('):
|
|
||||||
assert marker in metadata, marker
|
|
||||||
for forbidden in ('Command::', '.spawn(', 'version_token(', '"--version"', "'-c'", '.await'):
|
|
||||||
assert forbidden not in metadata, f'Diagnostic versions must not execute: {forbidden}'
|
|
||||||
|
|
||||||
runtime = (ROOT / 'src/acme/runtime_env.rs').read_text()
|
|
||||||
assert 'include!("runtime_versions.rs")' in runtime
|
|
||||||
assert 'runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)))' in runtime
|
|
||||||
assert 'python_version: versions.python' in runtime and 'certbot_version: versions.certbot' in runtime
|
|
||||||
assert 'path.with_extension("exe")' in runtime, 'Windows executable discovery must include .exe'
|
|
||||||
|
|
||||||
regressions = (ROOT / 'src/acme/tests.rs').read_text()
|
|
||||||
for marker in ('managed_python_pip_and_certbot_versions_use_metadata_only',
|
|
||||||
'absent_or_unverified_versions_are_not_invented',
|
|
||||||
'conflicting_site_distributions_do_not_return_stale_versions'):
|
|
||||||
assert marker in regressions, marker
|
|
||||||
print('PASS: shared Rust libraries, portable OS detection, no diagnostic subprocesses, bounded metadata and source regressions.')
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Offline source contracts for RustProxy 0.32.7 upstream protocols and card registry.
|
|
||||||
|
|
||||||
These assertions supplement, rather than replace, Rust ALPN, H2C and websocket
|
|
||||||
runtime tests. No code execution against a RustProxy server is implied.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
root=Path(__file__).resolve().parents[2]
|
|
||||||
read=lambda name:(root/name).read_text(encoding='utf-8')
|
|
||||||
registry=json.loads(read('src/admin/web/dashboard-widgets.json'))
|
|
||||||
ids=[widget['id'] for widget in registry]
|
|
||||||
assert len(ids)==len(set(ids)) and len(ids)>=16
|
|
||||||
assert all(re.fullmatch(r'[a-z0-9_]+',id) for id in ids)
|
|
||||||
html=read('src/admin/web/index.html')
|
|
||||||
html_ids=re.findall(r'<article\b[^>]*\bdata-dashboard-widget="([a-z0-9_]+)"',html)
|
|
||||||
assert len(html_ids)==len(set(html_ids)), 'duplicate Overview widget element'
|
|
||||||
assert set(ids)==set(html_ids), f'Registry diverges from rendered Overview cards: {set(ids)^set(html_ids)}'
|
|
||||||
assert ids==html_ids, 'registry should follow the default displayed card order'
|
|
||||||
for language in ['en_US','pl_pl']:
|
|
||||||
translations=json.loads(read(f'langs/{language}.json'))['translations']
|
|
||||||
assert 'Auto' in translations['vhProtocolAuto']
|
|
||||||
assert 'H2' in translations['vhProtocolHttp2']
|
|
||||||
assert 'H2' in translations['vhProtocolH2Strict']
|
|
||||||
assert all(key in translations for key in (
|
|
||||||
'vhProtocolCompatibilityWarning', 'vhProtocolAutoHttpsDetails', 'vhProtocolH2HttpDetails',
|
|
||||||
'vhProtocolLegacyHttp2HttpsDetails', 'vhProtocolLegacyHttp2HttpDetails',
|
|
||||||
'backendWidgetModeCompat', 'backendWidgetModeStrict',
|
|
||||||
'backendWidgetRequests', 'backendWidgetCapacity'))
|
|
||||||
for widget in registry:
|
|
||||||
assert widget['label_key'] in translations, (language,widget['label_key'])
|
|
||||||
if widget['description_key'] is not None:
|
|
||||||
assert widget['description_key'] in translations
|
|
||||||
for widget in registry:
|
|
||||||
assert widget['element_selector']==f'[data-dashboard-widget="{widget["id"]}"]'
|
|
||||||
assert widget['data_source'] in ('status_metrics','stream_status','backend_status','system_status','cache_status')
|
|
||||||
assert widget['no_data']=='neutral_dash'
|
|
||||||
assert isinstance(widget['default_hidden'],bool)
|
|
||||||
assert widget['category'] in ('overview','streams','backends','performance','panels')
|
|
||||||
assert {v['id'] for v in registry if v['default_hidden']}=={
|
|
||||||
'stream_overview','stream_traffic','stream_sessions','stream_backend_health',
|
|
||||||
'backend_health','backend_activity','backend_errors','backend_latency',
|
|
||||||
'backend_traffic','backend_protocols','backend_requests','backend_capacity',
|
|
||||||
'backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations',
|
|
||||||
'extra_process_cpu','extra_process_rss','extra_process_fds','extra_open_connections',
|
|
||||||
'extra_websocket_active','extra_tls_error_rate','extra_request_error_rate',
|
|
||||||
'extra_cache_hit_rate','extra_cache_memory','extra_h2_share','extra_response_latency',
|
|
||||||
'extra_backend_unhealthy',
|
|
||||||
'extra_total_rps', 'extra_active_exchanges', 'extra_h1_share', 'extra_h3_share',
|
|
||||||
'extra_cache_miss_rate', 'extra_backend_active',
|
|
||||||
'wide_protocol_rates', 'wide_cpu', 'wide_memory', 'wide_cache_activity',
|
|
||||||
'wide_connections', 'wide_backend_health', 'wide_latency'
|
|
||||||
}
|
|
||||||
|
|
||||||
backend=read('src/config/model.rs')
|
|
||||||
client=read('src/upstream/client.rs')
|
|
||||||
# Existing saved `http2` is NOT strict on HTTPS: only new `h2` is.
|
|
||||||
assert re.search(r'enum UpstreamProtocol\s*\{\s*#\[default\]\s*Http1,',backend)
|
|
||||||
for name in ['Http2,', 'Auto,', 'H2,']:
|
|
||||||
assert name in backend
|
|
||||||
assert 'is_https && backend.protocol == UpstreamProtocol::Http2' in client
|
|
||||||
assert 'let protocol = if websocket {' in client and 'UpstreamProtocol::Http1' in client
|
|
||||||
assert 'if !is_https && protocol == UpstreamProtocol::Auto' in client
|
|
||||||
assert 'UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http)' in client
|
|
||||||
assert re.search(r'UpstreamProtocol::Http2 \| UpstreamProtocol::H2 => \{?\s*builder\.enable_http2\(\)\.wrap_connector\(http\)',client)
|
|
||||||
assert 'UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http)' in client
|
|
||||||
assert re.search(r'\.http2_only\(matches!\(\s*protocol,\s*UpstreamProtocol::Http2 \| UpstreamProtocol::H2\s*\)\s*\)',client)
|
|
||||||
assert 'backend.origin.starts_with("http://")' in read('src/proxy/types.rs')
|
|
||||||
assert 'UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2' in read('src/proxy/forward/upstream.rs')
|
|
||||||
assert '.retry_canceled_requests(false)' in client
|
|
||||||
assert '.pool_idle_timeout(' in client and '.pool_max_idle_per_host(' in client
|
|
||||||
assert 'generation_requests' in client and 'rotating' in client
|
|
||||||
assert 'include!("protocol_tests.rs");' in read('src/upstream/tests.rs')
|
|
||||||
protocol_tests=read('src/upstream/protocol_tests.rs')
|
|
||||||
for test in ['https_auto_chooses_h2_by_alpn_without_replaying_request',
|
|
||||||
'https_auto_negotiates_http1_before_sending_to_h1_only_peer',
|
|
||||||
'saved_legacy_https_http2_keeps_h1_fallback',
|
|
||||||
'saved_legacy_https_http2_still_prefers_h2',
|
|
||||||
'explicit_https_h2_requires_h2_and_refuses_h1_only_peer',
|
|
||||||
'explicit_https_h2_selects_h2_via_alpn',
|
|
||||||
'explicit_https_http1_does_not_offer_h2_to_h2_capable_peer',
|
|
||||||
'websocket_isolated_client_stays_on_http1_even_with_h2_backend',
|
|
||||||
'cleartext_auto_does_not_attempt_unsafe_h2c_probe',
|
|
||||||
'default_protocol_and_serialization_are_backward_compatible']:
|
|
||||||
assert f'fn {test}' in protocol_tests, test
|
|
||||||
assert 'fn explicit_h2c_backend_uses_http2_prior_knowledge' in read('src/upstream/tests.rs')
|
|
||||||
assert 'fn upstream_request_version(' in read('src/proxy/types.rs')
|
|
||||||
assert 'fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required' in read('src/proxy/tests/authority_tests.rs')
|
|
||||||
|
|
||||||
server=read('src/admin/preferences_columns.rs')
|
|
||||||
api=read('src/admin/preferences.rs')
|
|
||||||
router=read('src/admin/router.rs')
|
|
||||||
js=read('src/admin/web/app/preferences.js')
|
|
||||||
assert 'include_str!("web/dashboard-widgets.json")' in server
|
|
||||||
assert 'include_str!("web/dashboard-widgets.json")' in router
|
|
||||||
assert 'const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(' in router
|
|
||||||
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id)' in js
|
|
||||||
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden)' in js
|
|
||||||
assert 'ADMIN_DASHBOARD_BY_ID.get(id)?.description_key' in js
|
|
||||||
assert 'reconcile_dashboard_widgets(&mut preferences);' in api
|
|
||||||
assert 'prefs.dashboard_widgets.retain(' in api and 'prefs.dashboard_hidden_widgets.retain(' in api
|
|
||||||
assert 'fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids' in read('src/admin/preferences_tests.rs')
|
|
||||||
assert 'include!("preferences_tests.rs");' in read('src/admin/tests.rs')
|
|
||||||
|
|
||||||
assert 'item.insert("protocol".into(), json!(m.definition.protocol));' in read('src/balance/pool_view.rs')
|
|
||||||
assert 'include_str!("web/app/backend-dashboard.js")' in router
|
|
||||||
assert 'window.rustProxyBackendDashboard?.render(s)' in read('src/admin/web/app/core.js')
|
|
||||||
assert 'window.rustProxyBackendDashboard?.syncVisibility' in js
|
|
||||||
assert 'window.rustProxyBackendDashboard?.reset()' in read('src/admin/web/app/core.js')
|
|
||||||
assert 'vhProtocolCompatibilityWarning' in read('src/admin/web/vhosts/protocol.js')
|
|
||||||
assert '["h2", "vhProtocolH2Strict"]' in read('src/admin/web/vhosts/core.js')
|
|
||||||
assert 'backendWidgetRequestsTotal' in read('src/admin/web/app/backend-dashboard.js')
|
|
||||||
assert 'backendWidgetCapacityLimits' in read('src/admin/web/app/backend-dashboard.js')
|
|
||||||
assert 'include_str!("web/app/dashboard-layout-editor.js")' in router
|
|
||||||
assert 'include_str!("web/app/dashboard-inline-layout.js")' in router
|
|
||||||
assert all(widget.get('display_type') in {'value','value_chart','chart','panel','table'} for widget in registry)
|
|
||||||
assert 'dashboardWidgetTypeBadge' in js
|
|
||||||
assert 'overview-layout-unlock' in html and 'overview-layout-type-filter' in html
|
|
||||||
assert 'dashboard_widgets: [...draft.order]' in read('src/admin/web/app/dashboard-layout-editor.js')
|
|
||||||
assert 'dashboard_hidden_widgets: [...draft.hidden]' in read('src/admin/web/app/dashboard-layout-editor.js')
|
|
||||||
assert 'overview-layout-dialog' in html and 'overview-layout-open' in html
|
|
||||||
assert 'tls_errors_total' in read('src/admin/web/app/backend-dashboard.js')
|
|
||||||
assert len(registry) == 62
|
|
||||||
assert len([w for w in registry if w['category'] == 'panels']) == 14
|
|
||||||
assert 'include_str!("web/app/overview-extra-metrics.js")' in router
|
|
||||||
assert 'include_str!("web/app/overview-wide-metrics.js")' in router
|
|
||||||
assert 'include_str!("web/app/overview-masonry.js")' in router
|
|
||||||
assert 'window.rustProxyExtraOverview?.render(s)' in read('src/admin/web/app/core.js')
|
|
||||||
assert all(f'id="{id}"' in html for id in ('dashboard-overview-grid','overview-layout-default','overview-layout-reset','overview-reset-confirm'))
|
|
||||||
openapi = json.loads(read('src/admin/openapi.json'))
|
|
||||||
assert openapi['components']['schemas']['VhostBackend']['properties']['protocol']['enum'] == ['http1', 'http2', 'auto', 'h2']
|
|
||||||
prefs_schema = openapi['components']['schemas']['AdminPreferences']['properties']
|
|
||||||
for field in ('dashboard_widgets', 'dashboard_hidden_widgets'):
|
|
||||||
schema = prefs_schema[field]
|
|
||||||
assert schema['maxItems'] == len(registry), field
|
|
||||||
assert schema['items']['enum'] == ids, field
|
|
||||||
expected = ids if field == 'dashboard_widgets' else [widget['id'] for widget in registry if widget['default_hidden']]
|
|
||||||
assert schema['default'] == expected, field
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
assert '"version": "0.32.7"' in read('src/admin/openapi.json')
|
|
||||||
assert 'version = "0.32.7"' in read('Cargo.toml')
|
|
||||||
assert 'version = "0.32.7"' in read('Cargo.lock')
|
|
||||||
print(f'PASS: 0.32.7 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards')
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Keep executable release identity derived from the Cargo package version."""
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
||||||
from scripts.version import project_version
|
|
||||||
root = Path(__file__).resolve().parents[2]
|
|
||||||
version = project_version(root)
|
|
||||||
for name in ('src/main/bootstrap.rs','src/main/tasks.rs','src/web_server/fastcgi/protocol.rs','src/web_server/response/file.rs','dev.sh','scripts/deploy/upgrade.py','make_zip.py'):
|
|
||||||
text = (root/name).read_text(encoding='utf-8')
|
|
||||||
assert f'RustProxy {version}' not in text and f'RustProxy/{version}' not in text, f'hardcoded runtime version: {name}'
|
|
||||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/fastcgi/protocol.rs').read_text()
|
|
||||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/response/file.rs').read_text()
|
|
||||||
assert '__RUSTPROXY_ASSET_ID__' in (root/'src/admin/web/index.html').read_text()
|
|
||||||
assert 'OnceLock<PanelContent>' in (root/'src/admin/public.rs').read_text()
|
|
||||||
assert 'PanelContent::new' in (root/'src/admin/public.rs').read_text()
|
|
||||||
assert 'preferred_encoding' in (root/'src/admin/panel_content.rs').read_text()
|
|
||||||
assert 'include_str!("web/index.html")' in (root/'src/admin/public.rs').read_text()
|
|
||||||
assert 'super::asset_build_id()' in (root/'src/admin/public.rs').read_text()
|
|
||||||
assert 'admin_index.html' not in (root/'build.rs').read_text()
|
|
||||||
assert 'project_version' in (root/'make_zip.py').read_text()
|
|
||||||
print(f'PASS: executable version comes from Cargo.toml ({version})')
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// 0.29.7 origin error-page interception form roundtrip and input validation.
|
|
||||||
// Browser DOM is simulated; this is NOT a Rust/protocol integration test.
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import vm from 'node:vm';
|
|
||||||
|
|
||||||
const source = fs.readFileSync('src/admin/web/management.js', 'utf8');
|
|
||||||
const start = source.indexOf(' function vhostEditor(');
|
|
||||||
const end = source.indexOf(' let global = null;', start);
|
|
||||||
if (start === -1 || end <= start) throw new Error('Missing current vhost pages form');
|
|
||||||
const codes = [400,401,403,404,408,413,429,500,502,503,504];
|
|
||||||
const check = (valid, message) => { if (!valid) throw new Error(message); };
|
|
||||||
class Element {
|
|
||||||
constructor(tag='div', text='') {
|
|
||||||
this.tag = tag;
|
|
||||||
this.textContent = text;
|
|
||||||
this.value = '';
|
|
||||||
this.checked = false;
|
|
||||||
this.disabled = false;
|
|
||||||
this.children = [];
|
|
||||||
this.listeners = new Map();
|
|
||||||
}
|
|
||||||
append(...nodes) { this.children.push(...nodes); }
|
|
||||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
|
||||||
dispatch(name) { this.listeners.get(name)?.(); }
|
|
||||||
}
|
|
||||||
const el = (tag, text) => new Element(tag, text);
|
|
||||||
const label = (tag, key) => new Element(tag, key);
|
|
||||||
const pageEditor = (parent, label, initial) => { parent.append(el('div', label)); return {read:()=>initial||null}; };
|
|
||||||
const errorsEditor = (parent, initial) => { parent.append(el('div')); return {read:()=>initial||{}}; };
|
|
||||||
const context = {
|
|
||||||
pageEditor, errorsEditor, el, label, codes,
|
|
||||||
document: { createElement: tag => el(tag) },
|
|
||||||
t: key => key,
|
|
||||||
};
|
|
||||||
vm.createContext(context);
|
|
||||||
vm.runInContext(`${source.slice(start,end)}\nthis.makeEditor = vhostEditor;`,context);
|
|
||||||
const build = values => {
|
|
||||||
const parent = el('div');
|
|
||||||
const editor = context.makeEditor(parent, values);
|
|
||||||
const section = parent.children[2];
|
|
||||||
check(section.tag === 'section', 'Missing origin interception section');
|
|
||||||
const input = section.children[0].children[0];
|
|
||||||
const statuses = section.children[1].children[1];
|
|
||||||
return {editor, input, statuses, section};
|
|
||||||
};
|
|
||||||
const prior = build({});
|
|
||||||
check(!prior.input.checked && prior.statuses.disabled, 'Interception must default off');
|
|
||||||
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[]', 'Old config must remain unchanged');
|
|
||||||
prior.input.checked = true; prior.input.dispatch('change');
|
|
||||||
check(!prior.statuses.disabled, 'Status list must enable with checkbox');
|
|
||||||
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404]', 'Default status must be HTTP 404');
|
|
||||||
prior.statuses.value = '404, 500, 503';
|
|
||||||
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404,500,503]', 'Explicit statuses lost');
|
|
||||||
for (const invalid of ['302', '404,404', '404.0', '0x194', 'abc', '', '200', '600']) {
|
|
||||||
prior.statuses.value = invalid;
|
|
||||||
let rejected = false;
|
|
||||||
try { prior.editor.read(); } catch { rejected = true; }
|
|
||||||
check(rejected, `Invalid interception status should be rejected: ${invalid}`);
|
|
||||||
}
|
|
||||||
prior.input.checked = false; prior.input.dispatch('change');
|
|
||||||
check(prior.statuses.disabled && prior.editor.read().intercept_statuses.length===0,'Disabling interception must clear saved statuses');
|
|
||||||
const saved = build({intercept_statuses:[401,404],errors:{404:{kind:'html',status:null,html:'local'}}});
|
|
||||||
check(saved.input.checked && !saved.statuses.disabled && saved.statuses.value==='401, 404','Existing interception settings not restored');
|
|
||||||
check(saved.editor.read().errors['404'].html==='local', 'Custom error page lost');
|
|
||||||
const rust = fs.readFileSync('src/proxy/error_fallback.rs','utf8');
|
|
||||||
check(rust.includes('if route.internal {\n return response;'), 'Internal fallback response is not protected from interception');
|
|
||||||
const web = fs.readFileSync('src/proxy/forward/web_source.rs','utf8');
|
|
||||||
const proxy = fs.readFileSync('src/proxy/forward/response.rs','utf8');
|
|
||||||
check(web.includes('let mut response = intercept_route_origin_error(response, route, &snapshot);'), 'Web Server origin interception not in response path');
|
|
||||||
const prepared = fs.readFileSync('src/proxy/forward/success_prepare.rs','utf8');
|
|
||||||
check(proxy.includes('include!("success_prepare.rs")'), 'Reverse Proxy response preparation missing');
|
|
||||||
check(!proxy.includes('let response = intercept_route_origin_error(response, route, &snapshot);'), 'Do not convert Incoming body before HTTP/2 WebSocket setup');
|
|
||||||
check(prepared.includes('response = intercept_route_origin_error(response, route, &snapshot);'), 'Origin error interception missing after body adaptation');
|
|
||||||
const marker = fs.readFileSync('src/proxy/error_fallback.rs','utf8');
|
|
||||||
check(marker.includes('response.extensions_mut().insert(crate::pages::OriginProvidedResponse)'), 'Origin responses must be marked for the outer observer');
|
|
||||||
const observer = fs.readFileSync('src/proxy/upstream_helpers.rs','utf8');
|
|
||||||
check(/fn should_replace_error_page\(/.test(observer) && /get::<crate::pages::OriginProvidedResponse>\(\)\s*\.is_none\(\)/.test(observer) && /if should_replace_error_page\(&response, minimal_welcome\)/.test(observer), 'Outer response observer must not unconditionally replace origin 404/5xx');
|
|
||||||
const pages = fs.readFileSync('src/pages.rs','utf8');
|
|
||||||
check(pages.includes('body_only.status = None;'), 'Application error interception must preserve the upstream HTTP code even when page overrides status');
|
|
||||||
const interceptPosition = prepared.indexOf('response = intercept_route_origin_error(response, route, &snapshot);');
|
|
||||||
check(interceptPosition > prepared.indexOf('Response::from_parts(parts, Body::new(body))'), 'Origin interception must operate on Response<Body>');
|
|
||||||
check(interceptPosition < prepared.indexOf('response.extensions_mut().insert(ResponseRulesApplied)'), 'Intercepted origin response must receive final response metadata');
|
|
||||||
check(prepared.includes('websocket::wrap_h2_response_body(body, session, exchange)'), 'HTTP/2 WebSocket tunnel body must remain compatible with Incoming');
|
|
||||||
const reappliedRules = prepared.indexOf('apply_response_rules(', interceptPosition);
|
|
||||||
const errorNoStore = prepared.indexOf('HeaderValue::from_static("no-store")', interceptPosition);
|
|
||||||
check(reappliedRules > interceptPosition && errorNoStore > reappliedRules, 'Intercepted pages must reapply header policy and then force no-store');
|
|
||||||
const healthLoop = fs.readFileSync('src/balance/lease_runtime.rs','utf8');
|
|
||||||
check(healthLoop.includes('checks.spawn(probe(Arc::clone(member), health.clone()))'), 'Health probe must take an owned Arc<Member>');
|
|
||||||
const html = fs.readFileSync('src/admin/web/index.html','utf8');
|
|
||||||
check(html.includes('id="php-fpm-profiles-help"') && html.includes('id="php-help-dialog"'), 'Shared PHP-FPM profiles lack help modal');
|
|
||||||
for (const key of ['phpDocsScope','phpDocsValuesText','phpDocsAdminText','phpHintPathInfo']) {
|
|
||||||
check(html.includes(`data-i18n="${key}"`), `PHP help does not explain ${key}`);
|
|
||||||
}
|
|
||||||
console.log('PASS: application error interception form default/roundtrip/validation, internal fallback protection, PHP help access.');
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Render real Vhost cards with mock config/status to verify badge placement/color.
|
|
||||||
|
|
||||||
Headless Chromium fixture only: does not verify authenticated Rust runtime/ALPN.
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
WEB = ROOT / 'src/admin/web'
|
|
||||||
source = (WEB / 'vhosts/render.js').read_text()
|
|
||||||
start = source.index('const VHOST_PROTOCOL_FRESH_MS = ')
|
|
||||||
end = source.index('function backendTrafficRate(', start)
|
|
||||||
renderer = source[start:end]
|
|
||||||
css = (WEB / 'style.css').read_text()
|
|
||||||
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded">'
|
|
||||||
f'<head><meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
||||||
f'<style>{css}</style></head><body><section id="panel-vhosts">'
|
|
||||||
f'<div id="backend-diagnostics-card"></div><input id="vhost-search">'
|
|
||||||
f'<div id="vhost-list"></div><span id="vhost-editing"></span>'
|
|
||||||
f'</section></body></html>')
|
|
||||||
stubs = r'''
|
|
||||||
const $ = id => document.getElementById(id);
|
|
||||||
const t = key => key;
|
|
||||||
const locale = () => 'en-US';
|
|
||||||
const el = (tag, value, cls) => {
|
|
||||||
const node=document.createElement(tag);
|
|
||||||
if (value !== undefined) node.textContent=value;
|
|
||||||
if (cls) node.className=cls;
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
const text = (tag, key, cls) => el(tag, t(key), cls);
|
|
||||||
const action = (name, fn) => {
|
|
||||||
const btn=el('button', name, 'quiet');
|
|
||||||
btn.addEventListener('click', fn);
|
|
||||||
return btn;
|
|
||||||
};
|
|
||||||
const button = (name, fn, cls) => {
|
|
||||||
const btn=action(name, fn);
|
|
||||||
if (cls) btn.className=cls;
|
|
||||||
return btn;
|
|
||||||
};
|
|
||||||
const initialized=true;
|
|
||||||
let editing=null, latestStatus=null;
|
|
||||||
const vhostListPrefs={sort:'id',descending:false};
|
|
||||||
const envelope={config:{listeners:{endpoints:[]},vhosts:[
|
|
||||||
{id:'proxy-test',enabled:true,frontend:{hosts:['proxy.example'],listener_ids:[]},
|
|
||||||
source:{type:'reverse_proxy'},force_ssl:true,websockets:true,
|
|
||||||
backends:[{id:'origin1',protocol:'auto'}, {id:'origin2',protocol:'http1'}]},
|
|
||||||
{id:'static-test',enabled:true,frontend:{hosts:['static.example'],listener_ids:[]},
|
|
||||||
source:{type:'web_server',web_service_id:'public'},
|
|
||||||
backends:[{id:'unused',protocol:'auto'}]},
|
|
||||||
]}};
|
|
||||||
'''
|
|
||||||
with sync_playwright() as playwright:
|
|
||||||
browser = playwright.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
|
|
||||||
for width in (393, 1024):
|
|
||||||
page = browser.new_page(viewport={'width': width, 'height': 844})
|
|
||||||
page.set_content(html, wait_until='domcontentloaded')
|
|
||||||
page.add_script_tag(content=stubs + renderer + '\nrender();')
|
|
||||||
initial = page.evaluate('''() => {
|
|
||||||
const card=document.querySelector('[data-vhost-card="proxy-test"]');
|
|
||||||
const badge=card.querySelector('.vhost-backend-protocol-badge');
|
|
||||||
const group=card.querySelector('.vhost-policy-pills');
|
|
||||||
const staticCard=document.querySelector('[data-vhost-card="static-test"]');
|
|
||||||
return {badges:group.querySelectorAll('.vhost-backend-protocol-badge').length,
|
|
||||||
otherPills:group.querySelectorAll('.pill').length,
|
|
||||||
parent:badge.parentElement===group,
|
|
||||||
lastChild:card.lastElementChild===group,
|
|
||||||
separateRows:card.querySelectorAll('.vhost-backend-protocols').length,
|
|
||||||
staticBadges:staticCard.querySelectorAll('.vhost-backend-protocol-badge').length,
|
|
||||||
initialText:badge.textContent,
|
|
||||||
scroll:document.documentElement.scrollWidth,screen:innerWidth};
|
|
||||||
}''')
|
|
||||||
assert initial['badges'] == 2 and initial['otherPills'] == 2, initial
|
|
||||||
assert initial['parent'] and initial['lastChild'], initial
|
|
||||||
assert initial['separateRows'] == 0 and initial['staticBadges'] == 0, initial
|
|
||||||
assert initial['initialText'] == 'origin1 · AUTO → ?', initial
|
|
||||||
assert initial['scroll'] <= initial['screen'] + 2, initial
|
|
||||||
appearance = page.evaluate('''() => {
|
|
||||||
const badge=document.querySelector('[data-upstream-protocol-backend="origin1"]');
|
|
||||||
const expected=token=>{const n=document.createElement('span');n.style.color=`var(${token})`;
|
|
||||||
document.body.append(n);const c=getComputedStyle(n).color;n.remove();return c;};
|
|
||||||
const unknown=getComputedStyle(badge).color;
|
|
||||||
renderUpstreamProtocolBadges({backend_pools:[{id:'proxy-test',members:[
|
|
||||||
{id:'origin1',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:Date.now()-1000},
|
|
||||||
{id:'origin2',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:Date.now()-1000}
|
|
||||||
]}]});
|
|
||||||
const h2={text:badge.textContent,observed:badge.dataset.observed,
|
|
||||||
color:getComputedStyle(badge).color,expected:expected('--green')};
|
|
||||||
return {unknown, expectedUnknown:expected('--muted'), h2};
|
|
||||||
}''')
|
|
||||||
assert appearance['unknown'] == appearance['expectedUnknown'], (width, appearance)
|
|
||||||
assert appearance['h2']['color'] == appearance['h2']['expected'], (width, appearance)
|
|
||||||
assert appearance['h2']['observed'] == 'true' and appearance['h2']['text'] == 'origin1 · AUTO → H2', appearance
|
|
||||||
print(f'PASS: {width}px Vhost protocol badges share policy group and preserve green/neutral states')
|
|
||||||
page.close()
|
|
||||||
browser.close()
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#!/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,33 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "rate-sampling.js", "preferences.js", "dashboard-layout-editor.js", "dashboard-inline-layout.js", "stream-dashboard.js", "backend-dashboard.js", "config-history.js", "runtime.js", "settings.js")
|
|
||||||
PANEL_PARTS = (
|
|
||||||
"management.js", "drafts.js", "integrations.js", "vhosts.js",
|
|
||||||
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js",
|
|
||||||
"redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js",
|
|
||||||
"transform-panel.js", "acme.js", "settings.js", "certificate-api.js", "file-logs.js",
|
|
||||||
)
|
|
||||||
VHOST_PARTS = ("core.js", "protocol.js", "health.js", "rates.js", "editor.js", "render.js")
|
|
||||||
|
|
||||||
def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str:
|
|
||||||
return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts)
|
|
||||||
|
|
||||||
def read_vhosts_js(root: Path) -> str:
|
|
||||||
body = _read(root, "vhosts", VHOST_PARTS)
|
|
||||||
return "(() => {\n" + body + "\n})();"
|
|
||||||
|
|
||||||
def read_app_js(root: Path) -> str:
|
|
||||||
# Mirror the Rust include_str! assembly including the server-validated
|
|
||||||
# widget registry, so QA parses the same core used by the real panel.
|
|
||||||
parts = []
|
|
||||||
for part in APP_PARTS:
|
|
||||||
if part == "preferences.js":
|
|
||||||
registry = (root / "src/admin/web/dashboard-widgets.json").read_text(encoding="utf-8")
|
|
||||||
parts.append(f"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({registry});")
|
|
||||||
parts.append((root / "src/admin/web/app" / part).read_text(encoding="utf-8"))
|
|
||||||
primary = "\n".join(parts)
|
|
||||||
panel = "\n".join(
|
|
||||||
read_vhosts_js(root) if name == "vhosts.js" else (root / "src/admin/web" / name).read_text(encoding="utf-8")
|
|
||||||
for name in PANEL_PARTS
|
|
||||||
)
|
|
||||||
return primary + "\n" + panel
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Rust-independent structural checks; the Rust tests require Cargo."""
|
|
||||||
from pathlib import Path
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
source = (ROOT / 'src/proxy/forward/web_source.rs').read_text()
|
|
||||||
helper = (ROOT / 'src/proxy/web_cache_revalidation.rs').read_text()
|
|
||||||
tests = (ROOT / 'src/proxy/web_cache_revalidation/tests.rs').read_text()
|
|
||||||
assert 'mod web_cache_revalidation;' in (ROOT / 'src/proxy/mod.rs').read_text()
|
|
||||||
assert 'state.cache.revalidation_tiered(ticket).await' in source
|
|
||||||
assert '!cached.reusable_for_request(&request_headers)' in source
|
|
||||||
assert 'web_origin_request_headers(' in source
|
|
||||||
assert 'revalidation_sent && response.status() == StatusCode::NOT_MODIFIED' in source
|
|
||||||
assert 'finish_web_revalidation(' in source
|
|
||||||
assert 'state.cache.discard(ticket)' in source
|
|
||||||
assert 'cache.publish_revalidated(' in helper
|
|
||||||
assert 'cache.ticket_current(ticket)' in helper
|
|
||||||
assert 'cache.discard(ticket)' in helper
|
|
||||||
assert 'origin_safe' in helper
|
|
||||||
assert 'cached.apply_validators(&mut headers)' in helper
|
|
||||||
assert 'headers.remove("if-none-match")' in helper
|
|
||||||
assert 'headers.remove("if-modified-since")' in helper
|
|
||||||
for name in (
|
|
||||||
'browser_refresh_304_preserves_web_server_hit_counter',
|
|
||||||
'browser_conditional_refresh_returns_304_without_resetting_hits',
|
|
||||||
'mismatched_browser_validator_cannot_validate_different_cached_object',
|
|
||||||
'range_and_head_requests_use_validated_cached_body',
|
|
||||||
'changed_local_file_is_not_falsely_revalidated',
|
|
||||||
'304_new_private_policy_discards_cached_representation',
|
|
||||||
):
|
|
||||||
assert f'async fn {name}()' in tests
|
|
||||||
print('PASS: RustProxy 0.32.2 Web Server revalidation wiring and Rust regression contracts (static only)')
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Rust-independent 0.25 Web Server architecture/security contract checks."""
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
from rust_source import rust_source, rust_tree
|
|
||||||
from web_assets import read_app_js, read_vhosts_js
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
|
|
||||||
def need(text: str, *markers: str) -> None:
|
|
||||||
for marker in markers:
|
|
||||||
assert marker in text, marker
|
|
||||||
|
|
||||||
config = rust_source(ROOT, "src/config.rs")
|
|
||||||
vhost = rust_source(ROOT, "src/vhost.rs")
|
|
||||||
web_dir = ROOT / "src/web_server"
|
|
||||||
assert not (ROOT / "src/web_server.rs").exists(), "Web Server monolith must not coexist with src/web_server/mod.rs"
|
|
||||||
for module in ["mod.rs", "config.rs", "filesystem.rs", "security.rs", "response.rs", "request.rs", "routing.rs", "fastcgi.rs", "serve.rs", "htaccess.rs", "tests.rs"]:
|
|
||||||
assert (web_dir / module).is_file(), module
|
|
||||||
web = rust_tree(ROOT, "src/web_server")
|
|
||||||
proxy = rust_source(ROOT, "src/proxy/mod.rs")
|
|
||||||
admin = rust_source(ROOT, "src/admin/mod.rs")
|
|
||||||
ui = read_vhosts_js(ROOT)
|
|
||||||
web_ui = (ROOT / "src/admin/web/web-server.js").read_text() + "\n" + (ROOT / "src/admin/web/web-server-profiles.js").read_text()
|
|
||||||
app_ui = read_app_js(ROOT)
|
|
||||||
html = (ROOT / "src/admin/web/index.html").read_text()
|
|
||||||
settings_ui = (ROOT / "src/admin/web/settings.js").read_text()
|
|
||||||
logging = rust_source(ROOT, "src/logging.rs")
|
|
||||||
metrics = rust_source(ROOT, "src/metrics.rs")
|
|
||||||
acl = rust_source(ROOT, "src/acl.rs")
|
|
||||||
openapi = json.loads((ROOT / "src/admin/openapi.json").read_text())
|
|
||||||
|
|
||||||
need(config,
|
|
||||||
"pub web_services: Vec<crate::web_server::Service>",
|
|
||||||
"pub enum CompiledSource",
|
|
||||||
"CompiledSource::WebServer",
|
|
||||||
"unknown Web Service",
|
|
||||||
"web_server_main_source_needs_no_backend_pool",
|
|
||||||
"web_server_and_reverse_proxy_can_share_one_vhost",
|
|
||||||
"one_web_service_can_be_reused_by_multiple_vhosts",
|
|
||||||
"missing_source_fields_remain_reverse_proxy_for_old_configs")
|
|
||||||
need(vhost,
|
|
||||||
"pub enum Source",
|
|
||||||
"WebServer { web_service_id: String }",
|
|
||||||
"pub source: Source",
|
|
||||||
"vhost requires at least one backend member for Reverse Proxy sources",
|
|
||||||
"Web Server location cannot pin a reverse-proxy backend")
|
|
||||||
# PATH_INFO must be dispatched before `try_files` rewrites a valid
|
|
||||||
# Roundcube /static.php/skins/... request into the index.php controller.
|
|
||||||
serve = (web_dir / "serve.rs").read_text()
|
|
||||||
assert serve.index("if let Ok(components) = path_components(&rewritten_path)") < serve.index("match resolve_try_files(")
|
|
||||||
assert "hardening_denied_path(service, &script_path)" in serve
|
|
||||||
assert "htaccess::denied(service.htaccess.as_ref(), &script_path)" in serve
|
|
||||||
need(web,
|
|
||||||
"roundcube_static_php_path_info_runs_before_front_controller_try_files",
|
|
||||||
"without_php_path_info_try_files_keeps_legacy_front_controller_fallback")
|
|
||||||
|
|
||||||
need(web,
|
|
||||||
"pub struct Service",
|
|
||||||
"pub document_root: PathBuf",
|
|
||||||
"pub index: Vec<String>",
|
|
||||||
"pub directory_listing: bool",
|
|
||||||
"pub mime_types: BTreeMap<String, String>",
|
|
||||||
"pub default_mime_type: String",
|
|
||||||
"pub cache_control: Option<String>",
|
|
||||||
"pub cache_rules: Vec<CacheRule>",
|
|
||||||
"pub response_headers: BTreeMap<String, String>",
|
|
||||||
"pub try_files: Vec<String>",
|
|
||||||
"pub rewrites: Vec<RewriteRule>",
|
|
||||||
"pub precompressed: Precompressed",
|
|
||||||
"pub download_extensions: Vec<String>",
|
|
||||||
"pub symlink_policy: SymlinkPolicy",
|
|
||||||
"pub php: Php",
|
|
||||||
"pub htaccess: HtaccessConfig",
|
|
||||||
"pub hardening: Hardening",
|
|
||||||
"hardening_blocks_common_probe_paths_and_trace",
|
|
||||||
"hardening_strips_fingerprint_headers_after_source_rules",
|
|
||||||
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
|
|
||||||
'if name.as_str() == "proxy"',
|
|
||||||
'"content-length"',
|
|
||||||
'"proxy-authenticate"',
|
|
||||||
'"proxy-authorization"',
|
|
||||||
"mod htaccess",
|
|
||||||
"htaccess::apply_rewrites",
|
|
||||||
"htaccess::protected_component",
|
|
||||||
"htaccess_wordpress_style_front_controller_and_protected_files_work",
|
|
||||||
"htaccess_directory_index_mime_headers_expires_and_nested_scope_work",
|
|
||||||
"htaccess_strict_mode_rejects_unsupported_directives",
|
|
||||||
"htaccess_security_sensitive_unsupported_directives_fail_closed",
|
|
||||||
"php_source_extensions_are_not_served_without_php_fpm",
|
|
||||||
"htaccess_php_addhandler_requires_php_fpm",
|
|
||||||
"FastCgiEndpoint",
|
|
||||||
"FCGI_BEGIN_REQUEST",
|
|
||||||
"try_acquire_owned",
|
|
||||||
"CONTENT_DISPOSITION",
|
|
||||||
"CONTENT_ENCODING",
|
|
||||||
"libc::openat",
|
|
||||||
"libc::O_NOFOLLOW",
|
|
||||||
"ReaderStream::new(file.take(body_length))",
|
|
||||||
"IF_NONE_MATCH", "IF_MODIFIED_SINCE", "IF_RANGE", "RANGE",
|
|
||||||
"StatusCode::NOT_MODIFIED", "StatusCode::PARTIAL_CONTENT", "StatusCode::RANGE_NOT_SATISFIABLE",
|
|
||||||
"listing_escapes_html_and_encodes_urls",
|
|
||||||
"root_directory_listing_is_repeatable_across_refreshes",
|
|
||||||
"index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory",
|
|
||||||
"mime_overrides_default_mime_and_cache_control_are_applied_to_files",
|
|
||||||
'HeaderValue::from_static("no-store")',
|
|
||||||
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
|
|
||||||
"empty_index_can_go_directly_to_directory_listing",
|
|
||||||
"env!(\"CARGO_PKG_VERSION\")",
|
|
||||||
"traversal_and_symlink_escape_are_not_served",
|
|
||||||
"try_files_rewrites_cache_rules_headers_and_downloads_work",
|
|
||||||
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
|
|
||||||
"php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure",
|
|
||||||
"php_fpm_executes_configured_directory_index",
|
|
||||||
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
|
|
||||||
"inside_root_symlink_policy_allows_only_targets_beneath_root",
|
|
||||||
"secure filesystem confinement currently requires a Unix platform")
|
|
||||||
need(proxy,
|
|
||||||
"if let crate::config::CompiledSource::WebServer(service) = &route.source",
|
|
||||||
"let request_method = request.method().clone()",
|
|
||||||
"crate::web_server::serve(",
|
|
||||||
"let request_headers = request.headers().clone()",
|
|
||||||
"response.extensions_mut().insert(StaticSourceResponse)",
|
|
||||||
"execute_error_fallback(",
|
|
||||||
"crate::pages::replace_error",
|
|
||||||
"state.cache.wrap_with_fill(",
|
|
||||||
"crate::transform::apply(",
|
|
||||||
"apply_response_rules(",
|
|
||||||
"finish_headers(",
|
|
||||||
"crate::compression::encode(")
|
|
||||||
assert "crate::web_server::serve(service, &request" not in proxy
|
|
||||||
basic_identity_pos = proxy.index("basic_identity =")
|
|
||||||
identity_pos = proxy.index("let identity =")
|
|
||||||
web_source_pos = proxy.index("CompiledSource::WebServer(service)", basic_identity_pos)
|
|
||||||
assert basic_identity_pos < web_source_pos
|
|
||||||
assert identity_pos < web_source_pos
|
|
||||||
assert web_source_pos < proxy.index("let (backend, backend_lease, affinity_cookie)")
|
|
||||||
web_source = rust_source(ROOT, "src/proxy/forward/web_source.rs")
|
|
||||||
# The 0.32.2 local-304 path also applies response rules, before the normal
|
|
||||||
# 200-origin processing. Enforce order using the latter's distinct call site.
|
|
||||||
normal_origin_rules = "apply_response_rules(response.headers_mut(),"
|
|
||||||
assert web_source.index(normal_origin_rules) > web_source.index("let origin_headers =")
|
|
||||||
assert web_source.index("crate::transform::apply(") > web_source.index(normal_origin_rules)
|
|
||||||
need(acl, "ambiguous encoded path character", '"/%2e%2e/secret"', '"/a%2fb"', '"/a%5cb"', '"/bad%"')
|
|
||||||
need(admin, '"/web-server"', '"web-services"', '"vhost_metrics"')
|
|
||||||
need((ROOT / "src/admin/panel_assets.rs").read_text(), '"/assets/web-server.js"')
|
|
||||||
need(html, 'data-panel="web-server"', 'id="panel-web-server"', 'id="web-service-dialog"',
|
|
||||||
'id="web-service-tabs"', 'data-web-service-page="general"', 'data-web-service-page="routing"',
|
|
||||||
'data-web-service-page="mime"', 'data-web-service-page="cache"', 'data-web-service-page="delivery"',
|
|
||||||
'data-web-service-page="php"', 'data-web-service-page="security"', 'data-web-service-page="htaccess"',
|
|
||||||
'id="web-service-htaccess-enabled"', 'id="web-service-htaccess-help"', 'id="htaccess-help-dialog"', 'id="web-service-try-files"',
|
|
||||||
'id="web-service-cache-rules"', 'id="web-service-precompressed"', 'id="web-service-compression"', 'id="web-service-compression-zstd"', 'id="web-service-compression-br"', 'id="web-service-compression-gzip"', 'id="web-service-compression-deflate"', 'id="web-service-php-enabled"',
|
|
||||||
'id="web-service-php-pass-authorization"', 'id="web-service-php-profile"',
|
|
||||||
'id="web-service-php-profiles"', 'id="php-fpm-profiles-dialog"',
|
|
||||||
'id="web-service-php-body-limit"',
|
|
||||||
'id="web-service-php-header-limit"', 'id="web-service-php-stderr-limit"',
|
|
||||||
'id="web-service-php-custom-params"', 'id="web-service-php-values"', 'id="php-help-dialog"',
|
|
||||||
'id="web-service-symlink-policy"', 'id="web-service-preset"', 'id="web-service-preset-apply"',
|
|
||||||
'id="web-service-hardening-attacks"', 'id="web-service-hardening-headers"', 'data-logging-settings-tab="outputs"',
|
|
||||||
'data-logging-settings-tab="format"', 'data-logging-settings-tab="errors"')
|
|
||||||
need(settings_ui, "selectLoggingTab", 'data-logging-settings-page', 'selectLoggingTab("outputs")')
|
|
||||||
need(web_ui, 'saveSection("web-services"', "directory_listing", "web_service_id", "mime_types", "default_mime_type",
|
|
||||||
"cache_control", "cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "compression",
|
|
||||||
"download_extensions", "symlink_policy", "php", "htaccess", "hardening", "presetConfig", "phpFrontController", "staticCacheRules",
|
|
||||||
"pass_authorization", "max_request_body_bytes", "max_response_header_bytes", "stderr_log_bytes", "custom_params", "php_values",
|
|
||||||
"syncHtaccess", "htaccess-help-dialog", "php-help-dialog", "parseFastCgiParams", "parsePhpValues", "validatePhpCustomData", "activateTab", "initTabs", "presetChanged",
|
|
||||||
'button = el("button", t(key), "quiet")', 'button.dataset.tab = name', 'button.setAttribute("role", "tab")', "vhost-detail",
|
|
||||||
'el("span", t(key), "small-label")', "if (envelope) render();")
|
|
||||||
need(config, "pub php_fpm_profiles: Vec<crate::web_server::PhpFpmProfile>", "duplicate PHP-FPM profile id", "unknown PHP-FPM profile")
|
|
||||||
need(web, "pub struct PhpFpmProfile", "pub php_profile_id: Option<String>", "fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests")
|
|
||||||
need(proxy, "empty_for_cache(&mut request)", 'request.headers_mut().insert(HOST, original_host.clone())')
|
|
||||||
need(web_ui, "phpProfileLocal", 'saveSection("php-fpm-profiles"', "function profileEditor(")
|
|
||||||
need(logging, '"route"', '"cache_effective_enabled"', '"transform_effective_enabled"', 'value["cache_enabled"]', 'value["transform_enabled"]')
|
|
||||||
need(metrics, "pub struct VhostMetricsView", "ewma_duration_us", "pub fn view(&self) -> VhostMetricsView")
|
|
||||||
assert 'item.append(text("span"' not in web_ui
|
|
||||||
need(app_ui, 'function showStartupFailure(error)', '$("login").hidden = true', 'const info = await api("/api/auth/session")')
|
|
||||||
assert '$("login").hidden = false; document.documentElement.classList.remove("app-loading")' not in app_ui
|
|
||||||
need(ui, 'type: "web_server"', "web_service_id", "sourceReverseProxy", "vhBackendRequired", "backend-diagnostics-card", "status.vhost_metrics", "vhostTrafficRate")
|
|
||||||
assert "WebService" in openapi.get("components", {}).get("schemas", {})
|
|
||||||
assert "PhpFpmProfile" in openapi["components"]["schemas"]
|
|
||||||
assert "php_fpm_profiles" in openapi["components"]["schemas"]["Config"]["properties"]
|
|
||||||
web_service_schema = openapi["components"]["schemas"]["WebService"]
|
|
||||||
assert "php_profile_id" in web_service_schema["properties"]
|
|
||||||
assert web_service_schema["properties"]["index"].get("minItems", 0) == 0
|
|
||||||
assert web_service_schema["properties"]["index"]["maxItems"] == 16
|
|
||||||
assert web_service_schema["properties"]["mime_types"]["maxProperties"] == 64
|
|
||||||
assert web_service_schema["properties"]["default_mime_type"]["default"] == "application/octet-stream"
|
|
||||||
assert web_service_schema["properties"]["cache_control"]["nullable"] is True
|
|
||||||
assert web_service_schema["properties"]["compression"]["$ref"].endswith("/CompressionOverrides")
|
|
||||||
compression_override_schema = openapi["components"]["schemas"]["CompressionOverrides"]
|
|
||||||
for prop in ["enabled", "zstd", "br", "gzip", "deflate"]:
|
|
||||||
assert prop in compression_override_schema["properties"], prop
|
|
||||||
for prop in ["cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "compression", "download_extensions", "symlink_policy", "php", "htaccess", "hardening"]:
|
|
||||||
assert prop in web_service_schema["properties"], prop
|
|
||||||
assert web_service_schema["properties"]["symlink_policy"]["default"] == "deny"
|
|
||||||
assert web_service_schema["properties"]["php"]["properties"]["enabled"]["default"] is False
|
|
||||||
php_schema = web_service_schema["properties"]["php"]["properties"]
|
|
||||||
assert php_schema["pass_authorization"]["default"] is True
|
|
||||||
assert php_schema["max_request_body_bytes"]["default"] == 0
|
|
||||||
assert php_schema["max_response_header_bytes"]["default"] == 65536
|
|
||||||
assert php_schema["stderr_log_bytes"]["default"] == 4096
|
|
||||||
assert php_schema["custom_params"]["maxProperties"] == 64
|
|
||||||
assert php_schema["php_values"]["maxProperties"] == 64
|
|
||||||
assert web_service_schema["properties"]["htaccess"]["properties"]["enabled"]["default"] is False
|
|
||||||
assert web_service_schema["properties"]["htaccess"]["properties"]["filename"]["default"] == ".htaccess"
|
|
||||||
assert web_service_schema["properties"]["hardening"]["properties"]["block_common_attacks"]["default"] is False
|
|
||||||
assert web_service_schema["properties"]["hardening"]["properties"]["strip_unnecessary_headers"]["default"] is False
|
|
||||||
htaccess_source = rust_source(ROOT, "src/web_server/htaccess.rs")
|
|
||||||
need(htaccess_source, "MAX_HTACCESS_FILES", "MAX_HTACCESS_TOTAL_BYTES", "RewriteEngine", "RewriteCond", "RewriteRule", "DirectoryIndex", "AddType", "AddHandler", "ExpiresByType", "Require all granted/denied", "general Files/FilesMatch semantics are not supported", "security/handler directive", "apply_response", "apply_rewrites")
|
|
||||||
assert "Source" in openapi.get("components", {}).get("schemas", {})
|
|
||||||
assert "client_ips" in openapi["components"]["schemas"]["TrafficCounters"]["properties"]
|
|
||||||
assert (ROOT / "docs/WEB_SERVER.md").is_file()
|
|
||||||
assert (ROOT / "docs/PHP_FPM.md").is_file()
|
|
||||||
assert (ROOT / "docs/TODO.md").is_file()
|
|
||||||
docs_html = (ROOT / "src/admin/web/docs.html").read_text()
|
|
||||||
docs_js = (ROOT / "src/admin/web/docs.js").read_text()
|
|
||||||
need(docs_html, "docs-tab-reader", "docs-reader-content", "docs-tab-api")
|
|
||||||
need(docs_js, 'get("/docs/content.json")', "renderMarkdown", "selectDocument")
|
|
||||||
print("PASS: 0.25 Web Server separation, pipeline, filesystem, HTTP, UI/API and regression contracts are present.")
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import fs from "node:fs";
|
|
||||||
import vm from "node:vm";
|
|
||||||
|
|
||||||
class ClassList {
|
|
||||||
constructor() { this.values = new Set(); }
|
|
||||||
toggle(name, active) { active ? this.values.add(name) : this.values.delete(name); }
|
|
||||||
}
|
|
||||||
class Element {
|
|
||||||
constructor(id = "") {
|
|
||||||
this.id = id;
|
|
||||||
this.value = "";
|
|
||||||
this.checked = false;
|
|
||||||
this.disabled = false;
|
|
||||||
this.hidden = false;
|
|
||||||
this.open = false;
|
|
||||||
this.dataset = {};
|
|
||||||
this.children = [];
|
|
||||||
this.listeners = new Map();
|
|
||||||
this.classList = new ClassList();
|
|
||||||
this.attributes = new Map();
|
|
||||||
this.tabIndex = 0;
|
|
||||||
this.textContent = "";
|
|
||||||
this.type = "";
|
|
||||||
this.readOnly = false;
|
|
||||||
this.elements = [];
|
|
||||||
}
|
|
||||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
|
||||||
dispatch(name) { return this.listeners.get(name)?.({ target: this, preventDefault() {} }); }
|
|
||||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
|
||||||
append(...items) { this.children.push(...items); }
|
|
||||||
replaceChildren(...items) { this.children = [...items]; }
|
|
||||||
showModal() { this.open = true; }
|
|
||||||
close() { this.open = false; }
|
|
||||||
reset() {}
|
|
||||||
checkValidity() { return true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = [
|
|
||||||
"htaccess-help-close","htaccess-help-dialog","php-help-close","php-help-dialog","web-service-cache-control","web-service-cache-rules",
|
|
||||||
"web-service-close","web-service-default-mime","web-service-dialog","web-service-download-extensions",
|
|
||||||
"web-service-editing","web-service-form","web-service-hardening-attacks","web-service-hardening-headers",
|
|
||||||
"web-service-htaccess-enabled","web-service-htaccess-filename","web-service-htaccess-help","web-service-htaccess-recursive",
|
|
||||||
"web-service-htaccess-strict","web-service-id","web-service-index","web-service-list","web-service-listing",
|
|
||||||
"web-service-mime-types","web-service-name","web-service-new","web-service-php-concurrency",
|
|
||||||
"web-service-php-connect-timeout","web-service-php-enabled","web-service-php-endpoint","web-service-php-extensions",
|
|
||||||
"web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-path-info","web-service-php-body-limit","web-service-php-header-limit",
|
|
||||||
"web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-admin-values","web-service-php-help",
|
|
||||||
"web-service-compression","web-service-compression-options","web-service-compression-zstd","web-service-compression-br","web-service-compression-gzip","web-service-compression-deflate","web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip",
|
|
||||||
"web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset",
|
|
||||||
"web-service-response-headers","web-service-rewrites","web-service-root","web-service-search",
|
|
||||||
"web-service-symlink-policy","web-service-tabs","web-service-try-files",
|
|
||||||
"web-service-php-profile", "web-service-php-profiles", "web-service-php-profiles-open", "php-fpm-profiles-dialog",
|
|
||||||
"php-fpm-profiles-list", "php-fpm-profiles-close", "php-fpm-profile-add", "php-fpm-profiles-help"
|
|
||||||
];
|
|
||||||
const elements = new Map(ids.map(id => [id, new Element(id)]));
|
|
||||||
const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => {
|
|
||||||
const page = new Element(); page.dataset.webServicePage = name; return page;
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.get("web-service-php-endpoint").value = "unix:/run/php/php-fpm.sock";
|
|
||||||
elements.get("web-service-php-connect-timeout").value = "1000";
|
|
||||||
elements.get("web-service-php-request-timeout").value = "30000";
|
|
||||||
elements.get("web-service-php-concurrency").value = "64";
|
|
||||||
elements.get("web-service-php-pass-authorization").checked = true;
|
|
||||||
elements.get("web-service-php-path-info").checked = false;
|
|
||||||
elements.get("web-service-php-body-limit").value = "0";
|
|
||||||
elements.get("web-service-php-header-limit").value = "65536";
|
|
||||||
elements.get("web-service-php-stderr-limit").value = "4096";
|
|
||||||
elements.get("web-service-compression").value = "";
|
|
||||||
for (const name of ["zstd", "br", "gzip", "deflate"]) elements.get(`web-service-compression-${name}`).value = "";
|
|
||||||
|
|
||||||
const document = {
|
|
||||||
body: new Element("body"),
|
|
||||||
createElement: tag => { const node = new Element(); node.type = tag; return node; },
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
if (selector === "#web-service-tabs button") return elements.get("web-service-tabs").children;
|
|
||||||
if (selector === "[data-web-service-page]") return pages;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let savedSection = null;
|
|
||||||
const context = {
|
|
||||||
console,
|
|
||||||
structuredClone,
|
|
||||||
document,
|
|
||||||
window: {},
|
|
||||||
envelope: null,
|
|
||||||
busy: false,
|
|
||||||
$: id => {
|
|
||||||
const value = elements.get(id);
|
|
||||||
if (!value) throw new Error(`missing fake element ${id}`);
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
t: key => key,
|
|
||||||
el: (_tag, text = "", className = "") => {
|
|
||||||
const node = new Element();
|
|
||||||
node.textContent = text ?? "";
|
|
||||||
if (className) node.classList.values.add(className);
|
|
||||||
return node;
|
|
||||||
},
|
|
||||||
toast: () => {},
|
|
||||||
saveSection: async (name, value) => { savedSection = { name, value: structuredClone(value) }; return true; },
|
|
||||||
appConfirm: async () => true,
|
|
||||||
};
|
|
||||||
vm.createContext(context);
|
|
||||||
vm.runInContext(fs.readFileSync("src/admin/web/drafts.js", "utf8"), context, { filename: "drafts.js" });
|
|
||||||
vm.runInContext(fs.readFileSync("src/admin/web/web-server-profiles.js", "utf8"), context, { filename: "web-server-profiles.js" });
|
|
||||||
vm.runInContext(fs.readFileSync("src/admin/web/web-server.js", "utf8"), context, { filename: "web-server.js" });
|
|
||||||
|
|
||||||
// Explicitly disabling scoped dynamic compression must hide and disable codec
|
|
||||||
// inheritance controls without erasing their saved selections.
|
|
||||||
const compressionMode = elements.get("web-service-compression");
|
|
||||||
const compressionOptions = elements.get("web-service-compression-options");
|
|
||||||
const compressionZstd = elements.get("web-service-compression-zstd");
|
|
||||||
compressionZstd.value = "off";
|
|
||||||
compressionMode.value = "off";
|
|
||||||
compressionMode.dispatch("change");
|
|
||||||
if (!compressionOptions.hidden) throw new Error("Disabled Web Service compression did not hide codec overrides");
|
|
||||||
for (const name of ["zstd", "br", "gzip", "deflate"]) {
|
|
||||||
if (!elements.get(`web-service-compression-${name}`).disabled) throw new Error(`Disabled Web Service compression left ${name} override active`);
|
|
||||||
}
|
|
||||||
compressionMode.value = "on";
|
|
||||||
compressionMode.dispatch("change");
|
|
||||||
if (compressionOptions.hidden) throw new Error("Re-enabled Web Service compression did not restore codec overrides");
|
|
||||||
if (compressionZstd.value !== "off") throw new Error("Compression visibility toggle erased the saved codec selection");
|
|
||||||
|
|
||||||
const preset = elements.get("web-service-preset");
|
|
||||||
preset.value = "wordpress";
|
|
||||||
preset.dispatch("change");
|
|
||||||
|
|
||||||
const expectedIndex = "index.php\nindex.html\nindex.htm";
|
|
||||||
const expectedTry = "$uri\n$uri/\n/index.php?$query_string";
|
|
||||||
if (elements.get("web-service-index").value !== expectedIndex) throw new Error("WordPress preset did not populate index files");
|
|
||||||
if (elements.get("web-service-try-files").value !== expectedTry) throw new Error("WordPress preset did not populate try_files");
|
|
||||||
if (!elements.get("web-service-php-enabled").checked) throw new Error("WordPress preset did not enable PHP-FPM");
|
|
||||||
if (!elements.get("web-service-precompressed").checked) throw new Error("WordPress preset did not enable precompressed sidecars");
|
|
||||||
if (!elements.get("web-service-cache-rules").value.includes("ext:css = public, max-age=604800")) throw new Error("WordPress preset did not populate cache rules");
|
|
||||||
|
|
||||||
preset.value = "downloads";
|
|
||||||
preset.dispatch("change");
|
|
||||||
if (elements.get("web-service-index").value !== "") throw new Error("Downloads preset should clear index files");
|
|
||||||
if (!elements.get("web-service-listing").checked) throw new Error("Downloads preset should enable directory listing");
|
|
||||||
if (elements.get("web-service-php-enabled").checked) throw new Error("Downloads preset should disable PHP-FPM");
|
|
||||||
if (elements.get("web-service-cache-control").value !== "public, max-age=3600") throw new Error("Downloads preset cache policy missing");
|
|
||||||
|
|
||||||
// The save path must serialize the values materialized by preset selection, not
|
|
||||||
// the previous/custom values that were present when the dialog opened.
|
|
||||||
context.envelope = { config: { web_services: [] } };
|
|
||||||
elements.get("web-service-id").value = "web1";
|
|
||||||
elements.get("web-service-name").value = "web1";
|
|
||||||
elements.get("web-service-root").value = "/srv/web1";
|
|
||||||
elements.get("web-service-php-pass-authorization").checked = false;
|
|
||||||
elements.get("web-service-php-path-info").checked = true;
|
|
||||||
elements.get("web-service-php-body-limit").value = "1048576";
|
|
||||||
elements.get("web-service-php-header-limit").value = "131072";
|
|
||||||
elements.get("web-service-php-stderr-limit").value = "8192";
|
|
||||||
elements.get("web-service-php-custom-params").value = "APP_ENV = production";
|
|
||||||
elements.get("web-service-php-values").value = "memory_limit = 256M";
|
|
||||||
preset.value = "wordpress";
|
|
||||||
preset.dispatch("change");
|
|
||||||
elements.get("web-service-compression").value = "on";
|
|
||||||
elements.get("web-service-compression-gzip").value = "off";
|
|
||||||
await elements.get("web-service-form").dispatch("submit");
|
|
||||||
if (savedSection?.name !== "web-services" || savedSection.value.length !== 1) throw new Error("WordPress preset save did not persist a Web Service");
|
|
||||||
const saved = savedSection.value[0];
|
|
||||||
if (saved.index.join("\n") !== expectedIndex) throw new Error("Saved WordPress preset lost index files");
|
|
||||||
if (saved.try_files.join("\n") !== expectedTry) throw new Error("Saved WordPress preset lost try_files");
|
|
||||||
if (!saved.php.enabled) throw new Error("Saved WordPress preset lost PHP-FPM enablement");
|
|
||||||
if (!saved.precompressed.enabled) throw new Error("Saved WordPress preset lost precompressed enablement");
|
|
||||||
if (saved.compression?.enabled !== true || saved.compression?.gzip !== false) throw new Error("Saved WordPress preset lost compression/codec override");
|
|
||||||
if (saved.php.pass_authorization !== false) throw new Error("Saved WordPress preset lost Authorization forwarding choice");
|
|
||||||
if (saved.php.path_info !== true) throw new Error("Saved WordPress preset lost PATH_INFO choice");
|
|
||||||
if (saved.php.max_request_body_bytes !== 1048576 || saved.php.max_response_header_bytes !== 131072 || saved.php.stderr_log_bytes !== 8192) throw new Error("Saved WordPress preset lost advanced FastCGI limits");
|
|
||||||
if (saved.php.custom_params.APP_ENV !== "production") throw new Error("Saved WordPress preset lost custom FastCGI parameters");
|
|
||||||
if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPress preset lost PHP_VALUE settings");
|
|
||||||
if (!saved.cache_rules.some(rule => rule.matcher === "ext:css" && rule.cache_control.includes("604800"))) throw new Error("Saved WordPress preset lost cache rules");
|
|
||||||
|
|
||||||
console.log("PASS: Web Service preset selection materializes and persists CMS/download settings.");
|
|
||||||
|
|
||||||
// The shared editor lists profiles, saves them under the new revisioned
|
|
||||||
// section, and Web Services can attach without losing local PHP settings.
|
|
||||||
context.envelope.config.php_fpm_profiles = [{
|
|
||||||
id: "wp-pool", name: "WordPress pool", php: {
|
|
||||||
enabled: true, path_info: true, pass_authorization: true,
|
|
||||||
extensions: ["php"], endpoint: "tcp:127.0.0.1:9000",
|
|
||||||
connect_timeout_ms: 1000, request_timeout_ms: 30000,
|
|
||||||
max_concurrent_requests: 64, max_request_body_bytes: 0,
|
|
||||||
max_response_header_bytes: 65536, stderr_log_bytes: 4096,
|
|
||||||
custom_params: {}, php_values: {}, php_admin_values: {}
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
// Both entry points must share the existing modal; the toolbar shortcut
|
|
||||||
// belongs next to New Web Service rather than inside the service editor.
|
|
||||||
const html = fs.readFileSync("src/admin/web/index.html", "utf8");
|
|
||||||
const profileDialog = html.split('id="php-fpm-profiles-dialog"')[1]?.split('</dialog>')[0] ?? '';
|
|
||||||
const listAt = profileDialog.indexOf('id="php-fpm-profiles-list"');
|
|
||||||
const addAt = profileDialog.indexOf('id="php-fpm-profile-add"');
|
|
||||||
const dividerAt = profileDialog.indexOf('class="php-profile-help-divider"');
|
|
||||||
const helpAt = profileDialog.indexOf('id="php-fpm-profiles-help"');
|
|
||||||
if (!(listAt >= 0 && listAt < addAt && addAt < dividerAt && dividerAt < helpAt)) {
|
|
||||||
throw new Error("PHP-FPM syntax/safety help must be at the bottom of the profile modal, separated by a rule");
|
|
||||||
}
|
|
||||||
const webPanel = html.split('id="panel-web-server"')[1]?.split("</section>")[0] ?? "";
|
|
||||||
const toolbar = webPanel.match(/<div class="vhost-list-tools">([\s\S]*?)<\/div>/)?.[1] ?? "";
|
|
||||||
const shortcutAt = toolbar.indexOf('id="web-service-php-profiles-open"');
|
|
||||||
const newAt = toolbar.indexOf('id="web-service-new"');
|
|
||||||
if (shortcutAt < 0 || newAt < 0 || shortcutAt > newAt) {
|
|
||||||
throw new Error("PHP-FPM profiles shortcut must be adjacent to New Web Service in the toolbar");
|
|
||||||
}
|
|
||||||
elements.get("web-service-php-profiles-open").dispatch("click");
|
|
||||||
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("Toolbar PHP-FPM profile shortcut did not open the modal");
|
|
||||||
elements.get("php-fpm-profiles-close").dispatch("click");
|
|
||||||
if (elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal failed to close");
|
|
||||||
elements.get("web-service-php-profiles").dispatch("click");
|
|
||||||
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal was not opened");
|
|
||||||
const profileDetail = elements.get("php-fpm-profiles-list").children[0];
|
|
||||||
if (!profileDetail || profileDetail.children[0].textContent !== "WordPress pool") throw new Error("Shared profile list missing");
|
|
||||||
const profileForm = profileDetail.children[1];
|
|
||||||
const phpRows = profileForm.children.filter(node => node.classList.values.has("php-switch-row vh-wide"));
|
|
||||||
if (phpRows.length !== 3) throw new Error("PHP-FPM / PATH_INFO / Authorization switches do not have separate aligned rows");
|
|
||||||
for (const row of phpRows) {
|
|
||||||
if (row.children.length !== 2 || row.children[0].children[0].type !== "checkbox" ||
|
|
||||||
row.children[1].children[0].textContent !== "i" || row.children[1].children[1].children.length < 1) {
|
|
||||||
throw new Error("Profile PHP toggle lacks its checkbox label or explanation popover");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!phpRows[0].children[0].children[0].checked || !phpRows[1].children[0].children[0].checked) {
|
|
||||||
throw new Error("PHP execution and PATH_INFO saved values were not restored");
|
|
||||||
}
|
|
||||||
phpRows[1].children[0].children[0].checked = false;
|
|
||||||
if (!phpRows[0].children[0].children[0].checked) {
|
|
||||||
throw new Error("Changing PATH_INFO also disabled PHP execution");
|
|
||||||
}
|
|
||||||
phpRows[1].children[0].children[0].checked = true;
|
|
||||||
elements.get("php-fpm-profiles-help").dispatch("click");
|
|
||||||
if (!elements.get("php-help-dialog").open) throw new Error("PHP profile help button did not open syntax documentation");
|
|
||||||
elements.get("php-help-close").dispatch("click");
|
|
||||||
if (elements.get("php-help-dialog").open) throw new Error("PHP syntax help dialog did not close");
|
|
||||||
profileForm.children[1].children[1].value = "Updated WordPress pool";
|
|
||||||
await profileForm.dispatch("submit");
|
|
||||||
if (savedSection?.name !== "php-fpm-profiles" || savedSection.value[0].name !== "Updated WordPress pool") {
|
|
||||||
throw new Error("Shared profile edit did not save to the profile configuration section");
|
|
||||||
}
|
|
||||||
const profileSelector = elements.get("web-service-php-profile");
|
|
||||||
const localEndpoint = elements.get("web-service-php-endpoint").value;
|
|
||||||
profileSelector.value = "wp-pool";
|
|
||||||
profileSelector.dispatch("change");
|
|
||||||
if (!elements.get("web-service-php-endpoint").disabled) throw new Error("Shared profile left local PHP controls editable");
|
|
||||||
elements.get("web-service-id").value = "wp-shared";
|
|
||||||
elements.get("web-service-name").value = "WordPress shared";
|
|
||||||
await elements.get("web-service-form").dispatch("submit");
|
|
||||||
if (savedSection?.name !== "web-services" || savedSection.value[0].php_profile_id !== "wp-pool") {
|
|
||||||
throw new Error("Web Service did not persist selected shared PHP-FPM profile");
|
|
||||||
}
|
|
||||||
if (savedSection.value[0].php.endpoint !== localEndpoint) {
|
|
||||||
throw new Error("Attaching a shared FPM profile changed local settings");
|
|
||||||
}
|
|
||||||
console.log("PASS: Toolbar and editor PHP-FPM shortcuts open the shared modal; profile edits and Web Service attachment work.");
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/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.search(/^\s*const syncCanonical = \(\) => \{/m);
|
|
||||||
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.");
|
|
||||||
Reference in new issue
Block a user