Files
changedetection.io/changedetectionio/tests/test_add_watch_ui_browser.py
T
dgtlmoon 3a71777499 UI - Fixing lots of actions that should be a POST style action which led to a 404
Also solves some of GHSA-56fq-63vj-9992 Add-watch-UI should be POST/CSRF protected
2026-09-04 12:38:20 +02:00

211 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""The Add-Watch page's browser picker.
The live preview needs a browser that can render screenshots + xpath element data, and
whatever rendered the preview is what the saved watch must check with - otherwise you
visually pick an element with Chrome and the watch then re-checks it with the plain HTTP
client. These tests cover the offered list, the server-side gate, and that the browser
which rendered a parked snapshot wins over the posted form.
"""
import json
import os
from flask import url_for
def _datastore(client):
return client.application.config.get('DATASTORE')
def test_hidden_without_a_live_preview_browser(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""No capable browser -> the sidebar link is hidden and the route bounces.
One seam is patched deliberately: has_visual_browser() (page + sidebar gate) and
default_visual_browser() both resolve through list_visual_browser_choices(), so the
gate can't disagree with what the picker would have offered.
"""
from changedetectionio.blueprint.add_watch_ui import browser_config
monkeypatch.setattr(browser_config, 'list_visual_browser_choices', lambda datastore: [])
# Sidebar link is not rendered
res = client.get(url_for('watchlist.index'))
assert url_for('add_watch_ui.add_watch_ui_index').encode() not in res.data
# Direct navigation is bounced back to the watch list
res = client.get(url_for('add_watch_ui.add_watch_ui_index'), follow_redirects=True)
assert b'name="fetch_backend"' not in res.data
assert b'needs an interactive browser' in res.data
def test_shown_when_a_live_preview_browser_exists(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""A capable browser -> the sidebar link is offered and the page serves its picker."""
from changedetectionio.blueprint.add_watch_ui import browser_config
monkeypatch.setattr(browser_config, 'list_visual_browser_choices',
lambda datastore: [('html_webdriver', 'WebDriver Chrome/Javascript')])
res = client.get(url_for('watchlist.index'))
assert url_for('add_watch_ui.add_watch_ui_index').encode() in res.data
res = client.get(url_for('add_watch_ui.add_watch_ui_index'))
assert res.status_code == 200
assert b'name="fetch_backend"' in res.data
assert b'value="html_webdriver"' in res.data
# The system default resolves to html_requests (no preview), so a real browser is preselected
assert b'checked' in res.data
def test_browser_picker_lists_only_live_preview_capable(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""Capable browsers are offered; the plain HTTP client never is."""
from changedetectionio.blueprint.add_watch_ui import browser_config
# html_webdriver is only preview-capable when it resolves to playwright/puppeteer, so
# pretend it does (a WEBDRIVER_URL-only deployment resolves it to selenium, which can't).
monkeypatch.setattr(browser_config, 'is_visual_capable',
lambda name, datastore: name == 'html_webdriver')
res = client.get(url_for('add_watch_ui.add_watch_ui_index'))
assert res.status_code == 200
assert b'name="fetch_backend"' in res.data
assert b'value="html_webdriver"' in res.data
# The plain HTTP client can't render a preview, so it is not an option
assert b'value="html_requests"' not in res.data
# The system default resolves to html_requests here, so it is listed but disabled
assert b'value="system"' in res.data
assert b'disabled' in res.data
def test_snapshot_refuses_browser_that_cannot_preview(client, live_server, measure_memory_usage, datastore_path, monkeypatch):
"""/snapshot won't spend a fetch on a browser that produces no screenshot."""
from changedetectionio.blueprint.add_watch_ui import browser_config
monkeypatch.setattr(browser_config, 'is_visual_capable', lambda name, datastore: False)
snapshot_url = url_for('add_watch_ui.add_watch_ui_snapshot')
# Nothing capable, and no explicit browser asked for -> nothing to preview with
res = client.post(snapshot_url, data={'url': 'https://example.com'})
assert res.status_code == 400
assert b'No interactive browser' in res.data
# Explicitly asking for a browser that can't preview is refused just the same
res = client.post(snapshot_url, data={'url': 'https://example.com',
'fetch_backend': 'html_requests'})
assert res.status_code == 400
# A made-up name never resolves to a capable fetcher either (real capability lookup here)
monkeypatch.undo()
res = client.post(snapshot_url, data={'url': 'https://example.com',
'fetch_backend': '../../etc/passwd'})
assert res.status_code == 400
res = client.post(snapshot_url, data={'url': 'https://example.com',
'fetch_backend': 'os'})
assert res.status_code == 400
def test_snapshot_is_post_only(client, live_server, measure_memory_usage, datastore_path):
"""A GET must not reach the endpoint at all.
/snapshot drives a real server-side browser fetch and hands the rendered result back in
the response (GHSA-56fq-63vj-9992). As a GET that is reachable by anything that can make
the operator's browser issue a request - an <img>/<iframe>/link from another site - with
no CSRF token in play. POST-only + CSRFProtect means only our own page can trigger it.
"""
# Method mismatch surfaces as 404 here rather than 405
res = client.get(url_for('add_watch_ui.add_watch_ui_snapshot') + '?url=https://example.com')
assert res.status_code in (404, 405)
def test_submit_rejects_unknown_fetcher(client, live_server, measure_memory_usage, datastore_path):
"""A posted browser is checked server side, so a doctored form can't pin a junk fetcher."""
datastore = _datastore(client)
test_url = url_for('test_endpoint', _external=True)
for bad in ('os', 'html_requests\n', '{{7*7}}', '../../etc/passwd', 'html_nope'):
before = len(datastore.data['watching'])
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "fetch_backend": bad},
follow_redirects=True
)
assert res.status_code == 200
assert b"Watch added" not in res.data
assert len(datastore.data['watching']) == before, f"{bad!r} should not have created a watch"
def test_submit_still_accepts_any_installed_fetcher(client, live_server, measure_memory_usage, datastore_path):
"""This endpoint is shared with the watch-list quick-add, which may legitimately name a
backend that can't render a live preview (restock tests add watches with html_requests) -
only *offering* it on the Add-Watch page is restricted, not adding a watch with it."""
datastore = _datastore(client)
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "processor": "restock_diff", "fetch_backend": "html_requests"},
follow_redirects=True
)
assert b"Watch added" in res.data
uuid = next(iter(datastore.data['watching']))
assert datastore.data['watching'][uuid].get('fetch_backend') == 'html_requests'
def test_submit_saves_chosen_browser(client, live_server, measure_memory_usage, datastore_path):
"""The picked browser lands on the watch instead of leaving it on 'system'."""
datastore = _datastore(client)
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "fetch_backend": "html_webdriver"},
follow_redirects=True
)
assert b"Watch added" in res.data
uuid = next(iter(datastore.data['watching']))
assert datastore.data['watching'][uuid].get('fetch_backend') == 'html_webdriver'
def test_parked_snapshot_browser_wins_over_form(client, live_server, measure_memory_usage, datastore_path):
"""The browser that actually rendered the snapshot is the one the watch keeps.
/snapshot records it in the temporary watch's own watch.json; promoting the snapshot
must prefer that over whatever the form posted, since the parked screenshot and
element data came from it.
"""
datastore = _datastore(client)
test_url = url_for('test_endpoint', _external=True)
temp_uuid = '11111111-2222-3333-4444-555555555555'
temp_dir = datastore.get_temporary_watch_dir(temp_uuid)
os.makedirs(temp_dir, exist_ok=True)
with open(os.path.join(temp_dir, "watch.json"), 'w') as f:
json.dump({"fetch_backend": "html_webdriver"}, f)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "fetch_backend": "html_requests", "temporary_uuid": temp_uuid},
follow_redirects=True
)
assert b"Watch added" in res.data
uuid = next(iter(datastore.data['watching']))
assert datastore.data['watching'][uuid].get('fetch_backend') == 'html_webdriver'
# The snapshot dir was promoted into the watch, not left behind
assert not os.path.isdir(temp_dir)
def test_watchlist_quick_add_is_unaffected(client, live_server, measure_memory_usage, datastore_path):
"""The watch-list quick-add posts no browser at all - that still means 'system'."""
datastore = _datastore(client)
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url},
follow_redirects=True
)
assert b"Watch added" in res.data
uuid = next(iter(datastore.data['watching']))
assert datastore.data['watching'][uuid].get('fetch_backend') in (None, '', 'system')