unsloth/studio/backend/tests/test_cloudflare_tunnel.py
Leo Borcherding 91a0df9514
Studio: make the Cloudflare tunnel opt-in (off by default) (#7046)
* Studio: make the Cloudflare tunnel opt-in (off by default)

A wildcard bind (`-H 0.0.0.0`) auto-started a public trycloudflare.com
tunnel, so exposing Studio on the LAN also published it to the public
internet. Flip the default so the tunnel is opt-in.

- `--cloudflare` is now tri-state (Optional[bool], default None = off),
  mirroring the existing --enable-tools/--disable-tools handling. Pass
  --cloudflare to expose a public HTTPS link for a wildcard bind; --secure
  still implies the tunnel.
- --secure + --no-cloudflare is still rejected as a contradiction.
- Update the parent-command guard, re-exec forwarding, startup-banner
  wording, the colab comment, README, and tests.

* Studio: update installer/setup launch hints for opt-in Cloudflare

The post-install launch hints only mentioned --secure for a public link.
Now that the tunnel is opt-in, clarify that -H 0.0.0.0 exposes the raw
port on the LAN (not a public URL), and surface --cloudflare as the
explicit opt-in for a public HTTPS link (--secure keeps the raw port
private). Applied to install.ps1, install.sh, and studio/setup.sh.

* Studio: address review - keep cloudflare tri-state + harden run re-exec

Two review points from the bots:

- Gemini: keep `cloudflare` as Optional[bool] in run_server instead of
  casting None -> False, so the startup banner can distinguish "OFF (default)"
  (unset) from "OFF (--no-cloudflare)" (explicit). `_cloudflare_flag` and the
  banner branch now carry the tri-state.
- Codex (P1): `unsloth studio run` re-execs the studio venv's console script,
  which can be an older build whose --cloudflare defaulted on; omitting the
  flag let it re-enable the tunnel. That path now forwards the default polarity
  explicitly (--no-cloudflare, or nothing under --secure since --secure implies
  the tunnel). The plain `unsloth studio` path runs the same-version in-tree
  run.py (resolved via _find_run_py), so it keeps forwarding only an explicit
  polarity and still shows the accurate "(default)" banner.

Tests updated for the tri-state banner labels, the None gate cases, and the
new re-exec forwarding.

* Studio: forward --no-cloudflare on plain re-exec too (mixed install)

Codex follow-up: _find_run_py falls back to STUDIO_HOME/.../studio/backend/
run.py when the package copy is absent, so the plain `unsloth studio` re-exec
can land on an older run.py whose --cloudflare defaults on. Forward the default
polarity explicitly there too (--no-cloudflare, or nothing under --secure),
matching the run subcommand. The common in-venv launch skips the re-exec and
still shows the tri-state "(default)" banner.

* Studio: fix launch hint - --cloudflare needs the wildcard bind

Codex P3: the launch hint listed --cloudflare next to the loopback
`unsloth studio -p 8888` command, but the tunnel only starts for wildcard
binds, so `--cloudflare` alone on 127.0.0.1 does nothing. Show
`-H 0.0.0.0 --cloudflare` in the hints (install.ps1, install.sh,
studio/setup.sh) and clarify the same in the README.

* Studio: cross-platform masked terminal password prompt helper

Per-keystroke '*' echo (POSIX termios cbreak / Windows msvcrt.getwch),
backspace editing, Ctrl-C abort, EOF handling, confirmation loop with
re-prompt on mismatch or policy failure. Pure should_prompt gate for the
--secure/--cloudflare exposure paths.

* Studio CLI: force a terminal password change before public tunnel exposure

When a launch will start the Cloudflare tunnel (--secure, or --cloudflare on
a non-api-only wildcard bind) and the admin account still has its seeded
bootstrap password, prompt for a new password in the terminal (masked with
'*', confirmed, re-prompting until valid) before any re-exec or server
exists. The change is committed in the parent so it never crosses argv or
the environment and older studio-venv children see it immediately. Without
a terminal, warn and fall back to the backend bootstrap shutdown timer.
Mirrors backend update_password semantics in one transaction: rehash,
rotate the JWT secret, clear must_change_password, revoke refresh tokens,
drop the desktop secret, then remove the stale credential files.

* Studio: terminal password gate before the public tunnel (backend backstop)

Never publish a trycloudflare URL while the seeded admin password is
active: run_server now runs a terminal password-change gate after the
tunnel decision and strictly before start_studio_tunnel. Interactive
refusal fails closed (shutdown + exit 1, mirroring the secure gate);
without a tty it warns and keeps the bootstrap deadline. Success applies
the same effects as the change-password route (update_password +
revoke_user_refresh_tokens) and drops the stale
app.state.bootstrap_password. MIN_PASSWORD_LENGTH centralised in
auth/storage.py and referenced by the HTTP schema. terminal_prompt.py
carries the pure gate helper (interactive loop stubbed; supplied by the
masked-input module). Also migrates the studio/setup.ps1 launch footer
that still showed the bare wildcard hint.

* README: reconcile remote-access section with opt-in Cloudflare tunnel

* Studio: harden the terminal password gate after review

- run.py: run the gate BEFORE the uvicorn socket binds. On a wildcard
  --cloudflare launch the served HTML injects the bootstrap credential
  for first login, so a pre-gate listener would hand the default
  password to anyone who reaches the raw port while the operator is
  still typing. The gate now also seeds the admin row itself (it can
  run before lifespan startup).
- Headless launches that nothing would protect now fail closed: the
  bootstrap deadline never arms for api-only serving and
  UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0 disables it, so warn-and-proceed
  would have promised a shutdown that never comes. Both the CLI and the
  backend refuse to publish in that case; the ordinary headless path
  still warns and relies on the 1h deadline, and no longer auto-fills
  the default credential into HTML served on a public URL.
- storage.update_password gains revoke_refresh_tokens to delete the
  user's refresh tokens in the SAME transaction as the password commit;
  the change-password route and the backend gate use it (a separable
  follow-up delete could fail after the commit and leave a stale
  refresh token able to mint access tokens under the rotated secret).
- clear_bootstrap_password is best-effort: a locked/undeletable file
  must not surface as a failed password change.
- CLI masked reader: disable ISIG like the backend so Ctrl-Z cannot
  suspend the process with the shared terminal stuck in no-echo mode;
  handle Ctrl-C/Ctrl-Z as characters; treat stream EOF mid-line as an
  abort instead of submitting a partial password. Both readers restore
  terminal attrs from a SIGTERM/SIGHUP handler since a finally block
  cannot run when a default-disposition signal terminates the process.
- Backend reader: decode byte-at-a-time through an incremental UTF-8
  decoder so multi-byte characters split across read boundaries are no
  longer dropped; isatty checks tolerate closed/None streams.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: persist bootstrap suppression through lifespan startup

The pre-bind password gate nulled app.state.bootstrap_password, but the
FastAPI lifespan runs after it and re-reads the bootstrap password into
app.state on both admin paths, so a headless public launch could still
serve the injected credential in HTML. Carry a persistent
suppress_bootstrap_injection flag that the lifespan honors instead.

Also drop the quoted Tuple annotation on _terminal_password_gate that
tripped the import-hoist lint (the typing import looked unused).

* Studio CLI: keep the pre-exec auth DB private (0700 dir, 0600 db)

On a fresh install the pre-exposure password gate creates auth/ and
auth.db through the CLI before the backend ever runs, and
sqlite3.connect leaves the DB 0644 under a 022 umask. Mirror backend
storage.get_connection's chmod so the committed password hash and JWT
secret are never world-readable, even if the launch aborts before the
backend applies its own modes.

* Tighten pre-exposure password gate comments

* Studio: delete seeded bootstrap password before headless public re-exec

The headless warn-and-proceed path returns with the default admin
password still active, then re-execs a child Studio process. An old
studio-venv child (mixed-version install) predates the pre-bind gate and
its injection-suppress flag, so its lifespan reads .bootstrap_password
and injects the seeded credential into the public HTML for up to the
bootstrap deadline. A CLI-flag handshake cannot fix this uniformly: the
studio run path uses ignore_unknown_options and an old in-venv child
runs in-process, so it would never reject the flag.

Delete the seeded .bootstrap_password file in the parent before re-exec
so a fresh child of any version reads None and never serves it. This
covers both re-exec paths and both child versions. must_change_password
stays set, so the login page still forces a change and the bootstrap
shutdown timer still arms; only the plaintext-on-disk copy is removed.
Recovery is via a terminal-attached run or reset-password. Backend gate
and CLI warnings updated to match.

* Studio: commit the seeded admin before headless public re-exec

The headless-warn path deletes the seeded .bootstrap_password so a
re-exec'd child cannot inject it, but _ensure_cli_default_admin's INSERT
was never committed and rolled back on conn.close(). On a fresh
STUDIO_HOME an old studio-venv child then found no admin, regenerated a
fresh bootstrap password + file, and injected THAT into the public page,
defeating the deletion.

Commit the seeded admin right after _ensure_cli_default_admin so any
re-exec'd child sees the existing account and does not regenerate.
Regression tests cover both re-exec paths on a fresh (unseeded) DB.

* Studio: fail closed when the bootstrap password file cannot be removed

On the headless public path, deleting .bootstrap_password is the
protection against an old re-exec'd child injecting the seeded
credential. If unlink fails (locked file, read-only auth dir) the file
is still on disk, so warning and proceeding would still leak it for the
bootstrap-timeout window. Abort with a clear error instead. Regression
test covers the unlink-failure fail-closed path.

* Studio: hold no-echo for the whole password line, not per keystroke

The POSIX masked reader set cbreak/no-echo inside _getch_posix and restored
the terminal to echo-on in a finally after every single keystroke, because
_read_password calls _getch once per character. Between one char returning and
the next call re-entering cbreak, ECHO was on, so a keystroke arriving in that
window echoed the password in cleartext.

Move the terminal mode into a _prompt_raw_mode context that _read_password
holds around the entire line (mirroring unsloth_cli/commands/_password_prompt.py,
which already did this), restoring once when the line completes or aborts.
_getch_posix now only reads, since the mode is held by the caller. The context
is a no-op when stdin is not a real terminal, keeping the _getch test seam.

Add a regression test asserting the raw-mode context wraps the read exactly
once and every keystroke is read while it is active.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: strip the seeded bootstrap password when the auth DB check fails

The pre-exposure gate returned early on two auth-DB inspection failures and
proceeded to re-exec without removing the seeded .bootstrap_password:

- _connect_auth_db() failure: a seeded credential from a prior run may still
  be on disk.
- the must_change_password read-back failure: worse, _ensure_cli_default_admin
  had already seeded the admin and the code committed it (writing
  .bootstrap_password) right before the failing SELECT.

In the mixed-version case (a new outer CLI re-execing an old studio-venv child
that predates the pre-bind gate), that child would read the file back and
inject the default admin credential into the public Cloudflare page. The
sibling headless branch already deletes the file for exactly this reason, so
these returns were an inconsistent gap.

Factor the delete-or-fail-closed logic into
_strip_seeded_bootstrap_password_or_exit and call it on both inspection
failures (and reuse it in the headless branch): strip the seeded file first
(version-independent protection), failing closed if the removal itself fails.
must_change_password stays set, so the login page still forces a change and the
bootstrap shutdown timer still arms.

Add tests for both new paths (connect failure and post-commit read-back
failure strip the file and proceed; a failed strip fails closed).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fail closed when the seeded admin cannot be committed before exposure

The pre-exposure gate wrapped _ensure_cli_default_admin (the INSERT), its
conn.commit(), and the must_change_password read-back in one try, and the
except recovered by stripping .bootstrap_password and proceeding to re-exec on
the assumption the admin was already committed. That assumption only holds when
the failing statement is the SELECT. When the INSERT or the commit itself fails
(e.g. a write lock held past the busy timeout on a fresh install), no admin row
is committed: it rolls back on conn.close(), and a re-exec'd old studio-venv
child (no pre-bind gate) then finds no admin, regenerates a fresh bootstrap
password + file, and serves that default credential on the public Cloudflare
page. Stripping the file cannot stop a regeneration.

Split the seed+commit into its own try that fails closed (refuse the public
launch, best-effort removing any half-written seed file) since we cannot prove a
committed admin; keep the separate read-back failure on the strip-and-proceed
path, where the admin is committed so an old child finds it and will not
regenerate. Add a test for the seed-commit-failure path.

* Studio: decode the CLI masked password reader with errors="replace"

The CLI reader read keystrokes with text-mode sys.stdin.read(1), which raises
UnicodeDecodeError on a pasted non-UTF-8 password (e.g. Latin-1 bytes), or under
PYTHONUTF8 yields a lone surrogate that later crashes the pbkdf2 encode -- either
aborts the launch with a traceback. The backend mirror (terminal_prompt.py)
already reads raw bytes through an incremental decoder with errors="replace".
Mirror that here: read with os.read and an incremental decoder so invalid bytes
map to U+FFFD, iterating over each emitted char (one byte can complete a
replacement plus the next char).

* Studio: resolve the child launcher before the pre-exposure gate

The gate strips the seeded .bootstrap_password on a headless public launch, and
it ran before the re-exec launchability check (studio venv / run.py / console
script present). So a headless launch with an incomplete studio setup would seed
the admin, delete the bootstrap password, then abort because the child could not
be found, leaving the admin at must_change_password=1 with no password ever
shown or injectable: locked out until `unsloth studio reset-password`.

Resolve and validate the child launcher first, in both `studio` (studio_default)
and `studio run`, and only then run the gate, so an unlaunchable setup exits
before anything is stripped. Add a regression test that a missing venv exits
without removing the seeded file.

* Studio: fail closed when the auth DB cannot be opened before exposure

The connect-failure branch of the pre-exposure gate stripped .bootstrap_password
and proceeded, on the assumption a committed admin from a prior run made an old
child find it and not regenerate. But on a fresh public launch whose
_connect_auth_db() itself fails (transient lock during the schema/seed step, or
an unwritable home), no admin is committed, so a mixed-version re-exec child that
predates the backend gate can find no user, generate a fresh bootstrap password,
and serve it on the public Cloudflare page. Stripping a file we cannot vouch for
cannot stop a regeneration.

Make this branch fail closed like the seed/commit failure path: we only continue
past the DB inspection once a committed admin is confirmed. The existing file is
left untouched so a retry (after a transient lock clears) can still prompt.

Update the connect-failure test to assert fail-closed, and give the in-venv
--secure flag test a real STUDIO_HOME with an already-changed admin so the gate
is a no-op rather than relying on a DB-open failure.

* Studio: invalidate seeded bootstrap files before deleting auth.db on reset

reset-password deleted auth.db first, then best-effort unlinked the seeded
.bootstrap_password and desktop secret. unlink() only ignores
FileNotFoundError, so a locked or read-only file (Windows AV, read-only auth
dir) survived while auth.db was gone. The next server start then re-seeded
from that stale plaintext and re-validated the exact credential the reset was
meant to revoke.

Invalidate the credential files first, truncating any that cannot be
unlinked, then delete the DB, so a surviving file can never carry a reusable
secret. clear_bootstrap_password now truncates on unlink failure for the same
reason, and its warning says the contents were cleared rather than claiming
the stale password is already invalid.

* Studio: require a servable frontend before the pre-exposure gate can strip the seeded password

A headless public launch strips the seeded .bootstrap_password before the
re-exec'd child starts. If the child then cannot serve the login page (the only
in-band way to change the seeded password) the admin is locked out
(must_change_password=1, no file, no UI) until reset-password.

Add _require_servable_frontend_or_exit and call it before the gate on both
`unsloth studio` and `unsloth studio run` public launches: fail closed if a
non-api-only public launch has no built frontend dist, before anything is
stripped. A user-supplied --frontend is validated to contain index.html so a
bad path cannot silently bypass the check; an auto-resolved dist is trusted
(_find_frontend_dist already requires index.html) and forwarded to the child.

Model-load aborts on `studio run` remain a residual: the parent must strip for
mixed-version safety (an old studio-venv child has no pre-bind gate) and model
loadability cannot be proven before exec, so that path stays recoverable via
reset-password.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: harden reset-password ordering and validate the in-venv backend before the strip

Three follow-ups to the pre-exposure hardening:

reset-password now deletes auth.db FIRST and proves it is gone before touching
the seeded credential files. If the DB cannot be removed (a running Studio or
Windows holds it open, or a read-only auth dir) it aborts with the credential
files untouched, so a forgotten-password reset is not left half-done with the
recovery credentials deleted while an un-resettable must_change_password=1 DB
survives. After the DB is gone it invalidates the stale credential files
(unlink, else truncate) and fails closed if a file can be neither removed nor
truncated, since a surviving plaintext would be re-seeded by
generate_bootstrap_password() and re-validate the revoked password.

The in-venv (in-process) launch path had no analogue of the re-exec launcher
check: a headless public launch would seed the admin and strip the seeded
.bootstrap_password in the gate before _load_run_module() later failed on a
broken/partial venv, leaving must_change_password=1 with no password to log in.
Add _validate_inproc_backend_before_strip, called on the in-venv path (both
`unsloth studio` and `unsloth studio run`) before the gate on the headless
public path, so a broken backend fails cleanly before anything is stripped. It
is scoped to the headless path so an interactive prompt is not delayed behind a
full backend import.

* Studio: validate the frontend and tunnel before the strip on every public path

Five follow-ups closing the remaining pre-exposure-strip lockouts:

The in-venv (in-process) paths of both `unsloth studio` and `unsloth studio
run` validated the backend but not the frontend before the gate, so a headless
public launch with a missing/bad dist would strip the seeded .bootstrap_password
and then abort in run_server() during frontend setup, leaving
must_change_password=1 with no login page. Both now validate a servable frontend
before the strip (cheap check first, backend import after) and serve the
resolved dist in-process.

The `studio run` re-exec discarded the dist that satisfied the pre-strip check
and only forwarded a user-supplied --frontend. In a shadowed install where the
parent finds a built dist the child cannot, it stripped and exec'd without the
path, and the child aborted during frontend setup. It now forwards the resolved
dist, matching `unsloth studio`.

On a headless --secure launch the bind is loopback, so the Cloudflare tunnel is
the only public exposure. If cloudflared is provably unavailable (found nowhere
and undownloadable) the tunnel cannot start, so stripping the recovery
credential would just lock the user out with no public URL ever served. Add
_tunnel_binary_confirmed_unavailable and, on --secure only, refuse the launch
with the credential preserved rather than strip. Wildcard --cloudflare binds
0.0.0.0 publicly regardless of the tunnel, so it still strips; any uncertainty
(helper not loadable) also still strips, since a possible credential leak
outweighs a recoverable lockout.

clear_bootstrap_password no longer claims it cleared the file's contents when
both unlink and truncate failed; it now reports the stale password is still on
disk and asks the user to remove it manually.

* Studio: fix cloudflared probe path and skip the bootstrap strip for a self-suppressing child

Two follow-ups to the --secure pre-exposure hardening:

The cloudflared availability probe loaded cloudflare_tunnel by file path but not
its backend deps: ensure_cloudflared() -> _cache_path() lazily imports
utils.paths.storage_roots, which only resolves when studio/backend is on
sys.path. From the outer CLI it is not, so the probe saw ensure_cloudflared()
return None (cache unresolvable) and wrongly treated the tunnel as unavailable,
refusing --secure even when cloudflared was cached or downloadable. Add the
backend dir to sys.path for the probe (and remove it after) so the cache path
resolves as it will in the child.

A headless --secure launch stripped the seeded .bootstrap_password before the
child proved the tunnel could actually connect, so a cloudflared that is present
but cannot establish the tunnel (blocked connectivity, Cloudflare outage) left
must_change_password=1 with no recovery credential. But the strip is only needed
when the re-exec'd child is an OLD studio-venv backend with no pre-bind
suppression: this install's own run.py sets app.state.suppress_bootstrap_injection
before binding and never serves the seeded credential publicly. Add
_child_self_suppresses (true in-process, or when the re-exec target is this
install's own run.py by path identity) and skip the strip in that case, keeping
.bootstrap_password as a local recovery credential; the strip stays fully in
force for the studio-venv console-script path and any venv-fallback run.py, where
an old child is actually possible.

* Studio: reword the pre-exposure terminal password prompt

* Studio: warn when -H is overridden by --secure; align pre-exposure prompt wording

- --secure/--secure run: emit a Note (not an error) when -H is a non-loopback
  host, since --secure forces the loopback bind and would otherwise discard -H
  silently.
- Reword the pre-exposure terminal prompt to 'exposed on the public internet'
  in both the backend gate and the CLI mirror.
- Align the CLI success line with the backend ("Password updated for '<user>'.").
- Tests for the new -H warning (present when overridden, absent on loopback).

* Studio: add non-interactive --password to set the initial admin password

Headless hosts (CI, containers, systemd units) have no TTY, so the forced
first-exposure password change could not be completed unattended. Add a
non-interactive way to set the INITIAL admin password before the server binds:

- --password <value>, the UNSLOTH_STUDIO_PASSWORD env var, or --password -
  (read one line from stdin). Off by default; unset falls back to the normal
  interactive terminal prompt / browser setup.
- Applies on any launch (public --secure/--cloudflare or a headless -H 0.0.0.0
  bind), only when the account still has its seeded bootstrap password. An
  already-set password is a hard error, never an override; an invalid value
  (too short, or equal to the bootstrap) fails closed before bind.
- The CLI applies the change in the parent, never forwards --password to the
  re-exec child, and strips UNSLOTH_STUDIO_PASSWORD from the child env so the
  secret never crosses to the child. run.py does the same on the direct path and
  strips the env var so spawned subprocesses (cloudflared, llama-server, tools)
  cannot inherit it.

Mirrors resolve_supplied_password across the CLI and backend, documents the
option in the README (including the argv-visibility caveat), and covers all
flows (env/stdin/literal, fail-closed cases, no-forward, env-strip,
reset-password roundtrip) in the CLI, backend, and unit suites.

* Studio: truncate the stale bootstrap file when unlink fails on a CLI password change

The post-change cleanup in _cli_update_password only warned when
.bootstrap_password could not be unlinked but was still writable (locked file,
read-only auth dir), leaving the old plaintext on disk. If auth.db is later
reset or removed, generate_bootstrap_password() reads that file back and
re-validates the revoked bootstrap password. Truncate the file on unlink
failure so its stale plaintext cannot be re-seeded, mirroring the backend
clear_bootstrap_password(); the password change is already committed, so this
never rolls it back. The warning now states truthfully whether the contents
were cleared or the file must be removed manually.

* Studio: tighten comments

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-15 06:13:25 -07:00

948 lines
32 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the Cloudflare quick-tunnel helper and run.py wiring.
cloudflare_tunnel.py is stdlib-only (storage_roots is imported lazily), so it
loads via spec_from_file_location without the studio venv. run.py defaults are
checked by AST so we never import its heavy deps (uvicorn/structlog).
"""
import ast
import importlib.util
import io
import os
import sys
import tarfile
import types
from pathlib import Path
import pytest
_BACKEND = Path(__file__).resolve().parent.parent
_CT_PY = _BACKEND / "cloudflare_tunnel.py"
_RUN_PY = _BACKEND / "run.py"
def _load_ct():
spec = importlib.util.spec_from_file_location("cloudflare_tunnel", _CT_PY)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
ct = _load_ct()
# ── URL parsing ──────────────────────────────────────────────────────
def test_url_regex_extracts_and_ignores_noise():
blob = (
"2026-06-11T10:00:00Z INF Thank you for trying Cloudflare Tunnel.\n"
"2026-06-11T10:00:01Z INF Requesting new quick Tunnel on trycloudflare.com...\n"
"2026-06-11T10:00:01Z INF | https://setting-democracy-gathering.trycloudflare.com |\n"
"2026-06-11T10:00:02Z INF Registered tunnel connection https://not-the-url.example.com\n"
)
m = ct._URL_RE.search(blob)
assert m is not None
assert m.group(0) == "https://setting-democracy-gathering.trycloudflare.com"
def test_url_regex_no_match_on_unrelated():
assert ct._URL_RE.search("INF connecting to https://api.cloudflare.com/v4") is None
def test_url_regex_ignores_api_endpoint():
# cloudflared's failure line names its own API host; it must never be taken
# as the tunnel URL (it returns a 404 and is not a quick tunnel).
line = (
'failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel": '
"context deadline exceeded"
)
assert ct._URL_RE.search(line) is None
def test_url_regex_skips_api_host_but_matches_real_url():
blob = (
'ERR failed to request quick Tunnel: Post "https://api.trycloudflare.com/tunnel"\n'
"INF | https://brave-mountain-river-clouds.trycloudflare.com |\n"
)
m = ct._URL_RE.search(blob)
assert m is not None
assert m.group(0) == "https://brave-mountain-river-clouds.trycloudflare.com"
# ── asset mapping ────────────────────────────────────────────────────
@pytest.mark.parametrize(
"system,machine,expected",
[
("Linux", "x86_64", ("cloudflared-linux-amd64", False)),
("Linux", "aarch64", ("cloudflared-linux-arm64", False)),
("Darwin", "arm64", ("cloudflared-darwin-arm64.tgz", True)),
("Darwin", "x86_64", ("cloudflared-darwin-amd64.tgz", True)),
("Windows", "AMD64", ("cloudflared-windows-amd64.exe", False)),
("Windows", "x86", ("cloudflared-windows-386.exe", False)),
("Linux", "mips", None),
("Plan9", "x86_64", None),
],
)
def test_asset_name(monkeypatch, system, machine, expected):
monkeypatch.setattr(ct.platform, "system", lambda: system)
monkeypatch.setattr(ct.platform, "machine", lambda: machine)
assert ct._asset_name() == expected
# ── binary discovery ─────────────────────────────────────────────────
def test_find_cloudflared_prefers_path(monkeypatch):
monkeypatch.setattr(ct.shutil, "which", lambda name: "/usr/local/bin/cloudflared")
assert ct.find_cloudflared() == "/usr/local/bin/cloudflared"
def test_find_cloudflared_falls_back_to_cache(monkeypatch, tmp_path):
cached = tmp_path / "cloudflared"
cached.write_text("#!/bin/sh\n")
cached.chmod(0o755)
monkeypatch.setattr(ct.shutil, "which", lambda name: None)
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
assert ct.find_cloudflared() == str(cached)
def test_find_cloudflared_none_when_missing(monkeypatch, tmp_path):
monkeypatch.setattr(ct.shutil, "which", lambda name: None)
monkeypatch.setattr(ct, "_cache_path", lambda: tmp_path / "absent")
assert ct.find_cloudflared() is None
# ── ensure / download ────────────────────────────────────────────────
def test_ensure_downloads_and_chmods_when_missing(monkeypatch, tmp_path):
cached = tmp_path / "cloudflared"
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-linux-amd64", False))
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
def fake_download(url, dest):
assert url.endswith("/cloudflared-linux-amd64")
dest.write_bytes(b"ELF-ish")
return True
monkeypatch.setattr(ct, "_download", fake_download)
monkeypatch.setattr(ct.sys, "platform", "linux")
path = ct.ensure_cloudflared()
assert path == str(cached)
assert cached.exists()
# Host OS, not monkeypatched ct.sys.platform.
if os.name != "nt":
assert cached.stat().st_mode & 0o111
def test_ensure_returns_none_on_download_failure(monkeypatch, tmp_path):
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-linux-amd64", False))
monkeypatch.setattr(ct, "_cache_path", lambda: tmp_path / "cloudflared")
monkeypatch.setattr(ct, "_download", lambda url, dest: False)
assert ct.ensure_cloudflared() is None
def test_ensure_returns_none_for_unsupported_arch(monkeypatch, tmp_path):
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
monkeypatch.setattr(ct, "_asset_name", lambda: None)
monkeypatch.setattr(ct, "_cache_path", lambda: tmp_path / "cloudflared")
assert ct.ensure_cloudflared() is None
def test_download_sets_user_agent(monkeypatch, tmp_path):
import urllib.request
captured = {}
class _Resp:
_sent = False
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self, n = -1):
if self._sent:
return b""
self._sent = True
return b"payload"
def fake_urlopen(req, timeout = None):
captured["ua"] = req.get_header("User-agent")
return _Resp()
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
dest = tmp_path / "cloudflared"
assert ct._download("https://github.com/cloudflare/cloudflared/x", dest) is True
assert captured["ua"] == "unsloth-studio" # GitHub CDN 403s the default UA
assert dest.read_bytes() == b"payload"
# ── cross-platform: Windows (.exe), macOS (.tgz) ─────────────────────
def test_cache_path_uses_exe_on_windows(monkeypatch, tmp_path):
import types
fake_sr = types.ModuleType("utils.paths.storage_roots")
fake_sr.studio_bin_root = lambda: tmp_path
monkeypatch.setitem(sys.modules, "utils.paths.storage_roots", fake_sr)
monkeypatch.setattr(ct.sys, "platform", "win32")
assert ct._cache_path() == tmp_path / "cloudflared.exe"
def test_ensure_windows_downloads_exe(monkeypatch, tmp_path):
cached = tmp_path / "cloudflared.exe"
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-windows-amd64.exe", False))
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
monkeypatch.setattr(ct.sys, "platform", "win32")
def fake_download(url, dest):
assert url.endswith("/cloudflared-windows-amd64.exe")
dest.write_bytes(b"MZ") # PE header magic
return True
monkeypatch.setattr(ct, "_download", fake_download)
# chmod is skipped on Windows; would raise on a path that does not exist yet.
monkeypatch.setattr(ct.os, "chmod", lambda *a, **k: pytest.fail("chmod called on win32"))
assert ct.ensure_cloudflared() == str(cached)
assert cached.read_bytes() == b"MZ"
def test_ensure_macos_extracts_tgz_and_chmods(monkeypatch, tmp_path):
cached = tmp_path / "cloudflared"
monkeypatch.setattr(ct, "find_cloudflared", lambda: None)
monkeypatch.setattr(ct, "_asset_name", lambda: ("cloudflared-darwin-arm64.tgz", True))
monkeypatch.setattr(ct, "_cache_path", lambda: cached)
monkeypatch.setattr(ct.sys, "platform", "darwin")
def fake_download(url, dest):
# dest is cached.with_suffix(".tgz"); write a real archive there.
assert url.endswith("/cloudflared-darwin-arm64.tgz")
with tarfile.open(dest, "w:gz") as tar:
data = b"mach-o"
info = tarfile.TarInfo(name = "cloudflared")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return True
monkeypatch.setattr(ct, "_download", fake_download)
path = ct.ensure_cloudflared()
assert path == str(cached)
assert cached.read_bytes() == b"mach-o"
if os.name != "nt":
assert cached.stat().st_mode & 0o111
assert not cached.with_suffix(".tgz").exists() # temp archive cleaned up
# ── .tgz extraction (darwin) ─────────────────────────────────────────
def _make_tgz(
tmp_path,
member_name,
data = b"bin",
):
tgz = tmp_path / "cf.tgz"
with tarfile.open(tgz, "w:gz") as tar:
info = tarfile.TarInfo(name = member_name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return tgz
def test_tgz_extraction_extracts_clean_member(tmp_path):
tgz = _make_tgz(tmp_path, "cloudflared")
dest = tmp_path / "out"
assert ct._extract_tgz_member(tgz, dest) is True
assert dest.read_bytes() == b"bin"
def test_tgz_extraction_rejects_traversal(tmp_path):
tgz = _make_tgz(tmp_path, "../cloudflared")
dest = tmp_path / "out"
assert ct._extract_tgz_member(tgz, dest) is False
assert not dest.exists()
def test_tgz_extraction_missing_member(tmp_path):
tgz = _make_tgz(tmp_path, "README")
dest = tmp_path / "out"
assert ct._extract_tgz_member(tgz, dest) is False
# ── tunnel lifecycle ─────────────────────────────────────────────────
class _FakePopen:
def __init__(self):
self.terminated = False
self.killed = False
self._alive = True
def poll(self):
return None if self._alive else 0
def terminate(self):
self.terminated = True
self._alive = False
def wait(self, timeout = None):
if self._alive:
raise ct.subprocess.TimeoutExpired(cmd = "cloudflared", timeout = timeout)
return 0
def kill(self):
self.killed = True
self._alive = False
def test_stop_terminates_process():
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
fake = _FakePopen()
t._proc = fake
t.stop()
assert fake.terminated is True
assert t._proc is None
# second stop is a no-op (idempotent)
t.stop()
def test_start_after_stop_does_not_spawn(monkeypatch):
# If stop() lands before start() (a concurrent shutdown in the caller's
# register->start window), start() must NOT spawn a cloudflared process --
# nobody would own it and it would be orphaned.
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
spawned = []
class _FakeProc:
stdout = None
def poll(self):
return 0
monkeypatch.setattr(ct.subprocess, "Popen", lambda *a, **k: (spawned.append(a), _FakeProc())[1])
t.stop() # proc is None -> no-op terminate, but marks the tunnel stopped
t.start() # must short-circuit before Popen
assert spawned == []
assert t._proc is None
def test_wait_for_ready_times_out_without_blocking():
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
assert t.wait_for_ready(timeout = 0.05) is None
def _fake_proc(text):
return types.SimpleNamespace(stdout = io.StringIO(text))
def test_reader_captures_url_and_registration():
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
t._reader(
_fake_proc(
"INF Requesting new quick Tunnel on trycloudflare.com...\n"
"INF | https://words-here-abc.trycloudflare.com |\n"
"INF Registered tunnel connection connIndex=0 protocol=http2\n"
)
)
assert t.url == "https://words-here-abc.trycloudflare.com"
assert t.ready is True
assert t.wait_for_ready(0) == t.url
assert t.error is None # a fully-registered tunnel records no error
def test_reader_url_without_registration_is_not_ready():
# A URL but no "Registered tunnel connection" (e.g. quic control stream
# fails) must not be advertised -- it returns Cloudflare error 1033.
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
t._reader(
_fake_proc(
"INF | https://words-here-abc.trycloudflare.com |\n"
'ERR failed to serve tunnel connection error="control stream failure"\n'
)
)
assert t.url == "https://words-here-abc.trycloudflare.com"
assert t.ready is False
assert t.wait_for_ready(0) is None
assert t.error == "cloudflared exited before the tunnel connection registered"
def test_reader_handles_none_stdout():
# Popen.stdout can be None; _reader must not crash and must leave the tunnel
# un-ready so wait_for_ready returns None.
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
t._reader(types.SimpleNamespace(stdout = None))
assert t.url is None
assert t.ready is False
assert t.wait_for_ready(0) is None
assert t.error == "cloudflared exited before emitting a tunnel URL"
def test_reader_ignores_api_endpoint_failure_line():
t = ct.CloudflareTunnel(8080, "/bin/cloudflared")
t._reader(
_fake_proc(
"ERR failed to request quick Tunnel: Post "
'"https://api.trycloudflare.com/tunnel": context deadline exceeded\n'
)
)
assert t.url is None
assert t.wait_for_ready(0) is None
assert t.error == "cloudflared exited before emitting a tunnel URL"
def test_start_studio_tunnel_no_binary(monkeypatch):
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: None)
assert ct.start_studio_tunnel(8080) is None
def test_start_studio_tunnel_registers_before_wait(monkeypatch):
# The tunnel must be visible to stop_studio_tunnel() during the readiness
# wait, else a shutdown in that window orphans cloudflared.
seen = {}
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
def start(self):
pass
def wait_for_ready(self, timeout):
seen["active_during_wait"] = ct._active_tunnel is self
self.url = "https://x.trycloudflare.com"
return self.url
def stop(self):
seen["stopped"] = True
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
try:
assert ct.start_studio_tunnel(8080) == "https://x.trycloudflare.com"
assert seen["active_during_wait"] is True
finally:
ct.stop_studio_tunnel()
def test_start_studio_tunnel_clears_and_stops_on_no_url(monkeypatch):
seen = {}
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
def start(self):
pass
def wait_for_ready(self, timeout):
return None
def stop(self):
seen["stopped"] = True
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
assert ct.start_studio_tunnel(8080) is None
assert seen.get("stopped") is True
assert ct._active_tunnel is None
def test_start_studio_tunnel_returns_url(monkeypatch):
class _StubTunnel:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
def start(self):
self.url = "https://stub-xyz.trycloudflare.com"
def wait_for_ready(self, timeout):
return self.url
def stop(self):
pass
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _StubTunnel)
try:
assert ct.start_studio_tunnel(8080) == "https://stub-xyz.trycloudflare.com"
finally:
ct.stop_studio_tunnel()
def test_start_studio_tunnel_falls_back_to_http2(monkeypatch):
# First attempt mints a URL but never registers (quic blocked); the http2
# retry registers and wins.
attempts = []
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.protocol = protocol
self.url = None
attempts.append(protocol)
def start(self):
self.url = "https://words.trycloudflare.com" # URL always minted
def wait_for_ready(self, timeout):
return self.url if self.protocol == "http2" else None
def stop(self):
pass
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
try:
assert ct.start_studio_tunnel(8080) == "https://words.trycloudflare.com"
assert attempts == [None, "http2"] # default first, then forced http2
finally:
ct.stop_studio_tunnel()
def test_start_studio_tunnel_no_retry_when_shutdown_between_attempts(monkeypatch):
# A stop() landing in the gap AFTER the failed first attempt is cleaned up but
# BEFORE the http2 retry registers must abort the loop -- not start a second
# tunnel that nobody will ever stop (Codex review). Simulated by having the
# first attempt's stop() (called during cleanup) trigger the shutdown.
attempts = []
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
attempts.append(protocol)
def start(self):
self.url = "https://words.trycloudflare.com" # URL minted, never ready
def wait_for_ready(self, timeout):
return None
def stop(self):
ct.stop_studio_tunnel() # a concurrent shutdown lands in the gap
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
assert ct.start_studio_tunnel(8080) is None
assert attempts == [None] # http2 retry aborted after shutdown
assert ct._active_tunnel is None
def test_start_studio_tunnel_no_http2_retry_when_no_url(monkeypatch):
# No URL at all is an API/network failure; the http2 fallback would not help,
# so it must be skipped (don't burn a second timeout window).
attempts = []
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
attempts.append(protocol)
def start(self):
pass # never mints a URL
def wait_for_ready(self, timeout):
return None
def stop(self):
pass
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
assert ct.start_studio_tunnel(8080) is None
assert attempts == [None]
def test_start_studio_tunnel_both_protocols_fail_registration(monkeypatch):
# Both quic and http2 mint a URL but neither registers -> both attempts are
# exhausted and None is returned (no dead URL advertised).
attempts = []
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
attempts.append(protocol)
def start(self):
self.url = "https://words.trycloudflare.com" # URL minted, never ready
def wait_for_ready(self, timeout):
return None
def stop(self):
pass
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
assert ct.start_studio_tunnel(8080) is None
assert attempts == [None, "http2"]
assert ct._active_tunnel is None
def test_start_studio_tunnel_aborts_retry_on_concurrent_shutdown(monkeypatch):
# If a concurrent stop_studio_tunnel() clears _active_tunnel while we wait,
# the retry loop must NOT start a second (http2) tunnel: shutdown is already
# done, so nothing would ever stop it and it would be orphaned.
attempts = []
class _Stub:
def __init__(
self,
port,
binary,
protocol = None,
):
self.url = None
attempts.append(protocol)
def start(self):
self.url = "https://words.trycloudflare.com" # URL minted (saw_url True)
def wait_for_ready(self, timeout):
# Simulate stop_studio_tunnel() landing during the wait.
with ct._active_lock:
ct._active_tunnel = None
return None # never registered
def stop(self):
pass
monkeypatch.setattr(ct, "ensure_cloudflared", lambda: "/bin/cloudflared")
monkeypatch.setattr(ct, "CloudflareTunnel", _Stub)
assert ct.start_studio_tunnel(8080) is None
assert attempts == [None] # no http2 retry -> no orphaned second tunnel
assert ct._active_tunnel is None
# ── run.py source-level pins (AST / source, no heavy import) ─────────
def _func_param_defaults(source, func_name):
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name:
args = node.args.args
defaults = node.args.defaults
offset = len(args) - len(defaults)
out = {}
for i, d in enumerate(defaults):
if isinstance(d, ast.Constant):
out[args[offset + i].arg] = d.value
return out
return {}
def _argparse_default(source, option):
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
if node.func.attr == "add_argument" and node.args:
a0 = node.args[0]
if isinstance(a0, ast.Constant) and a0.value == option:
for kw in node.keywords:
if kw.arg == "default" and isinstance(kw.value, ast.Constant):
return kw.value.value
return None
def test_run_server_cloudflare_default_off():
defaults = _func_param_defaults(_RUN_PY.read_text(), "run_server")
assert "cloudflare" in defaults
assert defaults["cloudflare"] is None
def test_argparse_cloudflare_default_off():
assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is None
def test_verify_global_reachability_marks_private_address_unreachable():
src = _RUN_PY.read_text()
tree = ast.parse(src)
func_src = next(
ast.get_source_segment(src, n)
for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name == "_verify_global_reachability"
)
captured = []
ns = {
"_public_reachable": None,
"_stdout_color_ok": lambda: False,
"_url_host": lambda host: host,
"print": lambda *a, **k: captured.append(" ".join(str(x) for x in a)),
}
exec(compile(func_src, "<verify_global_reachability>", "exec"), ns)
ns["_verify_global_reachability"]("192.168.1.10", 8888)
assert ns["_public_reachable"] is False
assert "private/LAN address" in "\n".join(captured)
def test_run_server_registers_tunnel_atexit_backstop():
# An abnormal exit (exception after startup -> sys.exit) bypasses
# _graceful_shutdown; an atexit backstop must still stop the tunnel.
src = _RUN_PY.read_text()
assert "atexit.register(stop_studio_tunnel)" in src
def _run_print_cloudflare_line(
monkeypatch,
*,
cloudflare_url,
public_reachable,
cloudflare_requested = False,
cloudflare_flag = True,
secure = False,
loopback_host = "127.0.0.1",
color = False,
):
"""Exec _print_cloudflare_line without importing run.py's heavy deps."""
src = _RUN_PY.read_text()
tree = ast.parse(src)
func_src = next(
ast.get_source_segment(src, n)
for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name == "_print_cloudflare_line"
)
stub = types.ModuleType("startup_banner")
stub.stdout_supports_color = lambda: color
monkeypatch.setitem(sys.modules, "startup_banner", stub)
captured: list[str] = []
ns = {
"_cloudflare_url": cloudflare_url,
"_public_reachable": public_reachable,
"_cloudflare_requested": cloudflare_requested,
"_cloudflare_flag": cloudflare_flag,
"print": lambda *a, **k: captured.append(" ".join(str(x) for x in a)),
}
exec(compile(func_src, "<print_cloudflare_line>", "exec"), ns)
ns["_print_cloudflare_line"](secure = secure, loopback_host = loopback_host)
return "\n".join(captured)
def test_cloudflare_line_reworded_when_public_unreachable(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = False
)
assert "Use the secure link access via Cloudflare instead: https://x.trycloudflare.com" in out
def test_cloudflare_line_default_wording_when_reachable(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = True
)
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
assert "Use the secure link" not in out
def test_cloudflare_line_default_wording_when_unknown(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch, cloudflare_url = "https://x.trycloudflare.com", public_reachable = None
)
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
assert "Use the secure link" not in out
def test_cloudflare_line_states_inactive_when_enabled_but_not_requested(monkeypatch):
out = _run_print_cloudflare_line(monkeypatch, cloudflare_url = None, public_reachable = False)
assert "Cloudflare tunnel: OFF for this mode" in out
assert "local network only" in out
def test_cloudflare_line_warns_when_public_url_up(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = "https://x.trycloudflare.com",
public_reachable = True,
cloudflare_requested = True,
)
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
assert "Cloudflare tunnel: ON" in out
assert "PUBLIC" in out
assert "--no-cloudflare" in out
assert "raw port is also publicly reachable" in out
assert "local network only" not in out
def test_cloudflare_line_secure_mode_suppresses_public_warning(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = "https://x.trycloudflare.com",
public_reachable = True,
cloudflare_requested = True,
secure = True,
)
assert "Secure link access via Cloudflare: https://x.trycloudflare.com" in out
assert "Cloudflare tunnel: ON" not in out
def test_cloudflare_line_states_disabled_when_off(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = False,
cloudflare_requested = False,
cloudflare_flag = False,
)
assert "Cloudflare tunnel: OFF" in out
assert "local network only" in out
def test_cloudflare_line_labels_unset_as_default(monkeypatch):
# None = off by default (no flag) -> banner says "(default)", not "(--no-cloudflare)".
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = False,
cloudflare_requested = False,
cloudflare_flag = None,
)
assert "Cloudflare tunnel: OFF (default)" in out
assert "--no-cloudflare" not in out
def test_cloudflare_line_labels_explicit_no_cloudflare(monkeypatch):
# False = explicit --no-cloudflare -> banner says "(--no-cloudflare)".
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = False,
cloudflare_requested = False,
cloudflare_flag = False,
)
assert "Cloudflare tunnel: OFF (--no-cloudflare)" in out
def test_cloudflare_line_states_failed_when_requested_but_no_url(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = False,
cloudflare_requested = True,
cloudflare_flag = True,
)
assert "requested but failed to start" in out
assert "local network only" in out
def test_cloudflare_line_off_does_not_claim_local_only_when_unknown(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = None,
cloudflare_requested = False,
cloudflare_flag = False,
)
assert "Cloudflare tunnel: OFF" in out
assert "Raw port reachability was not verified" in out
assert "local network only" not in out
def test_cloudflare_line_failed_does_not_claim_local_only_when_unknown(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = None,
cloudflare_requested = True,
cloudflare_flag = True,
)
assert "requested but failed to start" in out
assert "Raw port reachability was not verified" in out
assert "local network only" not in out
@pytest.mark.parametrize(
"cloudflare_requested,cloudflare_flag,expected",
[
(True, True, "requested but failed to start"),
(False, True, "Cloudflare tunnel: OFF for this mode"),
(False, False, "Cloudflare tunnel: OFF"),
],
)
def test_cloudflare_line_unknown_warns_with_loopback_host(
monkeypatch, cloudflare_requested, cloudflare_flag, expected
):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = None,
cloudflare_requested = cloudflare_requested,
cloudflare_flag = cloudflare_flag,
loopback_host = "::1",
color = True,
)
assert expected in out
assert "bind ::1" in out
assert "bind 127.0.0.1" not in out
assert "\033[38;5;215;1m" in out
def test_cloudflare_line_off_does_not_claim_local_only_when_publicly_reachable(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = True,
cloudflare_requested = False,
cloudflare_flag = False,
)
assert "Cloudflare tunnel: OFF" in out
assert "reachable from the public internet" in out
assert "local network only" not in out
def test_cloudflare_line_failed_does_not_claim_local_only_when_publicly_reachable(monkeypatch):
out = _run_print_cloudflare_line(
monkeypatch,
cloudflare_url = None,
public_reachable = True,
cloudflare_requested = True,
cloudflare_flag = True,
)
assert "requested but failed to start" in out
assert "reachable from the public internet" in out
assert "local network only" not in out