raw: fetch behind a login - raw login stores the user's session as a secret outside the tree, fetch.routes sends a host through it or to an external fetcher, a handler's render goes through the optional browser component (preflight --with browser) (#169); 10.0.0-beta.8
CI / verify (push) Successful in 8m8s
CI / pwsh (push) Successful in 2m40s
Release / release (push) Successful in 35s
Release / wiki-mirror (push) Skipped

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- docs/how-to/README.md
- docs/how-to/fetch-pages-behind-a-login.md
- docs/how-to/ingest-a-source.md
- docs/reference/commands/README.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/configuration.md
- docs/reference/exit-codes.md
- instructions/preflight.md
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- sites/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/browser.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/raw/__init__.py
- tools/chemenu/commands/raw/fetch.py
- tools/chemenu/commands/raw/login.py
- tools/chemenu/config_shapes.py
- tools/chemenu/fetch_routes.py
- tools/chemenu/reference_doc.py
- tools/chemenu/settings.py
- tools/chemenu/sites/__init__.py
- tools/chemenu/sites/data.py
- tools/chemenu/sites/site.schema.json
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/chemenu/tests/test_raw_fetch_routes.py
- tools/chemenu/tests/test_raw_login.py
- tools/chemenu/tests/test_site_files.py
- tools/chemenu/web_capture.py
- tools/chemenu/web_session.py
- tools/preflight.ps1
- tools/preflight.sh
- tools/requirements-browser.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-11 20:50:23 +02:00
1 parent aed846740b
commit 5994b2a88c
43 files changed
+2189 -75

No files matched your search

+3 -1
View File
@@ -341,11 +341,13 @@ def test_network_yes_is_exactly_the_commands_that_can_reach_outside_this_checkou
since `--latest`, which asks the release feed and downloads from it, and `raw fetch`
since it exists (Gitea #120) - its `--html` form stays offline, which does not turn the
command back to `no`. `raw capture` and `raw status` reach a git remote (Gitea #177), and so
does `export guidelines --push` (Gitea #179). Pinned as an explicit
does `export guidelines --push` (Gitea #179). `raw login` opens a website's login page in a
browser (Gitea #169). Pinned as an explicit
set so a command gaining or losing that reach is a deliberate edit here, not a silent
drift between the property and what the command actually does."""
expected = {
"raw fetch",
"raw login",
"raw capture",
"raw status",
"export guidelines",
+1 -1
View File
@@ -78,7 +78,7 @@ def test_the_telemetry_policy_reads_the_record():
def test_one_record_per_section():
assert [shape.key for shape in config_shapes.SETTINGS_SECTIONS] == [
"telemetry", "remotes", "upload", "tasks", "superproductivity", "caldav", "sites",
"telemetry", "remotes", "upload", "tasks", "superproductivity", "caldav", "sites", "fetch",
]
+32
View File
@@ -740,3 +740,35 @@ def test_marked_scripts_fail_with_the_unblock_command(instance, monkeypatch):
def test_unmarked_scripts_are_ok(instance, monkeypatch):
_windows(monkeypatch)
assert _status(doctor.run_doctor(), "script-marks") == "OK"
# --- the optional browser component --------------------------------------------
def _without_browser(monkeypatch) -> None:
from chemenu import browser
monkeypatch.setattr(browser, "missing", lambda: "the Python package `playwright` is not installed in the venv")
def test_a_missing_browser_component_nothing_needs_is_ok(instance, monkeypatch):
_without_browser(monkeypatch)
checks = doctor.run_doctor()
assert _status(checks, "browser") == "OK"
assert "--with browser" in _detail(checks, "browser")
def test_a_missing_browser_component_a_session_route_needs_is_a_warning_not_a_failure(instance, monkeypatch):
_without_browser(monkeypatch)
write_settings(config.ROOT, monkeypatch, fetch={"routes": {"www.example.org": "session"}})
checks = doctor.run_doctor()
assert _status(checks, "browser") == "WARN"
assert "www.example.org" in _detail(checks, "browser")
assert "--with browser" in _fix(checks, "browser")
def test_an_installed_browser_component_is_ok(instance, monkeypatch):
from chemenu import browser
monkeypatch.setattr(browser, "missing", lambda: None)
assert _status(doctor.run_doctor(), "browser") == "OK"
+77 -1
View File
@@ -78,11 +78,24 @@ case "$1" in
shift 2
[ "$1" = --version ] && exit 0
echo "$*" >> "$PIP_LOG"
if [ -n "${{FAKE_PIP_FAIL:-}}" ]; then
case "$*" in
*requirements-browser*) fail=${{FAKE_BROWSER_PIP_FAIL:-}} ;;
*) fail=${{FAKE_PIP_FAIL:-}} ;;
esac
if [ -n "$fail" ]; then
echo "ERROR: Could not find a version that satisfies the requirement (network unreachable)" >&2
exit 1
fi
exit 0 ;;
playwright)
shift 2
echo "playwright $* into $PLAYWRIGHT_BROWSERS_PATH" >> "$PIP_LOG"
if [ -n "${{FAKE_PLAYWRIGHT_FAIL:-}}" ]; then
echo "Host system is missing dependencies to run browsers" >&2
exit 1
fi
mkdir -p "$PLAYWRIGHT_BROWSERS_PATH/chromium-1200"
exit 0 ;;
esac ;;
esac
exit 1
@@ -319,6 +332,69 @@ def test_pip_failure_stops_and_is_retried_next_time(machine, shell):
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 2
# --- the optional browser component -------------------------------------------------
def _browser_lines(machine) -> list[str]:
if not machine.pip_log.exists():
return []
return [line for line in machine.pip_log.read_text(encoding="utf-8").splitlines()
if "requirements-browser" in line or line.startswith("playwright ")]
@pytest.mark.parametrize("shell", SHELLS)
def test_without_with_browser_nothing_of_the_component_is_installed(machine, shell):
machine.standard()
(machine.tools / "requirements-browser.txt").write_text("playwright\n", encoding="utf-8")
result = machine.run(shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
assert _browser_lines(machine) == []
assert not any(line.split()[1:2] == ["browser"] for line in result.stdout.splitlines())
@pytest.mark.parametrize("shell", SHELLS)
def test_with_browser_installs_the_component_into_the_venv_and_keeps_it(machine, shell):
machine.standard()
(machine.tools / "requirements-browser.txt").write_text("playwright\n", encoding="utf-8")
result = machine.run("--with", "browser", shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
venv = machine.tools / ".venv"
lines = _browser_lines(machine)
assert any("-r" in line and line.endswith("requirements-browser.txt") for line in lines)
assert f"playwright install chromium into {venv / 'ms-playwright'}" in lines
assert any(line.split()[:2] == ["OK", "browser"] for line in result.stdout.splitlines())
assert (venv / ".chemenu-browser.sha256").is_file()
# A later plain run keeps it, and reinstalls only when its requirements change.
assert machine.run(shell=shell).returncode == 0
assert len(_browser_lines(machine)) == 2
(machine.tools / "requirements-browser.txt").write_text("playwright>=2\n", encoding="utf-8")
result = machine.run(shell=shell)
assert result.returncode == 0 and len(_browser_lines(machine)) == 4
assert "OK browser" in result.stdout
@pytest.mark.parametrize("shell", SHELLS)
@pytest.mark.parametrize("failing", ["FAKE_BROWSER_PIP_FAIL", "FAKE_PLAYWRIGHT_FAIL"])
def test_a_component_that_fails_stops_but_leaves_wikitool_working(machine, shell, failing):
machine.standard()
(machine.tools / "requirements-browser.txt").write_text("playwright\n", encoding="utf-8")
machine.extra_env[failing] = "1"
result = machine.run("--with=browser", shell=shell)
assert result.returncode == 42
assert_guidance(result.stdout, "optional browser component", "--with browser")
assert machine.recorded()["complete"] is True
assert not (machine.tools / ".venv" / ".chemenu-browser.sha256").exists()
@pytest.mark.parametrize("shell", SHELLS)
def test_an_unknown_component_is_refused(machine, shell):
machine.standard()
result = machine.run("--with", "spreadsheet", shell=shell)
assert result.returncode == 1
assert "not an optional component" in result.stderr
assert not machine.tools_file.exists()
# --- the store alias ----------------------------------------------------------------
@@ -183,6 +183,42 @@ def test_pip_failure_stops_and_is_retried_next_time(machine):
assert machine.recorded()["complete"] is True
def _browser_lines(machine: Machine) -> list[str]:
if not machine.pip_log.exists():
return []
return [line for line in machine.pip_log.read_text(encoding="utf-8").splitlines()
if "requirements-browser" in line or line.startswith("playwright ")]
def test_with_browser_installs_the_component_and_a_plain_run_keeps_it(machine):
(machine.tools / "requirements-browser.txt").write_text("playwright\n", encoding="utf-8")
assert _preflight(machine).returncode == 0
assert _browser_lines(machine) == []
result = _preflight(machine, "--with", "browser")
assert result.returncode == 0, result.stdout + result.stderr
venv = machine.tools / ".venv"
assert f"playwright install chromium into {venv / 'ms-playwright'}" in _browser_lines(machine)
assert "OK browser" in result.stdout
assert _preflight(machine).returncode == 0
assert len(_browser_lines(machine)) == 2
@pytest.mark.parametrize("failing", ["FAKE_BROWSER_PIP_FAIL", "FAKE_PLAYWRIGHT_FAIL"])
def test_a_component_that_fails_stops_but_leaves_wikitool_working(machine, failing):
(machine.tools / "requirements-browser.txt").write_text("playwright\n", encoding="utf-8")
machine.extra_env[failing] = "1"
result = _preflight(machine, "--with=browser")
assert result.returncode == 42
assert_guidance(result.stdout, "optional browser component", "--with browser")
assert machine.recorded()["complete"] is True
def test_an_unknown_component_is_refused(machine):
result = _preflight(machine, "--with", "spreadsheet")
assert result.returncode == 1
assert "not an optional component" in result.stderr
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
def test_store_alias_is_never_run_and_never_recorded(machine, platform, alias):
marker = machine.base / "alias-was-run"
@@ -0,0 +1,352 @@
"""`wikitool raw fetch` along a route: with the user's session, through an
external fetcher, or rendered by a browser (Gitea #169).
As in `test_raw_fetch.py`, every request goes to a real `http.server` on
127.0.0.1. The paid pages answer by the `Cookie` header they receive - the full
article to the right session, a teaser to anything else - so what is under test
is what a session actually sends, not what a stub was told. The browser is the
one thing replaced: Playwright is an optional component that CI does not
install, so `browser.render` and `browser.missing` are stood in for.
"""
from __future__ import annotations
import datetime
import json
import os
import sys
from pathlib import Path
import pytest
import typer
from chemenu import browser, config, config_shapes, fetch_routes, web_capture, web_session
from chemenu.commands.raw.fetch import raw_fetch_command
from chemenu.tests.conftest import write_settings
from chemenu.tests.test_raw_fetch import ARTICLE, Site
SECRET = "sid-6f3a-THIS-IS-THE-SESSION-VALUE"
FULL = (
b"<!doctype html><html><head><meta charset=\"utf-8\"><title>Paid</title></head><body><main>"
b"<h1>The paid article</h1><p>All of the text, " + b"long enough to be an article. " * 10 + b"</p>"
b"<a class=\"logout\" href=\"/logout\">Log out</a></main></body></html>"
)
TEASER = (
b"<!doctype html><html><head><meta charset=\"utf-8\"><title>Paid</title></head><body><main>"
b"<h1>The paid article</h1><p>The first sentence.</p>"
b"<div class=\"paywall\">Subscribe to read on</div></main></body></html>"
)
HANDLER = {
"host": "127.0.0.1",
"summary": "A test publisher behind a login.",
"session": {
"login": "http://127.0.0.1/login",
"signed_in": {"tag": "a", "class": "logout"},
"signed_out": {"class": "paywall"},
},
}
def _state(value: str = SECRET, expires: float = -1) -> dict:
return {
"cookies": [{
"name": "sid", "value": value, "domain": "127.0.0.1", "path": "/", "expires": expires,
"httpOnly": True, "secure": False, "sameSite": "Lax",
}],
"origins": [],
}
def _paid(server: Site, path: str = "/paid/article") -> str:
"""A page that answers by the cookie it receives."""
def answer(handler) -> None:
handler.wfile.write(FULL if f"sid={SECRET}" in (handler.headers.get("Cookie") or "") else TEASER)
server.routes[path] = (200, {"Content-Type": "text/html; charset=utf-8"}, answer)
return server.url(path)
@pytest.fixture
def site(monkeypatch):
for var in ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
monkeypatch.delenv(var, raising=False)
server = Site()
yield server
server.stop()
@pytest.fixture
def tree(kb_dir, monkeypatch):
root = kb_dir.parent
(root / "raw").mkdir()
(root / "incoming").mkdir(exist_ok=True)
(root / "sites").mkdir()
return root
def _handler(root: Path, **changes) -> None:
(root / "sites/publisher.json").write_text(json.dumps({**HANDLER, **changes}), encoding="utf-8")
@pytest.fixture
def routed(tree, monkeypatch):
"""127.0.0.1 routed through the session, with a handler that names the login."""
_handler(tree)
write_settings(tree, monkeypatch, fetch={"routes": {"127.0.0.1": "session"}})
return tree
def _session_env(monkeypatch, state: dict) -> None:
monkeypatch.setenv(config.ENV_SECRET_PREFIX + web_session.secret_name("127.0.0.1").upper(), json.dumps(state))
def _fetch(url, name=None, scope=False):
return raw_fetch_command(url=url, html=None, source_url=None, name=name, scope=scope)
def _exit_code(url, **kwargs) -> int:
with pytest.raises(typer.Exit) as excinfo:
_fetch(url, **kwargs)
return excinfo.value.exit_code
def _header(md: Path) -> dict:
import yaml
content = md.read_text(encoding="utf-8")
return yaml.safe_load(content[4:].partition("\n---\n")[0])
def _snapshot(root: Path) -> dict[str, bytes]:
return {str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()}
# --- with the user's session ------------------------------------------------
def test_with_a_stored_session_the_full_text_arrives_byte_identical(routed, site, monkeypatch, capsys):
_session_env(monkeypatch, _state())
url = _paid(site)
_fetch(url)
out = capsys.readouterr().out
assert (routed / "incoming/article.html").read_bytes() == FULL
header = _header(routed / "incoming/article.md")
assert header["session"] == "127.0.0.1"
assert header["fetched_by"] == "wikitool raw fetch"
assert "rendered" not in header
assert SECRET not in (routed / "incoming/article.md").read_text(encoding="utf-8")
assert SECRET not in out
assert "raw fetch --html" not in out # the teaser hint is for the anonymous fetch
def test_the_session_is_read_from_the_secrets_file_outside_the_tree(routed, site, monkeypatch):
path = web_session.store("127.0.0.1", "testinstance", _state())
assert path == config.secrets_file("testinstance") # the user's configuration directory
if os.name != "nt":
assert path.stat().st_mode & 0o077 == 0
_fetch(_paid(site))
assert (routed / "incoming/article.html").read_bytes() == FULL
# The test's HOME lies inside its tmp_path; in it, the secrets file is the one place the value is.
holding = [p for p in routed.rglob("*") if p.is_file() and SECRET.encode() in p.read_bytes()]
assert holding == [path]
@pytest.mark.parametrize("state", [None, _state("stale"), _state(expires=1_000_000_000)],
ids=["no-session", "ended-by-the-website", "expired-cookie"])
def test_without_a_valid_session_exit_42_names_raw_login_and_keeps_no_teaser(routed, site, monkeypatch, capsys, state):
if state is not None:
_session_env(monkeypatch, state)
url = _paid(site)
before = _snapshot(routed / "incoming")
assert _exit_code(url) == 42
out = capsys.readouterr().out
assert "tools/wikitool raw login 127.0.0.1" in out
assert _snapshot(routed / "incoming") == before
if state is None:
assert site.requests == [] # nothing to send, so nothing is asked
def test_a_session_route_without_a_login_in_any_handler_is_refused_before_fetching(tree, site, monkeypatch, capsys):
(tree / "sites/publisher.json").write_text(
json.dumps({k: v for k, v in HANDLER.items() if k != "session"}), encoding="utf-8")
write_settings(tree, monkeypatch, fetch={"routes": {"127.0.0.1": "session"}})
assert _exit_code(_paid(site)) == 1
assert "names a login page" in capsys.readouterr().out
assert site.requests == []
def test_scope_sends_the_session_with_every_page_and_names_it(routed, site, monkeypatch):
_handler(routed, path="/paid/", subpages={"links": {"tag": "a", "inside": "td"}})
write_settings(routed, monkeypatch, sites={"pause_seconds": 0})
_session_env(monkeypatch, _state())
index = (b"<html><body><main><h1>Index</h1><table><tr><td><a href=\"one\">One</a></td></tr></table>"
b"<a class=\"logout\" href=\"/logout\">Log out</a></main></body></html>")
def answer(handler) -> None:
handler.wfile.write(index if f"sid={SECRET}" in (handler.headers.get("Cookie") or "") else TEASER)
site.routes["/paid/"] = (200, {"Content-Type": "text/html"}, answer)
_paid(site, "/paid/one")
_fetch(site.url("/paid/"), name="bundle", scope=True)
bundle = routed / "incoming/bundle"
assert (bundle / "one.html").read_bytes() == FULL
assert _header(bundle / "one.md")["session"] == "127.0.0.1"
assert json.loads((bundle / "_scope.json").read_text(encoding="utf-8"))["session"] == "127.0.0.1"
# --- rendered by a browser --------------------------------------------------
def _fake_render(calls: list):
def render(url, user_agent, state=None, **kwargs):
calls.append((url, state))
return web_capture.Response(url, url, 200, "text/html; charset=utf-8", FULL)
return render
def test_a_handler_declaring_render_gives_a_rendered_header(tree, site, monkeypatch):
_handler(tree, render=True)
calls: list = []
monkeypatch.setattr(browser, "render", _fake_render(calls))
url = site.html("/page", ARTICLE)
_fetch(url)
assert calls == [(url, None)]
assert site.requests == [] # the browser fetched it, not the built-in fetch
assert _header(tree / "incoming/page.md")["rendered"] is True
def test_render_with_a_session_hands_the_browser_the_session(routed, site, monkeypatch):
_handler(routed, render=True)
_session_env(monkeypatch, _state())
calls: list = []
monkeypatch.setattr(browser, "render", _fake_render(calls))
_fetch(site.url("/paid/article"))
assert calls[0][1] == _state()
header = _header(routed / "incoming/article.md")
assert header["rendered"] is True and header["session"] == "127.0.0.1"
def test_without_the_declaration_nothing_is_ever_rendered(routed, site, monkeypatch):
_session_env(monkeypatch, _state())
def refuse(*args, **kwargs):
raise AssertionError("rendered without a declaration")
monkeypatch.setattr(browser, "render", refuse)
_fetch(_paid(site))
assert "rendered" not in _header(routed / "incoming/article.md")
def test_render_without_the_browser_component_exits_42_with_the_preflight_option(tree, site, monkeypatch, capsys):
_handler(tree, render=True)
monkeypatch.setattr(browser, "missing", lambda: "the Python package `playwright` is not installed in the venv")
before = _snapshot(tree / "incoming")
assert _exit_code(site.html("/page", ARTICLE)) == 42
assert "tools/preflight.sh --with browser" in capsys.readouterr().out
assert _snapshot(tree / "incoming") == before
# --- an external fetcher ----------------------------------------------------
def _fetcher(tmp_path: Path, body: bytes = FULL, code: int = 0, stderr: str = "") -> list[str]:
script = tmp_path / "fetcher.py"
script.write_text(
"import sys\n"
f"sys.stdout.buffer.write({body!r})\n"
f"sys.stderr.write({stderr!r})\n"
f"open({str(tmp_path / 'fetcher.args')!r}, 'w').write(' '.join(sys.argv[1:]))\n"
f"sys.exit({code})\n",
encoding="utf-8",
)
return [sys.executable, str(script), "--html"]
def test_an_external_fetcher_writes_its_stdout_byte_identical(tree, site, monkeypatch, tmp_path):
write_settings(tree, monkeypatch, fetch={"routes": {"127.0.0.1": "homelab"},
"fetchers": {"homelab": _fetcher(tmp_path)}})
url = site.url("/elsewhere/post")
_fetch(url)
assert site.requests == [] # the built-in fetch never ran
assert (tmp_path / "fetcher.args").read_text(encoding="utf-8") == f"--html {url}"
assert (tree / "incoming/post.html").read_bytes() == FULL
header = _header(tree / "incoming/post.md")
assert header["fetched_by"] == "wikitool raw fetch (fetcher: homelab)"
assert "http_status" not in header
def test_a_failing_fetcher_exits_1_with_its_stderr_and_no_fallback(tree, site, monkeypatch, tmp_path, capsys):
write_settings(tree, monkeypatch, fetch={
"routes": {"127.0.0.1": "homelab"},
"fetchers": {"homelab": _fetcher(tmp_path, code=3, stderr="upstream says 503")},
})
url = site.html("/post", ARTICLE)
before = _snapshot(tree / "incoming")
assert _exit_code(url) == 1
out = capsys.readouterr().out
assert "exit 3" in out and "upstream says 503" in out
assert _snapshot(tree / "incoming") == before
assert site.requests == []
def test_a_route_to_a_fetcher_this_checkout_lacks_is_refused(tree, site, monkeypatch, capsys):
write_settings(tree, monkeypatch, fetch={"routes": {"127.0.0.1": "homelab"}})
assert _exit_code(site.html("/post", ARTICLE)) == 1
assert "fetch.fetchers.homelab" in capsys.readouterr().out
assert site.requests == []
def test_a_fetcher_command_in_the_committed_file_is_refused(tree, site, monkeypatch, capsys, tmp_path):
(tree / config.INSTANCE_SETTINGS_FILENAME).write_text(json.dumps({
"schema": 1, "instance_id": "testinstance",
"fetch": {"routes": {"127.0.0.1": "homelab"}, "fetchers": {"homelab": _fetcher(tmp_path)}},
}), encoding="utf-8")
assert _exit_code(site.html("/post", ARTICLE)) == 1
out = capsys.readouterr().out
assert "fetch.fetchers" in out and "checkout" in out
assert site.requests == []
assert config_shapes.FETCH.field("fetchers").names_command
with pytest.raises(ValueError):
config_shapes.Field("fetchers", "object", "", tier=config_shapes.INSTANCE, names_command=True)
# --- a host no route names --------------------------------------------------
def _without_time(md: Path) -> str:
return "\n".join(line for line in md.read_text(encoding="utf-8").splitlines() if not line.startswith("retrieved:"))
def test_a_host_no_route_names_is_fetched_exactly_as_before(tree, site, monkeypatch, capsys):
url = site.html("/post", ARTICLE)
_fetch(url, name="first")
first_out = capsys.readouterr().out
write_settings(tree, monkeypatch, fetch={"routes": {"other.example.org": "session"}})
_fetch(url, name="later")
later_out = capsys.readouterr().out
assert (tree / "incoming/later.html").read_bytes() == (tree / "incoming/first.html").read_bytes()
assert _without_time(tree / "incoming/later.md") == _without_time(tree / "incoming/first.md").replace(
"first.html", "later.html")
assert later_out.replace("later", "first") == first_out
assert [ua for _, ua in site.requests] == [site.requests[0][1]] * 2
# --- the pieces -------------------------------------------------------------
def test_secret_names_are_valid_reference_names():
assert web_session.secret_name("www.heise.de") == "session_www_heise_de"
assert web_session.secret_name("a--b.example") == "session_a_b_example"
def test_expired_cookies_are_not_sent(site):
jar = web_session.cookie_jar(_state(expires=(datetime.datetime.now() - datetime.timedelta(days=1)).timestamp()))
url = _paid(site)
response = web_capture.fetch(url, "test", cookies=jar)
assert response.body == TEASER
def test_a_route_names_a_session_label_only_for_the_session():
assert fetch_routes.Route("h", fetch_routes.SESSION).session_label == "h"
assert fetch_routes.Route("h", fetch_routes.FETCHER, fetcher="x").session_label == ""
assert fetch_routes.Route("h", fetch_routes.FETCHER, fetcher="x").fetched_by.endswith("(fetcher: x)")
+124
View File
@@ -0,0 +1,124 @@
"""`wikitool raw login <host>` - the user signs in once, and the session is stored
as a secret outside the working tree (Gitea #169).
The browser window itself needs a human and the optional Playwright component,
so `browser.login` and `browser.missing` are stood in for: what is under test is
the order of the checks, where the session lands, and that nothing prints it.
"""
from __future__ import annotations
import json
import os
import pytest
import typer
from chemenu import browser, config, web_session
from chemenu.commands.raw.fetch import raw_fetch_command
from chemenu.commands.raw.login import raw_login_command
from chemenu.tests.conftest import write_settings
from chemenu.tests.test_raw_fetch_routes import FULL, HANDLER, SECRET, _paid, _state, site, tree # noqa: F401
@pytest.fixture
def routed(tree, monkeypatch): # noqa: F811
(tree / "sites/publisher.json").write_text(json.dumps(HANDLER), encoding="utf-8")
write_settings(tree, monkeypatch, fetch={"routes": {"127.0.0.1": "session"}})
return tree
def _login(host: str = "127.0.0.1") -> int:
try:
raw_login_command(host=host)
except typer.Exit as exc:
return exc.exit_code
return 0
def test_login_stores_the_session_outside_the_tree_and_prints_none_of_it(routed, site, monkeypatch, capsys): # noqa: F811
opened = []
monkeypatch.setattr(browser, "login", lambda url: opened.append(url) or _state())
assert _login() == 0
out = capsys.readouterr()
assert opened == ["http://127.0.0.1/login"]
path = config.secrets_file("testinstance")
stored = json.loads(json.loads(path.read_text(encoding="utf-8"))["session_127_0_0_1"])
assert stored == _state()
if os.name != "nt":
assert path.stat().st_mode & 0o077 == 0
assert SECRET not in out.out + out.err
assert "session_127_0_0_1" in out.out
# ... and the next fetch uses it.
raw_fetch_command(url=_paid(site), html=None, source_url=None, name=None, scope=False)
assert (routed / "incoming/article.html").read_bytes() == FULL
def test_login_keeps_the_other_secrets(routed, monkeypatch):
path = config.secrets_file("testinstance")
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"caldav_app_password": "other"}), encoding="utf-8")
path.chmod(0o600)
monkeypatch.setattr(browser, "login", lambda url: _state())
assert _login() == 0
assert json.loads(path.read_text(encoding="utf-8"))["caldav_app_password"] == "other"
def test_login_takes_a_url_for_its_host(routed, monkeypatch):
monkeypatch.setattr(browser, "login", lambda url: _state())
assert _login("http://127.0.0.1/some/article") == 0
assert web_session.load("127.0.0.1", "testinstance") == _state()
def test_login_without_the_browser_component_exits_42_with_the_preflight_option(routed, monkeypatch, capsys):
monkeypatch.setattr(browser, "missing", lambda: "the Python package `playwright` is not installed in the venv")
assert _login() == 42
out = capsys.readouterr().out
assert "tools/preflight.sh --with browser" in out
assert not config.secrets_file("testinstance").exists()
def test_login_for_a_host_not_routed_through_the_session_is_refused(tree, monkeypatch, capsys): # noqa: F811
(tree / "sites/publisher.json").write_text(json.dumps(HANDLER), encoding="utf-8")
monkeypatch.setattr(browser, "login", lambda url: pytest.fail("the browser was opened"))
assert _login() == 1
assert "config set fetch.routes" in capsys.readouterr().out
def test_login_for_a_host_whose_handler_names_no_login_page_is_refused(tree, monkeypatch, capsys): # noqa: F811
write_settings(tree, monkeypatch, fetch={"routes": {"127.0.0.1": "session"}})
monkeypatch.setattr(browser, "login", lambda url: pytest.fail("the browser was opened"))
assert _login() == 1
assert "names a login page" in capsys.readouterr().out
def test_login_warns_when_a_variable_answers_before_the_file(routed, monkeypatch, capsys):
monkeypatch.setenv("WIKITOOL_SECRET_SESSION_127_0_0_1", json.dumps(_state("older")))
monkeypatch.setattr(browser, "login", lambda url: _state())
assert _login() == 0
assert "WARN WIKITOOL_SECRET_SESSION_127_0_0_1 is set" in capsys.readouterr().out
def test_the_component_is_reported_missing_without_playwright(monkeypatch):
monkeypatch.setattr(browser.importlib.util, "find_spec", lambda name: None)
assert "playwright" in browser.missing()
with pytest.raises(browser.BrowserMissing, match="--with browser"):
browser.require("`raw login`")
def test_config_set_writes_a_route_and_leaves_a_fetcher_to_the_user(tree): # noqa: F811
from typer.testing import CliRunner
from chemenu.cli import app
runner = CliRunner()
result = runner.invoke(app, ["config", "set", "fetch.routes", '{"www.example.org": "session"}'])
assert result.exit_code == 0, result.output
data = json.loads((tree / config.INSTANCE_SETTINGS_FILENAME).read_text(encoding="utf-8"))
assert data["fetch"] == {"routes": {"www.example.org": "session"}} and data["instance_id"]
result = runner.invoke(app, ["config", "set", "fetch.fetchers", '{"homelab": ["/bin/true"]}'])
assert result.exit_code == 42
assert not (tree / config.LOCAL_SETTINGS_FILENAME).exists()
result = runner.invoke(app, ["config", "set", "fetch.routes", '{"www.example.org": "Session!"}'])
assert result.exit_code == 1
+18
View File
@@ -190,6 +190,10 @@ BROKEN = {
"a placeholder without its field": {**EXAMPLE, "checks": [{"text": "see {count}"}]},
"a parent that does not exist": {**EXAMPLE, "parent": "nobody"},
"a parent for another host": {**EXAMPLE, "parent": "learn-microsoft"},
"a session without a sign of being signed in": {**EXAMPLE, "session": {"login": "https://docs.example.org/login"}},
"a session whose login page is not http(s)": {
**EXAMPLE, "session": {"login": "file:///etc/passwd", "signed_in": {"class": "logout"}}},
"a render that is not a boolean": {**EXAMPLE, "render": "yes"},
}
@@ -273,6 +277,20 @@ def test_a_symlink_is_not_followed(tree):
assert doctor.check_site_handlers().status == "FAIL"
def test_a_session_and_render_are_read_and_inherited(tree):
_handler(tree, "example", {**EXAMPLE, "render": True, "session": {
"login": "https://docs.example.org/login", "signed_out": {"class": "paywall"}}})
_handler(tree, "example-guide", {"host": "docs.example.org", "summary": "The guide.", "path": "/guide/",
"parent": "example"})
instance, broken = sites.read_instance(tree)
assert broken == ()
child = next(s for s in instance if s.name == "example-guide")
assert child.render and child.session.login == "https://docs.example.org/login"
assert child.session.is_signed_in("<p>article</p>")
assert not child.session.is_signed_in('<div class="paywall">subscribe</div>')
assert sites.login_site("docs.example.org", instance=instance).name == "example"
def test_doctor_is_quiet_without_a_sites_directory(tree):
assert doctor.check_site_handlers().status == "OK"