Run 34421944328 pihole#1 (README replay, sso off) never left
/admin/login: the native login was refused. The compose template put
the administrator password inline under environment:, and compose
interpolates every $name in it, so the container held the inventory
password with each $name removed (its sha256 prefix matches that
mangled value, not the real one). The environment moves into
templates/env.j2, which has_env wires as env_file, with the password
through dotenv_quote like every other role. Rendering the real template
with a password containing $name, ${...} and $$ and reading it back from a
compose v5.5.1 container returns it unchanged, while an unquoted RAW=S$x
control line in the same file comes back as "S". TZ now comes from the
shared sys-svc-container include, so PIHOLE_TIMEZONE goes.
pihole#0 (onion) timed out on "still handing off": oauth2-proxy serves
its 403 page for a user outside the allowed group on /oauth2/callback,
and the poll checked the callback URL before the page text. The CI
trace holds "You do not have permission to access this resource." in
that page's collapsed More Info block, which textContent reads. The
poll now judges the denial text first, then the Keycloak auth URL, and
only then treats the callback URL as a handoff in progress. The loose
"403" and "forbidden" matches go, since a reached admin page can
contain either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three assertions in these specs passed whether or not the thing they name
happened.
The native login test navigates to `/admin/login` and then asserted that the URL
contains `/admin`, which it already did before anyone typed a password, next to
a check that `body` is visible, which is true the moment the document parses. A
failed login scored green. It now waits for the URL to leave `/login` and for
the password field it just filled to be gone.
The SSO login test asserted `page.url()` once, immediately after a settle whose
result is discarded, so whether it saw the finished redirect was luck. Its two
sibling tests in the same file poll for the Pi-hole base URL and wait out
`/oauth2/callback` first; this one never did. It does now, and it also rejects
being parked on the callback, which nothing checked before.
The denial test counted `/oauth2/callback` and the auth endpoint as evidence of
denial. Both appear mid-handoff on a login that is about to SUCCEED, so an
authorization regression could pass here unnoticed. The check becomes a poll
over three distinct states: still handing off keeps polling, back at the auth
endpoint or a forbidden body is denial, anything else fails and reports where
the browser actually landed. A transient no longer reads as a pass.
The obvious form of that fix — requiring the URL to leave `/oauth2/callback`
before judging — is avoided on purpose: oauth2-proxy may render its 403 on the
callback path itself, and the test would then time out on a correct denial.
Judging the state rather than the path does not care.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Over Tor the redirect chain behind a login/logout/OIDC click routinely exceeds Playwright's fixed 30s action timeout, so the click throws before the flow's scaled waits run. Sweep all role Playwright suites and the shared persona helpers: give every navigation/auth-transition click an onion-scaled timeout (.click({ timeout: resolveTimeout(30_000) })) and route the remaining raw millisecond literals (timeout options, waitForTimeout, sleep-promise setTimeout, positional wait args) through resolveTimeout. Local UI clicks (menus, toggles, modal dismissals, DOM clicks inside evaluate) stay untouched.
Two new lints keep it that way: test_no_raw_timeout_literals forbids raw numeric timeout literals in Playwright specs (opt-out: nocheck: raw-timeout), test_persona_clicks_scaled requires every click in the shared persona auth flows to carry a resolveTimeout timeout (opt-out: nocheck: unscaled-click).
Verified: node --check on all 133 changed JS files, all 13 playwright lints green, make autoformat clean, make test green (2745+196+163+4 tests OK).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New lint test_no_raw_goto.py forbids raw `page.goto(...)` in role playwright specs + persona flows (the gotoOnion wrapper in personas/utils is exempt) so navigation retries transient Tor-transport errors over onion. Migrates every raw call across ~75 roles (343 sites) to `gotoOnion(page, ...)` and teaches gotoOnion to detect relative onion URLs (`isOnionCanonical`). gotoOnion is a behaviour-identical drop-in on clearnet (single attempt, same return). Verified: no-raw-goto lint green, eslint 0 errors, make test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the global TIMEOUT_FACTOR and a shared timeouts.js helper: resolveTimeout(base) scales by TIMEOUT_FACTOR and, over a .onion target, an extra multiplier, so Tor's per-request latency no longer trips hard-coded timeouts. Migrate every raw timeout in the specs (timeout:/timeout=/setTimeout/waitForTimeout) to resolveTimeout, stage the helper in all three staging paths, and add test_no_raw_timeouts to forbid raw numeric timeouts. Clearnet behaviour is unchanged (factor 1: resolveTimeout(N) === N).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HSTS (Strict-Transport-Security) is an HTTPS-only header: a browser ignores it
over plain http (RFC 6797) and OpenResty only emits it on the TLS vhost. On an
onion node TLS is off and every domain is served over http, so the inline
`expect(headers["strict-transport-security"]).toBeTruthy()` in 33 specs failed
with `Received: undefined`.
Route all 33 through the shared expectHstsWhenTls(headers, baseUrl, label)
helper (personas/utils/tls.js), which requires HSTS over https and asserts its
absence over http. On a TLS node the base URL is https, so the assertion is
identical to before; on an onion node the same spec now passes. HSTS logic
lives in one place (SPOT) instead of 34 inline copies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- native.spec.js: update button selector from /sign in|login/i to
/log in|sign in/i — Pi-hole v6's submit button text is
'Log in (uses cookie)', not 'Sign in' or 'Login'
- 01_setup.yml: remove pihole setpassword CLI task — Pi-hole v6 blocks
this command when FTLCONF_webserver_api_password is set via env var
('set by environment variable; please unset it to use this function').
Password is now set at container startup via the env var, which is
the v6-idiomatic approach.
Three fixes:
1. guest.spec.js: skip instead of fail when OIDC_ISSUER_URL is unset
(variant-1 has SSO disabled so no Keycloak redirect is expected;
the old expect().toBeTruthy() caused a hard failure instead of skip)
2. playwright.env.j2: only emit OIDC_ISSUER_URL when SSO is proxy-gated
(was unconditionally set, causing native.spec.js to wrongly skip in
variant-1 and oauth2.spec.js/guest.spec.js to fail on missing redirect)
3. compose.yml.j2: Pi-hole v6 compatibility fixes
- Replace WEBPASSWORD with FTLCONF_webserver_api_password (v6 dropped
legacy env vars; WEBPASSWORD is silently ignored in v6)
- Add FTLCONF_webserver_domain={{ domain }} to whitelist the nginx
proxy Host header; Pi-hole v6 FTL returns 403 for unrecognised Host
headers (DNS rebinding protection) — this was causing the baseline
playwright.spec.js to fail with HTTP 403 in variant-1
Playwright's default testMatch only discovers files matching
*.spec.js / *.test.js. test-guest.js, test-oauth2.js, and
test-native.js never matched this pattern and were silently never
executed in any CI variant -- only playwright.spec.js's 2 baseline
tests ever ran (confirmed: 0 occurrences of these files in any
playwright-junit.xml across variant-0/variant-1, sync/async).
Renamed to guest.spec.js, oauth2.spec.js, native.spec.js so the SSO
persona scenarios (Keycloak redirect, admin/biber login, native login)
actually execute and validate the sso-proxy CA trust fix.
- playwright.spec.js: remove duplicated SSO/persona scenarios now
covered by test-guest.js/test-oauth2.js/test-native.js. Reduce to
baseline checks (front page + TLS/HSTS, HTML content-type), matching
the pgadmin pattern.
- meta/main.yml: rewrite description to describe Pi-hole the software
(network-wide DNS sinkhole, DNS/DHCP management, web dashboard)
rather than the role/integration details.
- Update logout test to navigate directly to oauth2/sign_out endpoint with Keycloak logout redirect
- Remove duplicate oauth2-proxy service definition from compose.yml.j2 (was causing duplicate key error)
- Add TEST_E2E_PLAYWRIGHT_WAIT_ENABLED: false to vars/main.yml (oauth2-proxy returns 403 before redirect)
- Deploy Pi-hole as web app with NGINX proxy and OAuth2 protection
- Only web-app-pihole-administrators group can access the dashboard
- DNS port 53 (TCP/UDP) exposed for network-wide ad blocking
- Upstream DNS configurable per inventory (default: Cloudflare 1.1.1.1)
- Redis session store for OAuth2 proxy
- Timezone from HOST_TIMEZONE platform variable
- Port bindings use DOCKER_BIND_HOST platform variable
- Register subnet 192.168.102.240/28 in networks
- Register ports 8070 (http) and 16493 (oauth2) in ports
- Playwright tests: oauth2 protection, admin login, biber denied (3/3 passing)
Known limitations:
- Pi-hole v6 gravity blocklist requires manual setup via admin UI
- Internal *.infinito.example DNS forwarding to CoreDNS planned as follow-up