diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml new file mode 100644 index 0000000..9bdb394 --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,95 @@ +# The release validation, on Linux, exactly as the release checklist names it. +# +# WHY THIS EXISTS. `npm test` is the evidence behind "Supported": it renders the +# shipped shells on the REAL template engines -- PasarGuard's Jinja2 and +# Rebecca's pongo2 -- rather than on a stand-in. That makes the suite depend on +# three tools the Node runtime alone does not provide: +# +# Go tools/fixtures (the fixture renderer) and +# tools/engines/pongo2 (Rebecca's engine, v6.1.0, the version +# Rebecca pins) are both built from source +# Python 3 + tools/engines/jinja2_pasarguard.py renders the PasarGuard +# Jinja2 shells with the real Jinja2 +# ShellCheck npm run lint:sh gates the installer and the release tooling +# +# A developer's machine may have all three; a fresh checkout may not. The point +# of running here is that "the suite passes" stops depending on whose machine it +# ran on, and that the four commands below are the same four the release +# checklist names, in the same order, each reporting its own result. +# +# NOT the Docs workflow. That one builds and publishes docs/ and never runs the +# suite; a green "build" check on a pull request says nothing about this. + +name: Release validation + +on: + pull_request: + paths: + - "src/**" + - "tools/**" + - "tests/**" + - "installer/**" + - "template/**" + - "package.json" + - "VERSION" + # Not in the release tarball, but it is the file a release-facing pull + # request usually ends on. Leaving it out means the head commit of such a + # pull request carries no check result at all, which reads as "unverified" + # rather than "unaffected". + - "CHANGELOG.md" + - ".github/workflows/release-validation.yml" + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + # No `cache: npm`: this project has no dependencies and no lockfile, so + # the cache step would have nothing to hash and would fail. + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Install the test toolchain (Python 3 + Jinja2, ShellCheck) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends python3-jinja2 shellcheck + + - name: Show the toolchain + run: | + node --version + go version + python3 -c 'import jinja2; print("jinja2", jinja2.__version__)' + shellcheck --version + + # `npm test` runs `pretest` (the fixture renderer) first, which needs Go. + - name: npm test + run: npm test + + # Every command runs even when an earlier one failed: the point of this + # job is the four results, not the first one. The job still fails if any + # step did, because a failed step fails the job whatever the later ones + # report. Without this, a red `npm test` would hide the other three. + - name: npm run verify + if: ${{ !cancelled() }} + run: npm run verify + + - name: npm run lint:sh + if: ${{ !cancelled() }} + run: npm run lint:sh + + - name: npm run build:panels + if: ${{ !cancelled() }} + run: npm run build:panels diff --git a/CHANGELOG.md b/CHANGELOG.md index 557bf31..21ea36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,84 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Fixes found while hardening 1.3.0 against the panels' own source. Nothing changes +for an existing 3X-UI install, and no adapter or panel-behaviour change is +involved. + +### Fixed + +- **PasarGuard: the page no longer titles itself "Subscription".** PasarGuard's + page context carries the subscriber (`user.username`) and the admin's own + columns, but **not** the panel-wide subscription settings, so the template had + nothing to read and every page fell back to the generic word. The page is now + named by the admin's configured profile title when there is one, and by the + subscriber otherwise — the order PasarGuard itself resolves a title in. A + profile title that still carries a PasarGuard format placeholder + (`{DATA_LIMIT}`, `{EXPIRE_DATE}`, `{USERNAME}`, …) is refused rather than + printed literally, because the page cannot resolve those variables and a + literal placeholder is worse than the subscriber's own name. The subscriber's + address (`user.ip`) is still never read. +- **Rebecca: the page no longer titles itself "Subscription" either.** Rebecca's + page context carries **no profile title at all**: the panel-wide + `subscription_profile_title` is written into the `/info` `profile-title` + **header** (`subscriptionHeaders`) and the page render path + (`renderSubscriptionHTML` → `renderSubscriptionPageTemplate`) never passes it, + so the title the adapter reads from that header is invisible to the page. The + one identity the page does receive is `user.username`, so that now names the + page — the same fallback PasarGuard takes when no profile title is + configured. A value with no word in it at all (absent, empty, + whitespace-only) is not a name and the page keeps its generic fallback; the + value is never rewritten. No `{` guard is needed here, unlike PasarGuard: + Rebecca never runs a title through `str.format_map`. The subscriber's token + and address are still never read, and nothing changes for an existing 3X-UI + or PasarGuard install. +- **Country flags: a node name that carries an ISO alpha-2 code now gets that + country's flag.** `TR | Istanbul`, `RU-01`, `GB-LON-1`, `FI-01`, `DE` and the + rest previously found no regional-indicator pair and fell to the monogram + (`T-I`, `R-U`, `G-B`, `F-I`) — which on any platform reads as the code, which + is why the badge appeared to show `TR`/`RU`/`GB`/`FI` instead of a flag. A + two-letter token is read as a country only when it is uppercase, stands alone + between non-letters, and names a real country in the **same 258-code registry + the emoji path already uses**; `LON` out of `GB-LON-1`, `USA`, `A1B`, `ZZ-01` + and lowercase English words (`no`, `it`, `us`, `in`) are all still refused. An + explicit flag emoji still wins over a code in the same name, and the displayed + name is never rewritten. Country *names* and *city names* — `Turkey - + Istanbul`, `Türkiye`, `Turkiye`, `Finland Helsinki` — still draw the monogram: + a name table does not fit the frozen artifact budget, and guessing from an + ambiguous name is worse than a monogram. All 15 artifacts were re-baselined + (+179 B each; `pulsenova` is 204,705 B with 95 B of headroom). + +### Documentation + +- `docs/design/PGCLOCK-AUDIT.md` — a read-only audit of the third-party PGClock + template as a secondary reference. It confirms `user.username` as the exposed + subscriber identity, has no service-name, support-URL or flag logic, and is + unlicensed, so nothing was derived from it and `PROVENANCE.md` is unchanged. +- `docs/design/PASARGUARD-ADAPTER-AUDIT.md` §8 — the page context is not the + `/info` payload: the headers carry the values the panel *resolved*, the page + context carries the admin's own columns and nothing else. +- `docs/design/REBECCA-ADAPTER-AUDIT.md` §2 — the same distinction for Rebecca, + stated against `subscriptionHeaders` and `subscriptionTemplateContext`: the + page context carries no profile title at all, so the page's title and the + adapter's header-derived `title` field can legitimately differ. +- `docs/design/FLAG-RENDERER-AUDIT.md` §17 — the alpha-2 path is an **input** + extension; the return shape, `CODES` and every fallback are unchanged. + +### Development + +- **The release validation now runs on Linux, in CI.** The repository's only + workflow was `Docs`, which builds the documentation site and never ran the + suite — so a green check on a pull request said nothing about whether the page + still rendered. `.github/workflows/release-validation.yml` runs the four + release commands (`npm test`, `npm run verify`, `npm run lint:sh`, + `npm run build:panels`) on `ubuntu-latest`, installing the three tools the + suite needs and a Node runtime does not provide: **Go** (the fixture renderer + and the pongo2 harness), **Python 3 with Jinja2** (the PasarGuard harness) and + **ShellCheck**. Each command reports its own result even when an earlier one + fails, because the point of the job is the four results. + ## [1.3.0] - 2026-09-26 Row-Template now installs on **PasarGuard** and **Rebecca** as well as 3X-UI, diff --git a/docs/design/FLAG-RENDERER-AUDIT.md b/docs/design/FLAG-RENDERER-AUDIT.md index 9f16d5c..7fab32a 100644 --- a/docs/design/FLAG-RENDERER-AUDIT.md +++ b/docs/design/FLAG-RENDERER-AUDIT.md @@ -976,3 +976,123 @@ See the **Phase 1 status** block at the head of this document for the shipped fi for the one gate that is *not* satisfied on exit: the §10 ≥ 1 KB headroom floor. FLAG RENDERER IMPLEMENTED — COMMITTED AT `4a95cbc` — PREVIEWS REFRESHED AT `a012108` + +--- + +## 17. Input-side alpha-2 support — 1.3.0 hardening (added 2026-09-27) + +**Status: IMPLEMENTED.** `flagOf` gained an **input** form. Its **return shape, +its registry and its fallbacks are unchanged.** + +### 17.1 The defect + +Operators name nodes with the country as an **ISO 3166-1 alpha-2 code**, not as a +flag emoji: `TR | Istanbul`, `RU-01`, `GB-LON-1`, `FI-01`, `DE`. `flagOf` only +ever looked for a **regional-indicator pair** (`U+1F1E6`–`U+1F1FF`), so all of +these returned `''` and the row fell to the **monogram** — `T-I`, `R-U`, `G-B`, +`F-I`. On Windows, where the emoji path itself already degrades to letters (§2), +the two states look alike, which is why the defect read as "the badge shows +`TR`/`RU`/`GB`/`FI` instead of a flag". + +### 17.2 Why this does not contradict Correction B + +Correction B in the Phase 0.6 table above reads *"`flagOf()` keeps returning the +**regional-indicator pair** — no alpha-2 change"*, and §5 rule 2 expands it: +*"`flagOf` keeps its public shape. It returns the **regional-indicator pair**, +not an alpha-2 code. `cfg.flag` stays `'🇩🇪'`."* + +That rule is about the **output**: the function must not start returning `'DE'` +instead of `'🇩🇪'`, because `cfg.flag` is the key into `FLAGS` and the text of the +badge. It is preserved exactly: + +| Contract | Before | After | +|---|---|---| +| return value | a regional-indicator pair, or `''` | **unchanged** | +| `cfg.flag` | `'🇩🇪'` | **unchanged** | +| `CODES` | the validity source, 258 assigned codes | **unchanged** | +| the monogram fallback | `flagOf(name) === ''` | **unchanged** | +| `cleanName` | strips indicators, tidies separators | **unchanged** | +| existing tests | 13 in `tests/flag.test.mjs` | **all pass verbatim** | + +What changed is the **input** vocabulary: a bare uppercase two-letter token is now +read as a code *in addition to* the emoji scan. §8's `flagOf()` **unchanged** row +belongs to the gradient workstream, which never had cause to touch the input side; +it is not a freeze on the function's input contract. + +### 17.3 The rule, and why it is narrow + +A two-letter run is read as a code only when it is **uppercase** and **stands +alone between non-letters**: + +```js +const CODE = /(?:^|[^A-Z])([A-Z]{2})(?![A-Z])/; +``` + +- **Uppercase only.** A case-insensitive match would make `no`, `it`, `us`, `in`, + `at`, `be`, `so` and `do` — all assigned codes — match inside ordinary English + node names (`no flag here`, `it support`, `us east`). Every one of those is + pinned as a refusal in the tests. +- **Bounded on both sides.** `LON` out of `GB-LON-1` is not a code, `USA` is not + a code, and `A1B` is not a code. `_` counts as a separator, so `TR_01` works. +- **Membership is `CODES`.** An unassigned pair (`ZZ`, `QQ`, `XX`) is refused + exactly as it is on the emoji path — the same 258-code registry, so the two + paths can never disagree about what a real country is. `UK` is accepted because + the registry already carries it as exceptionally reserved; `UK London` therefore + resolves to the UK flag emoji while `GB-LON-1` resolves to the GB one. Both are + real, and that is the registry's own doctrine, not a new decision. +- **The emoji wins.** An explicit flag emoji is the more specific claim, so it is + still preferred when both appear in one name. +- **The label is never rewritten.** An emoji means a flag and nothing else, but + two uppercase letters can be an operator's own wording, so the code is read and + **not** stripped from the displayed name. + +### 17.4 Measured cost, and what did not fit + +| Artifact | Before | After | Headroom | +|---|---|---|---| +| `pulsenova` | 204,526 | **204,705** | 274 → **95** | +| `editorial` | 204,107 | **204,286** | 693 → **514** | +| `signature` | 203,938 | **204,117** | 862 → **683** | +| `notebook` | 203,764 | **203,943** | 1,036 → **857** | + +**+179 B**, on every artifact, because `flag.js` is in the shared app script (§9). +The ceiling was **not** raised; every artifact is inside 204,800 B. `pulsenova` +is the tightest at **95 B**, below the 258 B the Phase 1 block records and below +§10's ≥ 1 KB floor, which was already unmet on entry. + +### 17.5 The boundary that was NOT crossed + +Country **names**, **city names** and **transliterations** are not resolved, and +this is a deliberate stop rather than an oversight: + +1. **A country-name table does not fit.** `Turkey`/`Türkiye`/`Turkiye`, + `Russia`, `Finland` and ~250 others, in the spellings operators actually use, + is a table of well over 1 KB — an order of magnitude more than the 95 B of + headroom that remains. It cannot be added without removing bytes from + somewhere else or raising the ceiling, and both are out of scope for this + change. +2. **A city table is worse.** `Istanbul → TR` is deterministic; `London → GB or + UK` already is not; and the table grows without a natural bound. +3. **The rule for ambiguity is "refuse".** The task's own instruction — *do not + infer from ambiguous names* — and §5's fidelity rule both point the same way. + A wrong flag is a factual error; the monogram is merely plain. + +So `Turkey - Istanbul`, `Türkiye`, `Turkiye`, `Russia Moscow`, `United Kingdom +London` and `Finland Helsinki` still draw the monogram, and the tests **assert +that they do** — recording the boundary rather than pretending to cross it. Names +that carry an alpha-2 code or an emoji — including every reported real-world case +that had a code — now draw a flag. + +### 17.6 Locks and tests + +All **15** byte-locks were re-baselined (`tests/build.test.mjs`: the 4 individual +locks and the 13-row `FROZEN_ARTIFACTS` table), because `flag.js` is shared. +`tests/flag.test.mjs` went from **13 tests to 20** — seven added: the reported +cases; **all 676 letter pairs** against the 258-code registry; the +uppercase/bounded/assigned refusals; emoji-beats-code; a re-assertion that no +previous answer moved; that the label is left exactly as the operator wrote it; +and the country-name boundary. The previous 13 tests pass verbatim. + +--- + +ALPHA-2 INPUT PATH ADDED — RETURN SHAPE, `CODES` AND FALLBACKS UNCHANGED — 15 LOCKS RE-BASELINED diff --git a/docs/design/PANEL-COMPATIBILITY-AUDIT.md b/docs/design/PANEL-COMPATIBILITY-AUDIT.md index 09d079f..872bb9a 100644 --- a/docs/design/PANEL-COMPATIBILITY-AUDIT.md +++ b/docs/design/PANEL-COMPATIBILITY-AUDIT.md @@ -1047,6 +1047,13 @@ subscriber's own address is deliberately excluded). | `datepicker` | `.datepicker` | — | — | | `links[]` | `.links` | `links` context key (may be `[]`) | `links` / `user.links` | +> **The `subTitle` row is the `/info` mapping, not the page's.** Both panels +> resolve that header from panel-wide settings, but **neither page context +> carries it**: the page's own title is derived from the subscriber +> (`user.username`, and on PasarGuard the admin's own `profile_title` first). +> The header value and the page title can therefore legitimately differ. See +> `PASARGUARD-ADAPTER-AUDIT.md` §8 and `REBECCA-ADAPTER-AUDIT.md` §2. + Additional data neither panel needs to fake because the UI does not use it: the combined `used` is computed as `download + upload` in `normalize()`; with no split available the adapter supplies the combined value and the UI renders correctly (§0.5). diff --git a/docs/design/PASARGUARD-ADAPTER-AUDIT.md b/docs/design/PASARGUARD-ADAPTER-AUDIT.md index e0e59c7..d52235c 100644 --- a/docs/design/PASARGUARD-ADAPTER-AUDIT.md +++ b/docs/design/PASARGUARD-ADAPTER-AUDIT.md @@ -220,4 +220,59 @@ Everything else is DIRECT, ADAPTER, DERIVED, or a clean omission. --- -PASARGUARD ADAPTER AUDIT COMPLETE — NO ADAPTER CREATED — AWAITING REVIEW BEFORE PHASE 3B +## 8. 1.3.0 hardening addendum — the page context is not the /info payload + +*Added 2026-09-27, while hardening 1.3.0. This section corrects nothing above; it +records a distinction §4 did not need, because §4 is about the **adapter** and +this is about the **page**.* + +### 8.1 Two different PasarGuard surfaces + +The adapter and the page read **different** PasarGuard surfaces, and the same +field name can mean different things on each: + +| Surface | Where it comes from | What it holds | +|---|---|---| +| the `/info` **headers** | `create_response_headers()` in `app/operation/subscription.py` | the values the panel **resolved**: `support-url` = `admin.support_url or sub_settings.support_url`; `profile-title` = `encode_title(formatted_title)`, where `formatted_title` is the **admin's** `profile_title` or the panel-wide `Subscription.profile_title` (`"Subscription"` by default) | +| the **page context** | `_build_subscription_body_payload()` | `{user, links, announce, announce_url, apps}` — and **nothing else**. The Jinja2 environment adds only `now` (`app/templates/__init__.py`) | + +So §4's row for `title` (`profile-title` header, base64, DERIVED) is correct **for +the adapter**, and it is *not* what the page reads. The page cannot read a header +at all, and it cannot reach the panel-wide `Subscription` settings: they are not +in the context. What the page *can* read is the admin's own columns, because +`user.admin` is `AdminContactInfo` — `{id, username, telegram_id, +discord_webhook, sub_domain, profile_title, support_url, custom_variables, +notification_enable}`. + +### 8.2 The three fields this hardening needed + +| Needed | Page context source | Notes | +|---|---|---| +| subscriber name | `user.username` | The only deterministic subscriber identifier in the context. `email` does not exist on the model; `id`, `note`, `subscription_url` and `auto_delete_in_days` are `exclude=True`. `UserValidator.validate_username` bounds it to 3–128 characters of `[a-zA-Z0-9-_@.]`, so it is Latin and HTML-safe by the panel's own rule. | +| service / brand name | `user.admin.profile_title` | The admin's configured title — the same value that becomes the `profile-title` header when the admin's is the one chosen. The panel-wide `Subscription.profile_title` is **not reachable**, so the page's fallback is the subscriber's own name, which is the panel's own behaviour when nothing is configured. | +| support URL | `user.admin.support_url` | Already implemented and unchanged; the page reads the admin's column directly, which agrees with the header the panel resolves from it. | + +### 8.3 A profile title can carry format placeholders + +`setup_format_variables()` (§3) exists because a `profile_title` is +`str.format`-ed against those 13 variables before it is used. A page that shows +`user.admin.profile_title` verbatim can therefore render a literal +`{DATA_LIMIT}` or `{EXPIRE_DATE}`. The page cannot resolve them — the values are +in the panel's response, not the page context — so a title containing `{` is +refused and falls through to the subscriber's name. Showing the subscriber their +own name is better than showing them an unresolved placeholder. + +### 8.4 What this addendum does not change + +- The adapter's mapping in §4 is unchanged. **No adapter file is touched by the + 1.3.0 hardening**, and no PasarGuard field is newly consumed. +- §5's `Live polling via ?format=info` row stands as written: the page's poller + still uses 3X-UI's `?format=info` query, PasarGuard's endpoint is a path + suffix, and the failure is fail-safe (`unsupported`; the server-rendered + figures stand). It remains **deferred and out of scope**, as §5 and R7 say. +- R4 (`ip` must not be carried) is unchanged and still enforced: the page reads + `user.ip` nowhere. + +--- + +PASARGUARD ADAPTER AUDIT COMPLETE — ADAPTER WRITTEN IN 1.3.0 — §8 ADDED DURING 1.3.0 HARDENING diff --git a/docs/design/PGCLOCK-AUDIT.md b/docs/design/PGCLOCK-AUDIT.md new file mode 100644 index 0000000..773a9d3 --- /dev/null +++ b/docs/design/PGCLOCK-AUDIT.md @@ -0,0 +1,152 @@ +# PGClock — secondary-reference audit (1.3.0 hardening) + +**Status:** read-only audit. **No PGClock code was copied, ported or derived from.** +`PROVENANCE.md` is unchanged, and no licence obligation arises (see §5). + +**Subject:** , at `HEAD` = `2ee77a7` +("نسخهٔ استاندارد — قالب شیشه‌ای صفحهٔ اشتراک برای Pasarguard" — the standard +glass-style subscription page template for PasarGuard). + +**Question this audit answers:** does PGClock hold any PasarGuard behaviour that +Row-Template needs for the four 1.3.0 hardening items — subscriber identity, +service/brand name, support URL, or country/flag detection — and is any of it +worth importing? + +**Short answer: no.** PGClock is a single self-contained HTML page. It confirms +one decision Row had already taken from the panel source (`user.username` is the +subscriber identity), says nothing at all about the other three, and is +unlicensed, so nothing may be taken from it in any case. + +--- + +## 1. What the repository contains + +Every file at `HEAD`, from the Git tree API: + +| Path | Bytes | Kind | +|---|---|---| +| `README.md` | 2,920 | Persian install/usage notes | +| `install.sh` | — | Ubuntu installer + version menu (`Lite` / `PGClock` / `Pro`) | +| `index.html` | 91,075 | the page itself — CSS, markup and JS in one file | +| `Preview.png` | 288,721 | screenshot | +| `Host Version/index.html` | 94,525 | the "host" variant of the same page | +| `Host Version/index.php` | 1,862 | a thin wrapper | +| `Host Version/.htaccess` | 27 | rewrite rule | + +There is no `package.json`, no build step, no source directory and no test +suite. The README states this as a feature: *"یک فایل HTML — بدون Node.js و +build"* — one HTML file, no Node.js and no build. + +--- + +## 2. The install mechanism it uses (relevant, and consistent with Row) + +PGClock writes to `/var/lib/pasarguard/templates/subscription/index.html` and +sets two keys in `/opt/pasarguard/.env`: + +```env +CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/" +SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html" +``` + +That is the same mechanism Row-Template's PasarGuard installer uses +(`PASARGUARD-INSTALLER-AUDIT.md`), reached independently by a third party. It is +useful corroboration that the mechanism is the intended one — and it is the only +thing in the repository that is relevant to Row at all. Row's installer was +written from the panel source and is **not** changed by this finding. + +--- + +## 3. Subscriber identity — confirms `user.username`, and nothing more + +PGClock reads exactly one identity field, in one place: + +```js +var name = user.username || '—'; // index.html:2054 +document.title = name && name !== '—' ? name : 'اشتراک'; // index.html:2056 +``` + +So the page's own title is **the subscriber's username**, with the generic +Persian word for "subscription" (`اشتراک`) as the fallback — and the static +`اشتراک` in the markup is the same fallback. + +This is a **secondary, independent confirmation** of the decision Row's fix +takes from the panel source (`app/templates/subscription/index.html` titles +itself with `user.username`). It is confirmation only: the code is three lines +long, it is the obvious implementation, and nothing was taken from it. + +`user.username` is also the only field PGClock reads that Row's fix reads. Its +other reads — `status`, `used_traffic`, `data_limit`, `expire`, `online_at`, +`on_hold_expire_duration`, `on_hold_timeout`, `hwid_limit`, `created_at` — are +fields Row's adapter already maps, and none of them is an identity field. + +## 4. Service name, support URL and flags — absent + +The whole 91 KB file was searched, not sampled: + +| What was searched for | Occurrences in `index.html` | +|---|---| +| `user.username` | 1 (§3) | +| `profile_title` | **0** | +| `support_url` | **0** | +| `service_name` | **0** | +| `sub_settings` | **0** | +| `country` | **0** | +| `flag` | **0** | +| a regional-indicator code point, or `U+1F1E6`–`U+1F1FF` | **0** | + +The two apparent "ISO" hits are `Date.prototype.toISOString()` and a variable +named `isOpen`; there is no country-code logic of any kind. PGClock has **no +service/brand name variable, no support URL variable and no country or flag +mapping**, so it offers no guidance on hardening items 2 and 3. + +It is worth stating plainly *why* it has none, because it is the same reason Row +has none: PasarGuard's page context is +`{user, links, announce, announce_url, apps}` plus Jinja2's `now` +(`app/operation/subscription.py` `_build_subscription_body_payload`). A +`profile_title` or `support_url` is not in it. PGClock's author therefore had +nothing to read, exactly as Row does not — which is why Row's fix reads the +admin's own column (`user.admin.profile_title`, which *is* in the context) and +why the panel-wide settings are documented as unreachable +(`PASARGUARD-ADAPTER-AUDIT.md` §8). + +--- + +## 5. Licence — none, so nothing may be taken + +There is **no `LICENSE` file** (`raw.githubusercontent.com/.../LICENSE` → HTTP +404), **no licence header** in `index.html`, `install.sh`, `index.php` or +`.htaccess`, and **no licence section** in the README. The README's only +statement of terms is the install instructions. + +Under the default of "all rights reserved", PGClock is **not** open source, and +no code, comment, identifier, string or data table may be copied from it — +including the "obvious" three-line username block in §3, which Row did not copy +(it is written from the panel's own template, and the fix cites the panel file, +not PGClock). + +**No PGClock code is present in Row-Template.** This audit therefore does **not** +trigger the PROVENANCE obligation: `PROVENANCE.md` is unchanged, and no +attribution line is added. If PGClock is ever relicensed, or if any code is ever +taken from it, that file must be updated first. + +--- + +## 6. Verdict + +**PGClock is a useful secondary confirmation and a source of nothing else.** + +1. It independently corroborates `user.username` as the subscriber identity + (§3) — the same conclusion the panel source gives, reached without the + panel source. +2. It corroborates the `.env` + `CUSTOM_TEMPLATES_DIRECTORY` install mechanism + Row's PasarGuard installer already uses (§2). +3. It has no service-name, support-URL or flag logic to offer (§4). +4. It is unlicensed, so nothing could be taken from it even if it did (§5). + +No code was imported, no data table was transcribed, and no licence obligation +arises. The hardening items stand on the panel source, as the task requires. + +--- + +PGCLOCK AUDIT COMPLETE — READ-ONLY — NOTHING DERIVED — PROVENANCE UNCHANGED diff --git a/docs/design/README.md b/docs/design/README.md index aebb65b..288f779 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -44,6 +44,7 @@ Rebecca were research targets; since 1.3.0 all three are supported (see the | [REBECCA-ADAPTER-AUDIT.md](REBECCA-ADAPTER-AUDIT.md) | Mapping Rebecca to the page's data contract, from source. | | [REBECCA-ADAPTER-DECISIONS.md](REBECCA-ADAPTER-DECISIONS.md) | Decision record for Rebecca's time and status values. | | [LIVE-POLLING-AUDIT.md](LIVE-POLLING-AUDIT.md) | Audit of the live status refresh contract across panels. | +| [PGCLOCK-AUDIT.md](PGCLOCK-AUDIT.md) | 1.3.0: read-only audit of the third-party PGClock template as a secondary reference. Nothing derived; `PROVENANCE.md` unchanged. | | [MULTIPANEL-CHECKPOINT-PLAN.md](MULTIPANEL-CHECKPOINT-PLAN.md) | Repository-state audit and commit plan for the multi-panel work. | ## Installer diff --git a/docs/design/REBECCA-ADAPTER-AUDIT.md b/docs/design/REBECCA-ADAPTER-AUDIT.md index d62fe0e..8e593c9 100644 --- a/docs/design/REBECCA-ADAPTER-AUDIT.md +++ b/docs/design/REBECCA-ADAPTER-AUDIT.md @@ -139,6 +139,16 @@ Two things the context does that matter: - **`current_timestamp` is `time.Now().UTC().Unix()` — seconds.** Confirms the whole time model is seconds-based. +**There is no profile title in it.** The panel-wide `subscription_profile_title` is written into +the `profile-title` **header** above and nowhere else: `renderSubscriptionHTML` → +`renderSubscriptionPageTemplate` (`internal/app/user/subscription.go:1131`, `:3687`) passes the +context listed here, which has no title, no admin object and no service brand. The one identity +the page does receive is `user.username`, so that is what the page's title is derived from +(`src/panels/rebecca/prelude.pongo2`), with a generic fallback when it carries no word at all. +The header title the adapter reads is therefore **not visible to the page**, which is why the +page's title and the adapter's `title` field can legitimately differ — and why a page test must +not assert the header value. + The template itself uses **26 `if` / 26 `endif`, 8 `else`, 6 `for` / 6 `endfor`** — balanced, and all in the pongo2 dialect. diff --git a/src/panels/pasarguard/prelude.jinja2 b/src/panels/pasarguard/prelude.jinja2 index 84597b5..9abfc81 100644 --- a/src/panels/pasarguard/prelude.jinja2 +++ b/src/panels/pasarguard/prelude.jinja2 @@ -22,9 +22,51 @@ lastOnline online_at in epoch milliseconds, empty when never seen subUrl user.subscription_url; empty makes the page use its own URL, which on PasarGuard IS the subscription URL + subTitle the name the page is identified by, from the two values the + panel puts in the page context: + user.admin.profile_title the operator's configured + subscription profile title + user.username the subscriber, when no profile + title is configured + See "THE PAGE TITLE" below for why these share one slot and + why the panel-wide SUB_PROFILE_TITLE cannot be read here. support the admin's support_url when the panel supplies one The subscriber's address (user.ip) is never read. + THE PAGE TITLE + + PasarGuard's own subscription page titles itself with the subscriber + (templates/subscription/index.html: `{{ user.username }} - Subscription + Information`), and the current official template + (PasarGuard/subscription-template, src/App.tsx) does the same: + `document.title = effectiveData.username`. Row's heading is the service name + instead (brand.js displayName: configured service name, then the panel's + title, then the localised word), so the panel's title is what has to carry + both meanings, and it carries them in this order: + + user.admin.profile_title -> user.username -> (the localised word) + + which is the panel's OWN order for a title it resolves itself + (app/operation/subscription.py _format_profile_title: the admin's + profile_title, then the panel-wide one), with the subscriber substituted for + the panel-wide default that the page context does not carry. + + WHY ONE SLOT. A separate subscriber field would be a fourteenth data-* + attribute, and the artifact it would live in has 274 bytes of headroom + against a 204,800-byte ceiling that may not be raised (tools/build.mjs). + Measured, that attribute plus its model and render paths costs ~157 B, and + the flag fix in this same change costs 179 B, so the two do not fit together. + The alternative — reading a missing Go map key — would also print the literal + "" into every 3X-UI page, whose view model has no such field and + which this change must not alter. + + A profile title is REFUSED when it still contains a `{`, because PasarGuard + formats those against variables the page cannot resolve + (app/subscription/share.py setup_format_variables: {USERNAME}, {DATA_LIMIT}, + {EXPIRE_DATE}, ...). Showing the operator a literal "{DATA_LIMIT}" is worse + than showing the subscriber's own name, so the placeholder form falls through + to the username. + Values are escaped on output by the autoescape block that follows this prelude: PasarGuard's environment does NOT autoescape by itself. ========================================================================== -#} @@ -48,11 +90,15 @@ {%- else -%} {%- set expire = 0 -%} {%- endif -%} +{%- set rt_admin = user.admin if user.admin else none -%} +{%- set rt_user = (user.username | string | trim) if user.username else '' -%} +{%- set rt_brand = (rt_admin.profile_title | string | trim) if rt_admin and rt_admin.profile_title else '' -%} +{%- if '{' in rt_brand -%}{%- set rt_brand = '' -%}{%- endif -%} {%- set subUrl = user.subscription_url or '' -%} {%- set subJsonUrl = '' -%} {%- set subClashUrl = '' -%} -{%- set subTitle = '' -%} -{%- set subSupportUrl = (user.admin.support_url or '') if user.admin else '' -%} +{%- set subTitle = rt_brand or rt_user -%} +{%- set subSupportUrl = (rt_admin.support_url | string | trim) if rt_admin and rt_admin.support_url else '' -%} {%- set datepicker = 'gregorian' -%} {%- set announce = announce or '' -%} {%- set links = links or [] -%} diff --git a/src/panels/rebecca/prelude.pongo2 b/src/panels/rebecca/prelude.pongo2 index 5535688..66c4684 100644 --- a/src/panels/rebecca/prelude.pongo2 +++ b/src/panels/rebecca/prelude.pongo2 @@ -27,7 +27,28 @@ A value with a non-UTC offset is left unknown, never guessed. subUrl user.subscription_url, and Clash Meta at its /clash-meta suffix support support_url - Rebecca has no announcement and no page title in its template context. + subTitle user.username, the one identity the page context carries. A + value with no word in it at all -- absent, empty or only + whitespace -- is not a name, and the page keeps its generic + fallback; the value itself is never rewritten. pongo2 has no + trim filter (and no string filter), so the test is wordcount, + which is 0 for every one of those forms and never errors + Rebecca has no announcement in its template context. + + THE PAGE TITLE. Rebecca's panel-wide subscription_profile_title does NOT + reach the page. subscriptionHeaders() puts it in the /info `profile-title` + HEADER (base64, internal/app/user/subscription.go), and the page path -- + renderSubscriptionHTML() -> renderSubscriptionPageTemplate() -- passes the + render context only user, links, links_text, usage_url, support_url, token, + current_timestamp and remaining_days. There is no profile title in it, no + admin object and no service brand. The one identity it does carry is + user.username, so that names the page: the same fallback the PasarGuard + prelude takes when no profile title is configured. Rebecca's own validator + restricts a username to [A-Za-z0-9._@-] of 3 to 32 characters + (internal/app/user/validation.go), so it is plain text by construction -- and + it is escaped on output like every other value, which is what keeps the page + safe if that rule ever widens. No brace guard is needed here, unlike + PasarGuard: Rebecca never runs a title through str.format_map. Every value is escaped on output: pongo2 autoescapes by default, and the explicit autoescape block after this prelude keeps it so. @@ -75,6 +96,7 @@ {%- set subClashUrl = "" -%} {%- if subUrl -%}{%- set subClashUrl = subUrl|add:"/clash-meta" -%}{%- endif -%} {%- set subTitle = "" -%} +{%- if user.username|wordcount > 0 -%}{%- set subTitle = user.username -%}{%- endif -%} {%- set subSupportUrl = support_url -%} {%- set datepicker = "gregorian" -%} {%- set announce = "" -%} diff --git a/src/scripts/flag.js b/src/scripts/flag.js index 6ad7b95..9f908ad 100644 --- a/src/scripts/flag.js +++ b/src/scripts/flag.js @@ -84,6 +84,19 @@ export function paintFlag(badge, flag) { badge.style.color = 'transparent'; } +/* A node name often carries the country as an ISO 3166-1 alpha-2 code instead + of a flag emoji — "TR | Istanbul", "GB-LON-1", "DE". A two-letter run is read + as a code only when it is UPPERCASE and stands alone between non-letters, so + "LON" is not read as a code out of "GB-LON-1" and a lowercase English word + ("in", "no", "it") never matches at all. Membership is the SAME registry the + emoji path uses, so a code is accepted only when it names a real country, and + an unassigned pair is refused exactly as it is there. + + The token is only ever read, never removed from the label: a flag emoji means + a flag and nothing else, but two uppercase letters can be part of an + operator's own wording, so the name is left as written. */ +const CODE = /(?:^|[^A-Z])([A-Z]{2})(?![A-Z])/; + function isIndicator(cp) { return cp >= RI_FIRST && cp <= RI_LAST; } @@ -98,7 +111,8 @@ function letter(cp) { spurious code — "🇿🇿🇺🇸" reads past the tofu pair and finds US, it does not read the middle two as ZU. A lone trailing indicator matches nothing. */ export function flagOf(text) { - const points = Array.from(String(text === null || text === undefined ? '' : text), (ch) => ch.codePointAt(0)); + const s = String(text ?? ''); + const points = Array.from(s, (ch) => ch.codePointAt(0)); let i = 0; while (i < points.length) { if (isIndicator(points[i]) && i + 1 < points.length && isIndicator(points[i + 1])) { @@ -109,7 +123,10 @@ export function flagOf(text) { } i += 1; } - return ''; + const m = CODE.exec(s); + return m && CODES.has(m[1]) + ? String.fromCodePoint(RI_FIRST - 65 + m[1].charCodeAt(0), RI_FIRST - 65 + m[1].charCodeAt(1)) + : ''; } /* The same name with every regional indicator removed, so the flag is shown diff --git a/template/index.html b/template/index.html index a48ae20..a6c643d 100644 --- a/template/index.html +++ b/template/index.html @@ -2658,6 +2658,8 @@

badge.style.color = 'transparent'; } +const CODE = /(?:^|[^A-Z])([A-Z]{2})(?![A-Z])/; + function isIndicator(cp) { return cp >= RI_FIRST && cp <= RI_LAST; } @@ -2667,7 +2669,8 @@

} function flagOf(text) { - const points = Array.from(String(text === null || text === undefined ? '' : text), (ch) => ch.codePointAt(0)); + const s = String(text ?? ''); + const points = Array.from(s, (ch) => ch.codePointAt(0)); let i = 0; while (i < points.length) { if (isIndicator(points[i]) && i + 1 < points.length && isIndicator(points[i + 1])) { @@ -2678,7 +2681,10 @@

} i += 1; } - return ''; + const m = CODE.exec(s); + return m && CODES.has(m[1]) + ? String.fromCodePoint(RI_FIRST - 65 + m[1].charCodeAt(0), RI_FIRST - 65 + m[1].charCodeAt(1)) + : ''; } function cleanName(text) { diff --git a/tests/build.test.mjs b/tests/build.test.mjs index be5f647..d9da7f3 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -137,10 +137,10 @@ test('the default (Row) build is byte-locked to the v1.1.0 artifact', () => { const bytes = Buffer.byteLength(html, 'utf8'); const sha = createHash('sha256').update(html).digest('hex'); - assert.equal(bytes, 201258, 'Row artifact changed size; byte-lock violated'); + assert.equal(bytes, 201437, 'Row artifact changed size; byte-lock violated'); assert.equal( sha, - '9694504337c27dacae2b6e9e2b2954b03560ae44761f514ff7f051718ba66776', + 'bc04b5cbdaf6108c24518e2f1cbdf13e1881262a892184ccb752071c15c616fa', 'Row artifact changed content; byte-lock violated', ); @@ -241,11 +241,11 @@ test('the editorial layout satisfies the hook contract, shares the Row runtime, test('the editorial artifact is byte-locked to the approved magazine design', () => { const html = build(true, 'editorial').html; const bytes = Buffer.byteLength(html, 'utf8'); - assert.equal(bytes, 204107, 'Editorial artifact changed size; byte-lock violated'); + assert.equal(bytes, 204286, 'Editorial artifact changed size; byte-lock violated'); const sha = createHash('sha256').update(html).digest('hex'); assert.equal( sha, - '407b6008019975c3ce768db064bf9268795f2ccb24d3627e4040f2e9f502a8fd', + '56602f69dca5d87f1fd81c6610ba1460d2cc7e43738245eace87b7dfa0ebbfa6', 'Editorial artifact changed content; byte-lock violated', ); }); @@ -637,9 +637,9 @@ test('the saffron artifact names its own design, satisfies the hook contract, an test('the canvas artifact is byte-locked to the approved design', () => { const html = build(true, 'canvas').html; const bytes = Buffer.byteLength(html, 'utf8'); - assert.equal(bytes, 202598, 'Canvas artifact changed size; byte-lock violated'); + assert.equal(bytes, 202777, 'Canvas artifact changed size; byte-lock violated'); const sha = createHash('sha256').update(html).digest('hex'); - assert.equal(sha, '122ccfb1238a1265e7f1878ac862bb7ede8c7e6aff691f4599d043dcf1e3aefa', 'Canvas artifact changed content; byte-lock violated'); + assert.equal(sha, '967f53de2dd7d34ed83eb8478b5b9f4c291217958b3327d05956b144f4d7908d', 'Canvas artifact changed content; byte-lock violated'); }); /* Prism owns its layout: the faceted sheet is its own document, so the @@ -747,11 +747,11 @@ test('the pulsenova layout satisfies the hook contract, shares the Row runtime, test('the pulsenova artifact is byte-locked to the approved dashboard design', () => { const html = build(true, 'pulsenova').html; const bytes = Buffer.byteLength(html, 'utf8'); - assert.equal(bytes, 204526, 'Pulse Nova artifact changed size; byte-lock violated'); + assert.equal(bytes, 204705, 'Pulse Nova artifact changed size; byte-lock violated'); const sha = createHash('sha256').update(html).digest('hex'); assert.equal( sha, - 'c8c1f27201d717028b5994d02422f4d7fcb13ef60a856a556828969c39e0c6b2', + '3ec5f245c098a49b8506e3135eb65f5aa6759a412f3e94356cdc8fdab37c51ae', 'Pulse Nova artifact changed content; byte-lock violated', ); }); @@ -977,19 +977,19 @@ test('the arcadenova cabinet satisfies the hook contract, shares the Row runtime asserts both the artifact's exact byte length and its full SHA-256 through the same build() the CLI and the release generator use. */ const FROZEN_ARTIFACTS = [ - ['prism', 202386, 'dd6a45eb8ba2e706c521a9542032c39a95bce1d7b8444c5b9fec4c135d33134b'], - ['terminal', 199901, '75bd73accaddb403a7d657685f9a4d0d6b2e51da461a8efafe5a29b30ab6f4ba'], - ['pulse', 202334, 'f29c3ff07dcf9eaff2959607823439824f455d082e1771ff4371a8b00106ac7f'], - ['brutal', 202792, '6d676cdad52ff61c65b4a249093c6d6a2e9f8ac52efcff2efe84b176a4fa9731'], - ['arcade', 203292, '71e1141e427fa1de5d081ef580e6878c1d507c69ecd6bf186140b657f5904e53'], - ['sketch', 202334, '6ef86fc952a17632d9496141cdf9af57cca8bf31d9d39627af24347c9eb2a4d5'], - ['signature', 203938, '5d103d8956c25ce598148bf48797b83bf83c81a28b04729aaeae83af17381a9d'], - ['saffron', 203316, '78f20de84e635f0f2c75e7436537c5187f21e3067528c81c243e47e56ad99274'], - ['prismnova', 203226, 'c6eb485fdcf3fb66c5c705eb2f897b176c5fbc0c684b3ff624289714421708cd'], - ['terminalnova', 203203, 'afd6ed22a69450915f87fb7dcdfa7b2f8ee47da65f3896ae57ad73d2e00b587a'], - ['arcadenova', 203569, 'adb9b1088d8b3d492536cc883f53b806da54a5d2a2d749934fadf611964f450e'], - ['meter', 202571, '194ed2c361529fb0c399c36b724fe0b3053d6896e68d303b4a38429f4a872122'], - ['notebook', 203764, '5dd18cb6d7708e1b3ed50ab3186b84146c7a3b706567f2c6c45cfcb29034bda2'], + ['prism', 202565, 'dab4b9acaeed9985be5729f36140c428f6d8af09a2ae82952dc4c38830efb5cf'], + ['terminal', 200080, '158c106bb67c4818282054617a389c6ea5d52cf80c3b3a4fb00a7ca6aee7f30b'], + ['pulse', 202513, 'a83f39155da3ad1b08b189399a74c32e20d9327aace2e3a23b7273dc535ca695'], + ['brutal', 202971, '19b1d19aae5c3465d276c96af496b857c31ea3ecc2b29c6f7be55d437f2711eb'], + ['arcade', 203471, '61de63267902febe67f3f21c4a86fa3a40f759a2c2d2b94fa65d44f9f359e6de'], + ['sketch', 202513, '51b91f97cd5e6b92dfba0611aef3631e0f1623cfb3f6a7af24ea8a1400e6935a'], + ['signature', 204117, '0c21393dac6cd3356e43ef79e1ea9464eced175f3c98cd360cda420752096dc2'], + ['saffron', 203495, '3a178529c4ae37c9f3beac691ebbb7db4c3b4536c31d04e8efbbaa7efb737e85'], + ['prismnova', 203405, '873775a1c6ddecfd47c9a9b91a02afbd6a4a4f02da5b155d9fa7b60c19b0d91c'], + ['terminalnova', 203382, 'd84dca9c951a70fa3b079dc7bed001dd527f76fafc096c872504ca5221ca97a4'], + ['arcadenova', 203748, '4ff39bf9ff0ad6a52014298222037950d65bbe87e0f442051961d3bde626b92c'], + ['meter', 202750, '1796baa1466933b0380b69c846c2542562cd150ea129d3cde3f5b4f1944b85bc'], + ['notebook', 203943, 'cfcc6124673d2b2813f37e8ccfc287a46c0e70db6e3932f044c2ee58cf2de927'], ]; for (const [id, bytes, sha] of FROZEN_ARTIFACTS) { diff --git a/tests/flag.test.mjs b/tests/flag.test.mjs index 130ee62..97c36f7 100644 --- a/tests/flag.test.mjs +++ b/tests/flag.test.mjs @@ -60,6 +60,129 @@ test('a name with no flag is left as it is', () => { assert.equal(cleanName(null), ''); }); +/* --- ISO alpha-2 codes ----------------------------------------------------- + A node name often carries the country as a code rather than a flag emoji. + The badge is the same badge the emoji path draws: the pair is built from the + code, and membership is the same 258-code registry, so a code is accepted + only when it names a real country. The label is never rewritten — two + uppercase letters can be an operator's own wording, and only an emoji + unambiguously means a flag. */ + +/* The two regional indicators for an alpha-2 code, built from the block + offset, so the expectation is not a hand-copied emoji. */ +const pair = (code) => String.fromCodePoint( + 0x1f1e6 + code.charCodeAt(0) - 65, + 0x1f1e6 + code.charCodeAt(1) - 65, +); + +test('a node name carrying an ISO alpha-2 code gets that country flag', () => { + const cases = [ + ['TR | Istanbul', 'TR'], + ['TR-01', 'TR'], + ['TR_01', 'TR'], + ['RU-01', 'RU'], + ['GB-LON-1', 'GB'], + /* "UK" is the exceptionally reserved code the registry already carries + beside GB, because its flag is widely rendered (see src/scripts/flag.js). + The code path therefore accepts it, and "UK London" and "GB-LON-1" + resolve to the two different-but-both-real UK flag emoji. */ + ['UK London', 'UK'], + ['FI-01', 'FI'], + ['DE', 'DE'], + ['NL | Amsterdam', 'NL'], + ['US East', 'US'], + ['FR-Paris-2', 'FR'], + ['AE Dubai', 'AE'], + ['IR Tehran', 'IR'], + ['CA Toronto', 'CA'], + ['JP Tokyo', 'JP'], + ['SG', 'SG'], + ['CH Zurich', 'CH'], + ['SE Stockholm', 'SE'], + ['NO Oslo', 'NO'], + ['ES Madrid', 'ES'], + ['IT Milano', 'IT'], + ['PL Warsaw', 'PL'], + ]; + for (const [raw, code] of cases) { + assert.equal(flagOf(raw), pair(code), `${raw} -> ${code}`); + } +}); + +test('every assigned code resolves through the code path, and no other pair does', () => { + /* The whole registry, so the code path can never be narrower than the emoji + path it stands beside. */ + const assigned = new Set(EXPECTED_CODES); + let found = 0; + for (let a = 0; a < 26; a += 1) { + for (let b = 0; b < 26; b += 1) { + const code = String.fromCharCode(65 + a) + String.fromCharCode(65 + b); + const got = flagOf(`Node ${code}`); + assert.equal(got, assigned.has(code) ? pair(code) : '', code); + if (got) found += 1; + } + } + assert.equal(found, 258, 'exactly the assigned codes'); +}); + +test('a code is read only when it is uppercase and stands alone', () => { + /* The ambiguous cases. A lowercase run is never a code, so the English words + that happen to be assigned codes cannot be read as countries. */ + for (const raw of [ + 'no flag here', 'it support', 'us east', 'in the cloud', 'my server', + 'amsterdam', 'is it up', 'at the edge', 'do not use', 'so slow', 'be quick', + ]) { + assert.equal(flagOf(raw), '', `${raw} must not be read as a country`); + } + /* Three or more letters are not a code, and neither is a code inside a word. */ + assert.equal(flagOf('LON'), '', 'a three-letter run is not a code'); + assert.equal(flagOf('GB-LON-1'), pair('GB'), 'and does not stop the real code being found'); + assert.equal(flagOf('USA'), '', 'a code inside a longer word is not read'); + assert.equal(flagOf('A1B'), '', 'a letter either side disqualifies it'); + assert.equal(flagOf('ZZ-01'), '', 'an unassigned pair is refused, exactly as in the emoji path'); + assert.equal(flagOf('QQ'), ''); + assert.equal(flagOf('XX'), ''); +}); + +test('a flag emoji still wins over a code in the same name', () => { + assert.equal(flagOf('\u{1F1E9}\u{1F1EA} TR-01'), '\u{1F1E9}\u{1F1EA}', 'the emoji is the more explicit claim'); + assert.equal(flagOf('TR \u{1F1E9}\u{1F1EA}'), '\u{1F1E9}\u{1F1EA}'); +}); + +test('the code path changes no existing answer', () => { + /* The whole previous behaviour, re-asserted through the same entry point. */ + assert.equal(flagOf('No flag here'), ''); + assert.equal(flagOf('\u{1F525} Fast'), ''); + assert.equal(flagOf('\u{1F3F3}\u{FE0F} white'), ''); + assert.equal(flagOf(''), ''); + assert.equal(flagOf(null), ''); + assert.equal(flagOf(undefined), ''); + assert.equal(flagOf(42), ''); + assert.equal(flagOf('\u{1F1FF}\u{1F1FF}\u{1F1FA}\u{1F1F8}'), '\u{1F1FA}\u{1F1F8}'); +}); + +test('the label is left exactly as the operator wrote it', () => { + /* A code is not stripped from the name: only an emoji unambiguously means a + flag, and "TR" may be part of a name rather than a country. */ + assert.equal(cleanName('TR | Istanbul'), 'TR | Istanbul'); + assert.equal(cleanName('GB-LON-1'), 'GB-LON-1'); + assert.equal(cleanName('DE'), 'DE'); + assert.equal(cleanName('Node TR'), 'Node TR'); +}); + +test('a country name that is not a code is still refused, not guessed', () => { + /* Reported real-world names that carry neither an emoji nor a code. The + monogram fallback is the honest answer for these until a country-name + table exists; this test records the boundary rather than pretending to + cross it. */ + for (const raw of [ + 'Turkey - Istanbul', 'T\u00FCrkiye', 'Turkiye', 'Russia Moscow', + 'United Kingdom London', 'Finland Helsinki', + ]) { + assert.equal(flagOf(raw), '', `${raw}: no code, no flag`); + } +}); + /* The full slate of names the refinement calls out: a flag leads, trails or sits mid-name; the label is Latin, Persian, Turkish, Chinese, Cyrillic; and the generic emoji that are not countries draw no badge and are left in place. */ diff --git a/tests/helpers/panel-hosts.mjs b/tests/helpers/panel-hosts.mjs index cca9706..d585599 100644 --- a/tests/helpers/panel-hosts.mjs +++ b/tests/helpers/panel-hosts.mjs @@ -12,7 +12,7 @@ * variables -- the same knobs a non-default APP_NAME would use, so no adapter * code is bypassed. * - * WHAT IS DOUBLED, AND WHY. Two external programs the adapters talk to: + * WHAT IS DOUBLED, AND WHY. Three external programs the adapters talk to: * * docker a shim that keeps "is the panel container running" in a state * directory, and on `compose up` loads the .env the way Docker @@ -22,15 +22,25 @@ * sqlite3 forwards to Python's sqlite3 module (as the 3X-UI suite does), so * Rebecca's database is a real SQLite file and every statement the * adapter runs is executed for real. + * curl denied, and the attempt recorded. These suites are offline; see + * wget NET_DENY_SHIM below for what used to leak in through them. * * Everything else -- the adapters, the transaction engine, the library -- is * the shipping code. + * + * `sqlite: false` means the host has NO sqlite3, and it is enforced rather than + * assumed: the child runs with a PATH from which every directory holding a + * sqlite3 has been removed (pathWithoutSqlite). The adapters gate on + * `command -v sqlite3`, so shadowing it with a shim that fails would still let + * the gate pass -- which is exactly how the runner's own sqlite3 changed the + * outcome of the manual-activation test. */ import { spawnSync } from 'node:child_process'; -import { chmodSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { chmodSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync, existsSync, symlinkSync, rmSync } from 'node:fs'; import { createHash } from 'node:crypto'; -import { dirname, join, resolve } from 'node:path'; +import { delimiter, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from '../../tools/build.mjs'; @@ -54,9 +64,14 @@ export const PYTHON = workingProgram(process.platform === 'win32' ? ['python', ' export function bashRun(lines, { paths = {}, env = {} } = {}) { const head = Object.entries(paths).map(([k, v]) => `${k}="$(cygpath -u ${sq(v)} 2>/dev/null || printf '%s' ${sq(v)})"; export ${k}`); + // A bare string is ONE line, not one line per character: `...lines` on a + // string spreads its characters, which turns the script into a column of + // one-character commands that dies under `set -e` before printing anything. + // Accept either shape, the way the suites' own `run` helpers do. + const body = [].concat(lines); const script = ['set -Eeuo pipefail', 'unset RT_TEMPLATE RT_RELEASE_URL RT_RELEASE_DIR RT_ASSUME_YES RT_PANEL RT_SMOKE_URL XUI_DB_FOLDER', - ...head, ...lines].join('\n'); + ...head, ...body].join('\n'); const r = spawnSync('bash', ['-c', script], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...env }, }); @@ -209,6 +224,87 @@ finally: PYEOF `; +/* --- no network, and no sqlite3 the host happens to have -------------------- + * + * NETWORK. `rt_complete_install` downloads the release pinned to the INSTALLED + * version whenever it judges the install short of what that version ships. The + * panel suites used to build a two-design payload, and RT_TEMPLATES_AVAILABLE + * names seventeen, so the install was ALWAYS short: on a runner with network the + * download succeeded, the store was rebuilt from the PUBLISHED release, and the + * test then compared it against the LOCALLY built artifact. The payloads are + * complete now, so that download is not reached -- these shims are the guard + * rail. curl and wget are on PATH in every fake host, they record the attempt + * and they fail, so a regression fails the suite loudly instead of silently + * validating a published release against the working tree. + * + * SQLITE3. `rt_panel__db_ready` gates on `command -v sqlite3`, so a + * sqlite3 that merely FAILS is not enough -- the command must be unreachable. + * `sqlite: false` therefore scrubs every directory that holds a sqlite3 out of + * the child's PATH (see pathWithoutSqlite), rather than trusting that the host + * has none: ubuntu-latest ships a real one, which is what made the manual + * activation test pass on the authoring machine and fail on the runner. */ +const NET_DENY_SHIM = `#!/usr/bin/env bash +# Test double: this suite has no network. Record the attempt, then fail. +set -u +if [ -n "\${RT_TEST_NET_LOG:-}" ]; then + printf '%s %s\\n' "\${0##*/}" "$*" >> "$RT_TEST_NET_LOG" +fi +printf 'row-template tests: network access is denied in this suite (%s %s)\\n' "\${0##*/}" "$*" >&2 +exit 1 +`; + +/* A PATH that is the one given, minus every directory that holds a sqlite3 + executable. Those directories are replaced by a mirror of themselves -- the + same entries, symlinked, without sqlite3 -- so nothing else on the host stops + resolving. Cached per input, because mirroring /usr/bin is ~1500 links. + + A mirror is only trusted once it is stamped. A run killed by a signal (a + timeout, a cancelled job) never reaches the exit hook below, and a half-built + mirror would otherwise be reused on the next run -- and, if `bash` happened to + be the entry that never got linked, fail in a way that has nothing to do with + sqlite3. */ +const mirrors = new Map(); +function mirrorWithoutSqlite(dir) { + const dest = join(tmpdir(), `row-nosqlite-${sha256(dir).slice(0, 12)}`); + const stamp = join(dest, '.mirror-complete'); + if (!existsSync(stamp)) { + rmSync(dest, { recursive: true, force: true }); + mkdirSync(dest, { recursive: true }); + let n = 0; + for (const entry of readdirSync(dir)) { + if (entry === 'sqlite3') continue; + try { symlinkSync(join(dir, entry), join(dest, entry)); n += 1; } catch { /* unreadable entry: skip it */ } + } + writeFileSync(stamp, `${n}\n`); + process.on('exit', () => rmSync(dest, { recursive: true, force: true })); + } + return dest; +} + +export function pathWithoutSqlite(base = process.env.PATH || '') { + const hit = mirrors.get(base); + if (hit) return hit; + const out = []; + const seen = new Set(); + for (const dir of base.split(delimiter).filter(Boolean)) { + if (seen.has(dir)) continue; + seen.add(dir); + if (!existsSync(join(dir, 'sqlite3'))) { out.push(dir); continue; } + out.push(mirrorWithoutSqlite(dir)); + } + const path = out.join(delimiter); + mirrors.set(base, path); + return path; +} + +/* The network attempts a fake host recorded. Empty means the suite never tried: + assert on it, so "this test needs no network" is checked, not assumed. */ +export function netAttempts(base) { + const f = join(base, 'net.log'); + if (!existsSync(f)) return ''; + return readFileSync(f, 'utf8').trim(); +} + /* The transaction engine locks with flock and refuses to run without it. Git Bash on Windows has none, so -- exactly as tests/installer-transaction.test.mjs does -- a minimal double provides flock's contract (an exclusive lock on the @@ -242,6 +338,11 @@ function shims(base, { sqlite = true } = {}) { mkdirSync(bin, { recursive: true }); writeFileSync(join(bin, 'docker'), DOCKER_SHIM); chmodSync(join(bin, 'docker'), 0o755); + // Both downloaders rt_fetch_one can pick, denied in every host. + for (const prog of ['curl', 'wget']) { + writeFileSync(join(bin, prog), NET_DENY_SHIM); + chmodSync(join(bin, prog), 0o755); + } if (!HOST_HAS_FLOCK) { writeFileSync(join(bin, 'flock'), FLOCK_SHIM); chmodSync(join(bin, 'flock'), 0o755); @@ -318,7 +419,13 @@ export function pasarguardHost(base, { running = true, env = PG_ENV, compose = t const bin = shims(base, { sqlite: Boolean(db) }); return { app, dataDir, cliPath, docker, bin, db: dbPath, envFile: join(app, '.env'), - paths: { RT_PG_APP_DIR: app, RT_PG_DATA_DIR: dataDir, RT_PG_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin }, + paths: { + RT_PG_APP_DIR: app, RT_PG_DATA_DIR: dataDir, RT_PG_CLI: cliPath, + RT_TEST_DOCKER: docker, RT_TEST_BIN: bin, RT_TEST_NET_LOG: join(base, 'net.log'), + }, + // No sqlite3 shim means the host has no sqlite3 as far as the code under + // test is concerned -- including any sqlite3 the RUNNER ships. + env: db ? {} : { PATH: pathWithoutSqlite() }, }; } @@ -382,7 +489,14 @@ export function rebeccaHost(base, { running = true, sqlite = true, url, customDi const bin = shims(base, { sqlite }); return { app, dataDir, cliPath, docker, bin, db, envFile: join(app, '.env'), - paths: { RT_RB_APP_DIR: app, RT_RB_DATA_DIR: dataDir, RT_RB_CLI: cliPath, RT_TEST_DOCKER: docker, RT_TEST_BIN: bin }, + paths: { + RT_RB_APP_DIR: app, RT_RB_DATA_DIR: dataDir, RT_RB_CLI: cliPath, + RT_TEST_DOCKER: docker, RT_TEST_BIN: bin, RT_TEST_NET_LOG: join(base, 'net.log'), + }, + // `sqlite: false` must make sqlite3 UNAVAILABLE, not merely broken: the + // adapters gate on `command -v sqlite3`, so the runner's own sqlite3 has to + // be off PATH, not shadowed by a shim that exists and fails. + env: sqlite ? {} : { PATH: pathWithoutSqlite() }, }; } diff --git a/tests/installer-panel-pasarguard.test.mjs b/tests/installer-panel-pasarguard.test.mjs index 1a76fe3..156b37a 100644 --- a/tests/installer-panel-pasarguard.test.mjs +++ b/tests/installer-panel-pasarguard.test.mjs @@ -23,11 +23,18 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { - bashRun, makePayload, pasarguardHost, HOST_PREAMBLE, sha256, PG_ENV, + bashRun, makePayload, pasarguardHost, HOST_PREAMBLE, sha256, PG_ENV, netAttempts, } from './helpers/panel-hosts.mjs'; - +import { availableTemplateIds } from '../tools/templates.mjs'; + +/* A COMPLETE payload -- every design the release registry names, not a sample. + A payload short of them makes `rt_complete_install` fetch the release pinned + to the INSTALLED version, and on a runner with network that pulled the + PUBLISHED release into the store, which was then compared against this + working tree's artifact. The host denies the network and records any attempt + (tests/helpers/panel-hosts.mjs); the life-cycle test asserts it never tried. */ const PAYLOAD_DIR = mkdtempSync(join(tmpdir(), 'row-pg-payload-')); -const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: ['row', 'editorial'] }); +const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: availableTemplateIds() }); process.on('exit', () => rmSync(PAYLOAD_DIR, { recursive: true, force: true })); /* A Row-Template install on a PasarGuard host, up to a generated page -- the @@ -46,8 +53,9 @@ function withHost(opts, fn) { try { const host = pasarguardHost(base, opts); const rt = join(base, 'rt'); - const run = (lines) => bashRun([HOST_PREAMBLE, ...[].concat(lines)], - { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD } }); + const run = (lines, env = {}) => bashRun([HOST_PREAMBLE, ...[].concat(lines)], + { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD }, + env: { ...host.env, ...env } }); return fn({ base, host, rt, run }); } finally { rmSync(base, { recursive: true, force: true }); @@ -404,5 +412,11 @@ test('install, verify, rebrand, switch design, roll back and uninstall on a Pasa assert.equal(existsSync(rt), false, 'the install root is gone'); assert.equal(existsSync(join(base, 'row-template')), false, 'and the CLI'); assert.equal(existsSync(host.cliPath), true, 'PasarGuard itself is untouched'); + + /* The whole life cycle ran offline. curl and wget are denied in this host + and every attempt is recorded, so this is a measurement, not a promise: + the payload is complete, rt_complete_install never fires, and nothing the + suite does may depend on a published release. */ + assert.equal(netAttempts(base), '', 'the life cycle must not touch the network'); }); }); diff --git a/tests/installer-panel-rebecca.test.mjs b/tests/installer-panel-rebecca.test.mjs index 65deb52..0568de2 100644 --- a/tests/installer-panel-rebecca.test.mjs +++ b/tests/installer-panel-rebecca.test.mjs @@ -19,16 +19,23 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { delimiter, join } from 'node:path'; import { - bashRun, makePayload, rebeccaHost, rebeccaRow, HOST_PREAMBLE, posix, + bashRun, makePayload, rebeccaHost, rebeccaRow, HOST_PREAMBLE, posix, netAttempts, pathWithoutSqlite, } from './helpers/panel-hosts.mjs'; - +import { availableTemplateIds } from '../tools/templates.mjs'; + +/* A COMPLETE payload -- every design the release registry names, not a sample. + A payload short of them makes `rt_complete_install` fetch the release pinned + to the INSTALLED version, and on a runner with network that pulled the + PUBLISHED release into the store, which was then compared against this + working tree's artifact. The host denies the network and records any attempt + (tests/helpers/panel-hosts.mjs); the life-cycle test asserts it never tried. */ const PAYLOAD_DIR = mkdtempSync(join(tmpdir(), 'row-rb-payload-')); -const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: ['row', 'editorial'] }); +const PAYLOAD = makePayload(PAYLOAD_DIR, { ids: availableTemplateIds() }); process.on('exit', () => rmSync(PAYLOAD_DIR, { recursive: true, force: true })); const SETUP = [ @@ -46,7 +53,8 @@ function withHost(opts, fn) { const host = rebeccaHost(base, opts); const rt = join(base, 'rt'); const run = (lines, env = {}) => bashRun([HOST_PREAMBLE, ...[].concat(lines)], - { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD }, env }); + { paths: { ...host.paths, RT_ROOT: rt, RT_BIN: join(base, 'row-template'), PAYLOAD }, + env: { ...host.env, ...env } }); return fn({ base, host, rt, run }); } finally { rmSync(base, { recursive: true, force: true }); @@ -422,6 +430,48 @@ test('a failed panel refresh leaves sub.html and the placed page exactly as they /* --- manual activation --------------------------------------------------------- */ +/* The runner's own sqlite3 must not leak into `sqlite: false`. The adapters + gate on `command -v sqlite3`, so a sqlite3 that merely fails would still open + the automatic path -- and that is exactly what happened: ubuntu-latest ships + a real sqlite3, so activation reported "auto" on the runner while this + authoring host, which has none, reported "manual". */ +test('sqlite: false takes every sqlite3 off PATH, a real one included', () => { + const planted = mkdtempSync(join(tmpdir(), 'row-fake-sqlite-')); + try { + /* A stand-in for a runner that ships sqlite3, plus a second tool so the + substituted directory still has to carry everything else it held. */ + for (const name of ['sqlite3', 'rowmarker']) { + writeFileSync(join(planted, name), `#!/bin/sh\necho "${name} on the host"\n`); + chmodSync(join(planted, name), 0o755); + } + const scrubbed = pathWithoutSqlite([planted, process.env.PATH].join(delimiter)); + assert.equal(scrubbed.split(delimiter).includes(planted), false, + 'a directory holding a sqlite3 is not on the child PATH'); + + /* `rc=0; … || rc=$?` rather than `… ; echo $?`, because bashRun runs under + `set -e` and a bare failing command would end the script before the echo. */ + const seen = bashRun([ + 's=0; command -v sqlite3 >/dev/null 2>&1 || s=$?; echo "sqlite3=$s"', + 'm=0; command -v rowmarker >/dev/null 2>&1 || m=$?; echo "rowmarker=$m"', + ], { env: { PATH: scrubbed } }); + assert.match(seen.out, /sqlite3=1/, + `command -v sqlite3 fails -- the gate the adapters ask\n${seen.code}\n${seen.err}`); + assert.match(seen.out, /rowmarker=0/, 'and the rest of the host still resolves'); + + // And through the host the suite actually builds. + withHost({ sqlite: false }, ({ run }) => { + const g = run([ + 's=0; command -v sqlite3 >/dev/null 2>&1 || s=$?; echo "found=$s"', + 'rc=0; rt_panel_rebecca_db_ready || rc=$?; echo "db_ready=$rc"', + ].join('\n')); + assert.match(g.out, /found=1/, 'the fake host offers no sqlite3 at all'); + assert.match(g.out, /db_ready=1/, 'so the panel selection cannot be read here'); + }); + } finally { + rmSync(planted, { recursive: true, force: true }); + } +}); + test('without sqlite3, activation places the page and says exactly what to set', () => { withHost({ sqlite: false }, ({ host, run }) => { const before = last(host); @@ -473,5 +523,11 @@ test('install, verify, rebrand, switch design, roll back and uninstall on a Rebe assert.equal(existsSync(rt), false); assert.equal(existsSync(join(base, 'row-template')), false); assert.ok(readdirSync(host.dataDir).includes('db.sqlite3'), 'the database is still there'); + + /* The whole life cycle ran offline. curl and wget are denied in this host + and every attempt is recorded, so this is a measurement, not a promise: + the payload is complete, rt_complete_install never fires, and nothing the + suite does may depend on a published release. */ + assert.equal(netAttempts(base), '', 'the life cycle must not touch the network'); }); }); diff --git a/tests/panels-engines.test.mjs b/tests/panels-engines.test.mjs index f6b0bc7..16a14d8 100644 --- a/tests/panels-engines.test.mjs +++ b/tests/panels-engines.test.mjs @@ -24,7 +24,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { assembleShell, wrapForPanel, TEMPLATE_DELIMITER } from '../tools/shell.mjs'; -import { extractIsland, toModel } from '../tools/contract.mjs'; +import { extractIsland, toModel, validateIsland } from '../tools/contract.mjs'; import { templateIds } from '../tools/templates.mjs'; import { renderPasarGuard, renderRebecca, pasarguardContext, rebeccaContext, @@ -58,19 +58,46 @@ const HOSTILE = '">{{ 7*7 } function pgExpected(doc, links) { return { ...doc.expected.model, - title: '', // not in PasarGuard's page context + title: pgPageTitle(doc), subUrl: '', // not a database column: the page uses its own URL subClashUrl: '', links, }; } +/* The page's title, as the shipped prelude derives it + (src/panels/pasarguard/prelude.jinja2): the admin's configured profile title + when there is one, otherwise the subscriber's own name. A profile title that + still carries a `{` is refused, because PasarGuard formats those against + variables the page cannot resolve, and a literal "{DATA_LIMIT}" on the page + is worse than the subscriber's name. */ +function pgPageTitle(doc) { + const admin = doc.native.admin || null; + const brand = admin && admin.profile_title ? String(admin.profile_title).trim() : ''; + if (brand !== '' && !brand.includes('{')) return brand; + return String(doc.native.info.username ?? '').trim(); +} + +/* The page's title, as the shipped prelude derives it + (src/panels/rebecca/prelude.pongo2). Rebecca's page context carries NO + profile title: the panel-wide subscription_profile_title is written into the + /info `profile-title` HEADER (subscriptionHeaders) and the page render path + (renderSubscriptionHTML -> renderSubscriptionPageTemplate) never passes it, + so the /info title the adapter reads is not visible to the page at all. The + one identity the page does receive is user.username, which is therefore the + page's name -- the same fallback the PasarGuard prelude takes when no + profile title is configured. The model reads the title back through + contract.mjs, which trims, so a whitespace-only name is no name here too. */ +function rbPageTitle(doc) { + return String(doc.native.info.username ?? '').trim(); +} + function rbExpected(doc, links) { const subUrl = doc.native.info.subscription_url || ''; return { ...doc.expected.model, - title: '', // not in Rebecca's page context - announce: '', // Rebecca has no announcement + title: rbPageTitle(doc), // the header title is not in Rebecca's page context + announce: '', // Rebecca has no announcement subUrl, subClashUrl: subUrl ? `${subUrl}/clash-meta` : '', links, @@ -329,3 +356,224 @@ test('the shipped PasarGuard shell escapes for itself: the body sits in an autoe assert.equal(html.slice(close).includes('{{'), false, `${id}: nothing is interpolated after the block closes`); } }); + +/* --- the subscriber and the service name ------------------------------------ + The page context carries the subscriber (user.username) and the admin's own + columns (user.admin.profile_title, user.admin.support_url). It does NOT carry + the panel-wide subscription settings, so those cannot reach the page at all. + The prelude resolves the page's name in the panel's own order — the admin's + configured profile title, then the subscriber — and refuses a profile title + that still carries one of the panel's format placeholders. Every case below + runs through the SHIPPED prelude on the real Jinja2. */ + +/* The page context a real install builds for one subscriber, with the two + values this section is about supplied explicitly. */ +function pgPage({ username, profileTitle = null, supportUrl = null, withAdmin = true }) { + const doc = PG.find((d) => d.case === '01-active-online'); + const context = pasarguardContext(doc); + context.user.username = username; + context.user.admin = withAdmin + ? { support_url: supportUrl, profile_title: profileTitle } + : null; + const [html] = renderPasarGuard([{ html: SHELL.pasarguard, context }]); + return { html, model: toModel(extractIsland(html)) }; +} + +test('PasarGuard: with no configured profile title the page names the subscriber', () => { + /* The reported defect: the page titled itself with the generic word. */ + const { html, model } = pgPage({ username: 'alice' }); + assert.equal(model.title, 'alice', 'the subscriber reaches the page model'); + assert.ok(html.includes('alice'), 'and names the browser title'); + assert.equal(html.includes('Subscription'), false, 'never the generic word'); +}); + +test('PasarGuard: the admin\'s configured profile title names the page, over the subscriber', () => { + const { html, model } = pgPage({ username: 'alice', profileTitle: 'Premium 100 GB' }); + assert.equal(model.title, 'Premium 100 GB', 'the configured service name wins'); + assert.ok(html.includes('Premium 100 GB'), 'and reaches the page'); +}); + +test('PasarGuard: a profile title carrying a format placeholder is refused, not shown literally', () => { + /* PasarGuard formats these against variables the page cannot resolve + (setup_format_variables). A literal "{DATA_LIMIT}" on the page would be a + worse defect than the subscriber's own name. */ + const titles = ['MyNet {USERNAME}', '{DATA_LIMIT} left', '{EXPIRE_DATE}', '{SERVER_IP}']; + const pages = renderPasarGuard(titles.map((t) => { + const doc = PG.find((d) => d.case === '01-active-online'); + const context = pasarguardContext(doc); + context.user.username = 'alice'; + context.user.admin = { support_url: null, profile_title: t }; + return { html: SHELL.pasarguard, context }; + })); + pages.forEach((html, i) => { + assert.equal(toModel(extractIsland(html)).title, 'alice', `${titles[i]}: falls through to the subscriber`); + assert.equal(html.includes(titles[i]), false, `${titles[i]}: never reaches the page`); + /* The placeholder must not survive in any form: the island's title is the + subscriber, and no rendered attribute carries a brace pair. */ + assert.equal(html.includes('data-sub-title="alice"'), true, `${titles[i]}: the island names the subscriber`); + assert.equal(/data-sub-title="[^"]*\{/.test(html), false, `${titles[i]}: no placeholder in the island title`); + for (const v of ['{USERNAME}', '{DATA_LIMIT}', '{EXPIRE_DATE}', '{SERVER_IP}']) { + assert.equal(html.includes(v), false, `${titles[i]}: ${v} is not on the page`); + } + }); +}); + +test('PasarGuard: a missing subscriber name keeps the generic fallback', () => { + for (const value of [null, undefined, '', ' ']) { + const { html, model } = pgPage({ username: value }); + const label = JSON.stringify(value); + assert.equal(model.title, '', `${label}: no name is invented`); + assert.ok(html.includes('Subscription'), `${label}: the generic fallback stands`); + } +}); + +test('PasarGuard: a subscriber with no admin at all is still named', () => { + const { model } = pgPage({ username: 'alice', withAdmin: false }); + assert.equal(model.title, 'alice'); +}); + +test('PasarGuard: the subscriber name is escaped and never becomes markup', () => { + const { html, model } = pgPage({ username: HOSTILE, profileTitle: HOSTILE }); + assert.equal(model.title, HOSTILE, 'the name arrives whole, as text'); + assert.equal(html.includes(HOSTILE), false, 'and never appears unescaped'); + assert.equal(html.includes('">