browser: click-path allowlist, safe tenant script upgrade, allowlist activation plan
- click/click_ref: pre-click allowlist gate on resolvable anchor href
(same check as navigate; miss refuses before the click executes);
click/click_ref/click_at: post-click landing URL check.
click_at has no pre-click gate by design (coordinates name no
element); JS-driven navigation, evaluate, meta refresh and popups
are documented as unintercepable with the Tinyproxy 403 backstop.
- Tenant script upgrade is now safe: only proven-unmodified scripts
(manifest match, 2a569ac known-shipped SHA-256, missing, or
current) auto-merge; tenant-modified scripts are never overwritten,
are reported as skipped, and get one deduped audit record. The
manifest never learns a tenant hash (else the next upgrade would
mistake a modified file for untouched). Idempotent reruns.
- Activation candidate = deployed Tinyproxy programming set encoded
with leading-dot entries (plain entries would regress
docs.python.org); npmjs.org per the deployed filter, not npmjs.com.
Host activation itself is NOT performed this round: only the plan,
validation tests and rollback are delivered.
Tests: 28 new (click 13, upgrade 8, activation 7), all fake/no-network;
98 browser-related tests pass including prior suites.
(cherry picked from commit e4a93f5748ec815f163d0bc096ff4dc4b8c651f5)
This commit is contained in:
1 parent
1141d0d0f8
commit
125b436ab3
8 files changed
+1150
-33
No files matched your search
@@ -0,0 +1,209 @@
|
||||
"""BROWSER_ALLOWED_HOSTS activation-candidate validation.
|
||||
|
||||
The candidate value is the ALREADY-DEPLOYED Tinyproxy
|
||||
programming-domain set (deploy/egress/tinyproxy-filter) plus the
|
||||
already-present github.com / pypi.org / npmjs.org -- no invented
|
||||
domains. NOTE: the task text said "npmjs.com", but the deployed
|
||||
filter (tinyproxy-filter line 23, new-host-checklist.md, and
|
||||
docs/platform-browser.md) all say npmjs.org, so the candidate
|
||||
follows the DEPLOYED set (npmjs.org); npmjs.com is deliberately
|
||||
NOT in the candidate -- adding it would widen the allowlist
|
||||
beyond the proxy, breaking allowlist==proxy parity. If the npm
|
||||
website itself must be browsable, that is a separate filter
|
||||
change for the parent to decide, not something this round
|
||||
invents. Because the Tinyproxy lines are subdomain-inclusive
|
||||
(``^\\(.*\\.\\)*domain$``), the env value uses leading-dot
|
||||
entries, which are the allowlist encoding of the same
|
||||
subdomain-inclusive set; plain entries would NOT match
|
||||
subdomains (proven by test_plain_entries_would_break_subdomains)
|
||||
and would therefore regress docs.python.org etc.
|
||||
|
||||
All checks run against fake browsers / the filter file itself:
|
||||
no production tenant, no network, no real credentials.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from playwright_browser import Browser, BrowserError # noqa: E402
|
||||
from playwright_browser import _check_allowed_url # noqa: E402
|
||||
|
||||
# The activation candidate: the deployed Tinyproxy programming
|
||||
# set, encoded subdomain-inclusively. Domain SET is fixed by the
|
||||
# task (no new domains); only the encoding (dot prefix) follows
|
||||
# from the filter's own subdomain rule.
|
||||
CANDIDATE_DOMAINS = [
|
||||
"python.org", "nodejs.org", "go.dev", "pkg.go.dev",
|
||||
"rust-lang.org", "developer.mozilla.org", "crates.io",
|
||||
"stackoverflow.com", "cve.mitre.org", "nvd.nist.gov",
|
||||
"github.com", "pypi.org", "npmjs.org",
|
||||
]
|
||||
CANDIDATE = ",".join("." + d for d in CANDIDATE_DOMAINS)
|
||||
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),
|
||||
"..", ".."))
|
||||
FILTER_PATH = os.path.join(REPO_ROOT, "deploy", "egress",
|
||||
"tinyproxy-filter")
|
||||
|
||||
|
||||
class _FakePage:
|
||||
def __init__(self):
|
||||
self.url = ""
|
||||
self.calls = []
|
||||
self.mouse = self
|
||||
|
||||
def title(self):
|
||||
return "T"
|
||||
|
||||
def goto(self, url, timeout=None, wait_until=None):
|
||||
self.calls.append(("goto", url))
|
||||
self.url = url
|
||||
|
||||
def locator(self, selector):
|
||||
raise AssertionError("no locator use in activation tests")
|
||||
|
||||
def click(self, selector, timeout=None):
|
||||
self.calls.append(("click", selector))
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeHandle:
|
||||
def __init__(self, page):
|
||||
self._page = page
|
||||
|
||||
def new_page(self, viewport=None):
|
||||
return self._page
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakePW:
|
||||
def __init__(self, page):
|
||||
self._page = page
|
||||
self.launch_count = 0
|
||||
|
||||
def start(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def chromium(self):
|
||||
outer = self
|
||||
|
||||
class _Chromium:
|
||||
def launch(self, **kwargs):
|
||||
outer.launch_count += 1
|
||||
return _FakeHandle(outer._page)
|
||||
|
||||
return _Chromium()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class _EnvGuard:
|
||||
def setUp(self):
|
||||
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
|
||||
|
||||
def tearDown(self):
|
||||
if self._old is None:
|
||||
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||
else:
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
|
||||
|
||||
|
||||
def _filter_domains():
|
||||
"""Domains covered by subdomain-inclusive filter lines."""
|
||||
with open(FILTER_PATH, encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
out = set()
|
||||
for line in lines:
|
||||
m = re.match(r"^\^\\\(\.\*\\.\\\)\*(.+)\$$", line)
|
||||
if m:
|
||||
out.add(m.group(1).replace("\\.", "."))
|
||||
return out
|
||||
|
||||
|
||||
class ActivationCandidateTest(_EnvGuard, unittest.TestCase):
|
||||
def setUp(self):
|
||||
_EnvGuard.setUp(self)
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = CANDIDATE
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.page = _FakePage()
|
||||
self.factory = _FakePW(self.page)
|
||||
self.browser = Browser(cwd=self._tmp.name,
|
||||
proxy="http://proxy:8888",
|
||||
browser_factory=lambda: self.factory)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
_EnvGuard.tearDown(self)
|
||||
|
||||
def test_candidate_matches_deployed_filter_set(self):
|
||||
# Every candidate domain has a subdomain-inclusive line
|
||||
# in the deployed filter; the candidate invents nothing.
|
||||
filt = _filter_domains()
|
||||
for domain in CANDIDATE_DOMAINS:
|
||||
self.assertIn(domain, filt, domain)
|
||||
|
||||
def test_candidate_allows_bare_and_subdomains(self):
|
||||
for domain in CANDIDATE_DOMAINS:
|
||||
_check_allowed_url("https://%s/" % domain)
|
||||
_check_allowed_url("https://www.%s/docs" % domain)
|
||||
# The cases the filter explicitly exists for.
|
||||
_check_allowed_url("https://docs.python.org/3/")
|
||||
_check_allowed_url("https://docs.rust-lang.org/")
|
||||
_check_allowed_url("https://api.github.com/")
|
||||
_check_allowed_url("https://pypi.org/project/x/")
|
||||
|
||||
def test_candidate_blocks_suffix_tricks_and_outsiders(self):
|
||||
for bad in ("https://notpython.org/",
|
||||
"https://python.org.evil.com/",
|
||||
"https://evilgithub.com/",
|
||||
"https://api.diante.me/",
|
||||
"https://www.amazon.com/",
|
||||
"https://mail.google.com/"):
|
||||
with self.assertRaises(BrowserError, msg=bad):
|
||||
_check_allowed_url(bad)
|
||||
|
||||
def test_plain_entries_would_break_subdomains(self):
|
||||
# Why the candidate uses dot forms: plain "python.org"
|
||||
# does not match docs.python.org under the established
|
||||
# navigate semantics, so a plain-entry activation would
|
||||
# regress exactly the docs the filter was added for.
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = (
|
||||
"python.org,nodejs.org,go.dev,pkg.go.dev,rust-lang.org,"
|
||||
"developer.mozilla.org,crates.io,stackoverflow.com,"
|
||||
"cve.mitre.org,nvd.nist.gov,github.com,pypi.org,npmjs.org")
|
||||
with self.assertRaises(BrowserError):
|
||||
_check_allowed_url("https://docs.python.org/3/")
|
||||
|
||||
def test_file_allowed_localhost_blocked_under_candidate(self):
|
||||
out = self.browser.navigate("file:///tmp/page.html")
|
||||
self.assertTrue(out["ok"])
|
||||
with self.assertRaises(BrowserError):
|
||||
self.browser.navigate("http://localhost:8000/")
|
||||
# localhost blocking is a behaviour CHANGE vs unset:
|
||||
# local dev servers need an explicit localhost entry.
|
||||
self.assertEqual(self.factory.launch_count, 1)
|
||||
|
||||
def test_task_level_navigate_allow_and_block(self):
|
||||
out = self.browser.navigate("https://docs.python.org/3/")
|
||||
self.assertTrue(out["ok"])
|
||||
launches = self.factory.launch_count
|
||||
with self.assertRaises(BrowserError):
|
||||
self.browser.navigate("https://evil.com/")
|
||||
self.assertEqual(self.factory.launch_count, launches)
|
||||
|
||||
def test_unset_means_no_change(self):
|
||||
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||
out = self.browser.navigate("https://anything.example/")
|
||||
self.assertTrue(out["ok"])
|
||||
_check_allowed_url("https://anything.example/")
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Click-path allowlist tests for the Playwright browser (fake only).
|
||||
|
||||
Covers the continuation of the navigate-only allowlist: click /
|
||||
click_ref get the SAME check before the click when the target
|
||||
anchor href is resolvable, and all three click paths get a
|
||||
post-click URL check. click_at has no pre-click gate by design
|
||||
(coordinates identify no element). No real Chromium, no network,
|
||||
no real credentials -- values here are test placeholders.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from playwright_browser import Browser, BrowserError # noqa: E402
|
||||
from playwright_browser import _check_navigated_url # noqa: E402
|
||||
|
||||
|
||||
class _FakeLocator:
|
||||
def __init__(self, page, href):
|
||||
self._page = page
|
||||
self._href = href
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def click(self, timeout=None):
|
||||
self._page.calls.append(("locator_click",))
|
||||
if self._page.click_landing is not None:
|
||||
self._page.url = self._page.click_landing
|
||||
|
||||
def get_attribute(self, name):
|
||||
if name == "href":
|
||||
return self._href
|
||||
return None
|
||||
|
||||
def inner_text(self, timeout=None):
|
||||
return "text"
|
||||
|
||||
|
||||
class _FakeMouse:
|
||||
def __init__(self, page):
|
||||
self._page = page
|
||||
|
||||
def click(self, x, y):
|
||||
self._page.calls.append(("mouse_click", x, y))
|
||||
if self._page.click_landing is not None:
|
||||
self._page.url = self._page.click_landing
|
||||
|
||||
def move(self, x, y):
|
||||
self._page.calls.append(("mouse_move", x, y))
|
||||
|
||||
|
||||
class _FakePage:
|
||||
def __init__(self):
|
||||
self.url = "https://example.com/"
|
||||
self.calls = []
|
||||
self.href_map = {}
|
||||
self.click_landing = None
|
||||
self.mouse = _FakeMouse(self)
|
||||
|
||||
def title(self):
|
||||
return "Example"
|
||||
|
||||
def goto(self, url, timeout=None, wait_until=None):
|
||||
self.calls.append(("goto", url))
|
||||
self.url = url
|
||||
|
||||
def locator(self, selector):
|
||||
self.calls.append(("locator", selector))
|
||||
return _FakeLocator(self, self.href_map.get(selector))
|
||||
|
||||
def click(self, selector, timeout=None):
|
||||
self.calls.append(("click", selector))
|
||||
if self.click_landing is not None:
|
||||
self.url = self.click_landing
|
||||
|
||||
def fill(self, selector, text, timeout=None):
|
||||
self.calls.append(("fill", selector, text))
|
||||
|
||||
def evaluate(self, expression):
|
||||
self.calls.append(("evaluate", expression))
|
||||
return None
|
||||
|
||||
def screenshot(self, path=None):
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 10)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeHandle:
|
||||
def __init__(self, page):
|
||||
self._page = page
|
||||
|
||||
def new_page(self, viewport=None):
|
||||
return self._page
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakePW:
|
||||
def __init__(self, page):
|
||||
self._page = page
|
||||
self.launch_count = 0
|
||||
|
||||
def start(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def chromium(self):
|
||||
outer = self
|
||||
|
||||
class _Chromium:
|
||||
def launch(self, **kwargs):
|
||||
outer.launch_count += 1
|
||||
return _FakeHandle(outer._page)
|
||||
|
||||
return _Chromium()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
class _EnvGuard:
|
||||
def setUp(self):
|
||||
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
|
||||
|
||||
def tearDown(self):
|
||||
if self._old is None:
|
||||
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||
else:
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
|
||||
|
||||
|
||||
class ClickAllowlistTest(_EnvGuard, unittest.TestCase):
|
||||
def setUp(self):
|
||||
_EnvGuard.setUp(self)
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.page = _FakePage()
|
||||
self.factory = _FakePW(self.page)
|
||||
self.browser = Browser(cwd=self._tmp.name,
|
||||
proxy="http://proxy:8888",
|
||||
browser_factory=lambda: self.factory)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
_EnvGuard.tearDown(self)
|
||||
|
||||
def _start(self, url="https://example.com/"):
|
||||
return self.browser.navigate(url)
|
||||
|
||||
def test_click_allowed_href_executes(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com, .python.org"
|
||||
self._start()
|
||||
self.page.href_map["a.docs"] = "https://docs.python.org/3/"
|
||||
self.page.click_landing = "https://docs.python.org/3/"
|
||||
out = self.browser.click("a.docs")
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertIn(("click", "a.docs"), self.page.calls)
|
||||
self.assertEqual(out["url"], "https://docs.python.org/3/")
|
||||
|
||||
def test_click_blocked_href_refused_before_click(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
self._start()
|
||||
self.page.href_map["a.evil"] = "https://evil.com/x"
|
||||
self.page.click_landing = "https://evil.com/x"
|
||||
with self.assertRaises(BrowserError) as ctx:
|
||||
self.browser.click("a.evil")
|
||||
self.assertIn("evil.com", str(ctx.exception))
|
||||
self.assertNotIn(("click", "a.evil"), self.page.calls)
|
||||
self.assertEqual(self.browser._current_url,
|
||||
"https://example.com/")
|
||||
|
||||
def test_click_suffix_trick_blocked(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = ".python.org"
|
||||
self._start("https://python.org/")
|
||||
for bad in ("https://notpython.org/",
|
||||
"https://python.org.evil.com/"):
|
||||
self.page.href_map["a.bad"] = bad
|
||||
with self.assertRaises(BrowserError):
|
||||
self.browser.click("a.bad")
|
||||
self.assertNotIn(("click", "a.bad"), self.page.calls)
|
||||
self.page.calls.clear()
|
||||
|
||||
def test_click_plain_entry_is_exact_only(self):
|
||||
# Same semantics as navigate: a plain entry does NOT
|
||||
# match subdomains (that is what leading-dot entries
|
||||
# are for). Documented, not a bug: the activation
|
||||
# candidate therefore uses dot forms.
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "python.org"
|
||||
self._start("https://python.org/")
|
||||
self.page.href_map["a.docs"] = "https://docs.python.org/"
|
||||
with self.assertRaises(BrowserError):
|
||||
self.browser.click("a.docs")
|
||||
self.assertNotIn(("click", "a.docs"), self.page.calls)
|
||||
|
||||
def test_click_port_and_case_insensitive(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "Example.COM"
|
||||
self._start()
|
||||
self.page.href_map["a.p"] = "https://EXAMPLE.com:8443/p"
|
||||
self.page.click_landing = "https://EXAMPLE.com:8443/p"
|
||||
out = self.browser.click("a.p")
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
def test_click_file_href_allowed(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
self._start()
|
||||
self.page.href_map["a.file"] = "file:///tmp/page.html"
|
||||
self.page.click_landing = "file:///tmp/page.html"
|
||||
out = self.browser.click("a.file")
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
def test_click_localhost_needs_explicit_listing(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
self._start()
|
||||
self.page.href_map["a.local"] = "http://localhost:8000/"
|
||||
with self.assertRaises(BrowserError):
|
||||
self.browser.click("a.local")
|
||||
self.assertNotIn(("click", "a.local"), self.page.calls)
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "localhost"
|
||||
self.page.click_landing = "http://localhost:8000/"
|
||||
out = self.browser.click("a.local")
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
def test_click_relative_href_resolves_against_current(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
self._start("https://example.com/dir/")
|
||||
self.page.href_map["a.rel"] = "/other"
|
||||
self.page.click_landing = "https://example.com/other"
|
||||
out = self.browser.click("a.rel")
|
||||
self.assertTrue(out["ok"])
|
||||
# Fragment-only hrefs never navigate to another host.
|
||||
self.page.href_map["a.frag"] = "#section"
|
||||
self.page.click_landing = None
|
||||
out = self.browser.click("a.frag")
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
def test_click_unknown_target_post_click_detection(self):
|
||||
# Button / JS handler: no href, so no pre-click gate.
|
||||
# The click DOES execute; a blocked landing is then
|
||||
# reported (detection, not prevention -- the proxy 403
|
||||
# is the layer that stops the load in production).
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
self._start()
|
||||
self.page.href_map["button.go"] = None
|
||||
self.page.click_landing = "https://evil.com/"
|
||||
with self.assertRaises(BrowserError) as ctx:
|
||||
self.browser.click("button.go")
|
||||
self.assertIn("evil.com", str(ctx.exception))
|
||||
self.assertIn(("click", "button.go"), self.page.calls)
|
||||
# Same target landing on an allowed host is fine.
|
||||
self.page.click_landing = "https://example.com/ok"
|
||||
out = self.browser.click("button.go")
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
def test_click_unset_allowlist_unchanged(self):
|
||||
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||
self._start("https://anything.example/")
|
||||
self.page.href_map["a.evil"] = "https://evil.com/"
|
||||
self.page.click_landing = "https://evil.com/"
|
||||
out = self.browser.click("a.evil")
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertIn(("click", "a.evil"), self.page.calls)
|
||||
|
||||
def test_click_ref_blocked_href_refused_before_click(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
self._start()
|
||||
self.browser._ref_paths = {"e0": "a.evil", "e1": "a.docs"}
|
||||
self.page.href_map["a.evil"] = "https://evil.com/"
|
||||
self.page.href_map["a.docs"] = "https://example.com/docs"
|
||||
self.page.click_landing = "https://evil.com/"
|
||||
with self.assertRaises(BrowserError):
|
||||
self.browser.click_ref("e0")
|
||||
self.assertNotIn(("locator_click",), self.page.calls)
|
||||
self.page.click_landing = "https://example.com/docs"
|
||||
out = self.browser.click_ref("e1")
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertIn(("locator_click",), self.page.calls)
|
||||
|
||||
def test_click_at_post_click_only(self):
|
||||
# click_at has no pre-click gate by design; a blocked
|
||||
# landing raises after the click, an allowed one passes.
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
self._start()
|
||||
self.page.click_landing = "https://evil.com/"
|
||||
with self.assertRaises(BrowserError):
|
||||
self.browser.click_at(100, 200)
|
||||
self.assertIn(("mouse_click", 100.0, 200.0), self.page.calls)
|
||||
self.page.click_landing = "https://example.com/"
|
||||
out = self.browser.click_at(100, 200)
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
def test_check_navigated_url_skips_non_page_schemes(self):
|
||||
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||
# about:blank / mailto: / javascript: never navigate
|
||||
# the page to a host, so they must not raise here.
|
||||
_check_navigated_url("about:blank")
|
||||
_check_navigated_url("mailto:someone@example.com")
|
||||
_check_navigated_url("javascript:void(0)")
|
||||
_check_navigated_url("file:///tmp/page.html")
|
||||
with self.assertRaises(BrowserError):
|
||||
_check_navigated_url("https://evil.com/")
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Safe tenant-script upgrade tests (no Docker, no network).
|
||||
|
||||
The rule under test: only a PROVEN-unmodified tenant script
|
||||
(manifest match, known-shipped-hash match, missing, or already
|
||||
current) is auto-merged. A tenant-modified script is NEVER
|
||||
overwritten: it stays byte-identical, is reported as skipped,
|
||||
and gets exactly one deduped audit record. Reruns are idempotent.
|
||||
No real credentials appear here.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import playwright_browser as pb # noqa: E402
|
||||
from playwright_browser import ensure_codex_home_playwright # noqa: E402
|
||||
|
||||
NAMES = pb.PLAYWRIGHT_SCRIPT_NAMES
|
||||
|
||||
|
||||
def _write_scripts(d, contents):
|
||||
for name in NAMES:
|
||||
with open(os.path.join(d, name), "w", encoding="utf-8") as fh:
|
||||
fh.write(contents[name])
|
||||
|
||||
|
||||
def _read_scripts(d):
|
||||
out = {}
|
||||
for name in NAMES:
|
||||
with open(os.path.join(d, name), encoding="utf-8") as fh:
|
||||
out[name] = fh.read()
|
||||
return out
|
||||
|
||||
|
||||
def _v1():
|
||||
return {name: "# v1 %s\nprint('v1')\n" % name for name in NAMES}
|
||||
|
||||
|
||||
def _v2():
|
||||
return {name: "# v2 %s\nprint('v2')\n" % name for name in NAMES}
|
||||
|
||||
|
||||
class UpgradeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.src_v1 = os.path.join(self._tmp.name, "src-v1")
|
||||
self.src_v2 = os.path.join(self._tmp.name, "src-v2")
|
||||
self.home = os.path.join(self._tmp.name, "home")
|
||||
os.makedirs(self.src_v1)
|
||||
os.makedirs(self.src_v2)
|
||||
os.makedirs(self.home)
|
||||
_write_scripts(self.src_v1, _v1())
|
||||
_write_scripts(self.src_v2, _v2())
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _audit_records(self):
|
||||
path = os.path.join(self.home, "playwright_scripts_audit.jsonl")
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return [json.loads(line) for line in fh if line.strip()]
|
||||
|
||||
def test_fresh_seed_and_idempotent_rerun(self):
|
||||
out = ensure_codex_home_playwright(self.home,
|
||||
script_dir=self.src_v2)
|
||||
self.assertEqual(_read_scripts(self.home), _v2())
|
||||
self.assertFalse(out["script_skipped_modified"])
|
||||
self.assertEqual(self._audit_records(), [])
|
||||
# Config section appended exactly once.
|
||||
with open(os.path.join(self.home, "config.toml"),
|
||||
encoding="utf-8") as fh:
|
||||
config = fh.read()
|
||||
self.assertEqual(config.count("[mcp_servers.playwright]"), 1)
|
||||
out2 = ensure_codex_home_playwright(self.home,
|
||||
script_dir=self.src_v2)
|
||||
self.assertFalse(out2["script_changed"])
|
||||
self.assertFalse(out2["config_changed"])
|
||||
self.assertFalse(out2["script_refreshed"])
|
||||
self.assertEqual(_read_scripts(self.home), _v2())
|
||||
with open(os.path.join(self.home, "config.toml"),
|
||||
encoding="utf-8") as fh:
|
||||
self.assertEqual(
|
||||
fh.read().count("[mcp_servers.playwright]"), 1)
|
||||
|
||||
def test_unmodified_script_auto_merges(self):
|
||||
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
|
||||
out = ensure_codex_home_playwright(self.home,
|
||||
script_dir=self.src_v2)
|
||||
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
|
||||
self.assertFalse(out["script_skipped_modified"])
|
||||
self.assertEqual(_read_scripts(self.home), _v2())
|
||||
manifest_path = os.path.join(
|
||||
self.home, "playwright_scripts_manifest.json")
|
||||
with open(manifest_path, encoding="utf-8") as fh:
|
||||
manifest = json.load(fh)
|
||||
for name in NAMES:
|
||||
self.assertEqual(
|
||||
manifest[name],
|
||||
hashlib.sha256(_v2()[name].encode()).hexdigest())
|
||||
|
||||
def test_modified_script_untouched_with_audit(self):
|
||||
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
|
||||
tenant_path = os.path.join(self.home, NAMES[0])
|
||||
with open(tenant_path, "a", encoding="utf-8") as fh:
|
||||
fh.write("# tenant tweak: custom timeout\n")
|
||||
with open(tenant_path, encoding="utf-8") as fh:
|
||||
tenant_bytes = fh.read()
|
||||
out = ensure_codex_home_playwright(self.home,
|
||||
script_dir=self.src_v2)
|
||||
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
|
||||
self.assertEqual(out["script_differs"], [NAMES[0]])
|
||||
# Modified file byte-identical; the other two auto-merged.
|
||||
with open(tenant_path, encoding="utf-8") as fh:
|
||||
self.assertEqual(fh.read(), tenant_bytes)
|
||||
rest = _read_scripts(self.home)
|
||||
for name in NAMES[1:]:
|
||||
self.assertEqual(rest[name], _v2()[name])
|
||||
records = self._audit_records()
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0]["action"], "skipped_modified")
|
||||
self.assertEqual(records[0]["script"], NAMES[0])
|
||||
self.assertEqual(
|
||||
records[0]["dst_sha256"],
|
||||
hashlib.sha256(tenant_bytes.encode()).hexdigest())
|
||||
|
||||
def test_modified_rerun_does_not_rewrite_or_duplicate_audit(self):
|
||||
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
|
||||
tenant_path = os.path.join(self.home, NAMES[0])
|
||||
with open(tenant_path, "a", encoding="utf-8") as fh:
|
||||
fh.write("# tenant tweak\n")
|
||||
with open(tenant_path, encoding="utf-8") as fh:
|
||||
tenant_bytes = fh.read()
|
||||
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
|
||||
out = ensure_codex_home_playwright(self.home,
|
||||
script_dir=self.src_v2)
|
||||
self.assertFalse(out["script_changed"])
|
||||
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
|
||||
self.assertEqual(len(self._audit_records()), 1)
|
||||
with open(tenant_path, encoding="utf-8") as fh:
|
||||
self.assertEqual(fh.read(), tenant_bytes)
|
||||
# The manifest must NOT have learned the tenant hash:
|
||||
# otherwise the next upgrade would mistake the modified
|
||||
# file for untouched and overwrite it.
|
||||
with open(os.path.join(
|
||||
self.home, "playwright_scripts_manifest.json"),
|
||||
encoding="utf-8") as fh:
|
||||
manifest = json.load(fh)
|
||||
self.assertNotEqual(
|
||||
manifest.get(NAMES[0]),
|
||||
hashlib.sha256(tenant_bytes.encode()).hexdigest())
|
||||
|
||||
def test_legacy_no_manifest_known_hash_merges(self):
|
||||
# Legacy tenant: files seeded before manifests existed,
|
||||
# content exactly a known shipped version -> proven
|
||||
# unmodified, safe to merge even with no manifest.
|
||||
old = {name: "# legacy %s\n" % name for name in NAMES}
|
||||
_write_scripts(self.home, old)
|
||||
patched = {name: hashlib.sha256(old[name].encode()).hexdigest()
|
||||
for name in NAMES}
|
||||
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
|
||||
pb.KNOWN_SHIPPED_SCRIPT_HASHES = patched
|
||||
try:
|
||||
out = ensure_codex_home_playwright(self.home,
|
||||
script_dir=self.src_v2)
|
||||
finally:
|
||||
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
|
||||
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
|
||||
self.assertEqual(_read_scripts(self.home), _v2())
|
||||
|
||||
def test_legacy_no_manifest_unknown_hash_skips(self):
|
||||
old = {name: "# tenant-custom %s\n" % name for name in NAMES}
|
||||
_write_scripts(self.home, old)
|
||||
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
|
||||
pb.KNOWN_SHIPPED_SCRIPT_HASHES = {}
|
||||
try:
|
||||
out = ensure_codex_home_playwright(self.home,
|
||||
script_dir=self.src_v2)
|
||||
finally:
|
||||
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
|
||||
self.assertEqual(set(out["script_skipped_modified"]), set(NAMES))
|
||||
self.assertEqual(_read_scripts(self.home), old)
|
||||
self.assertEqual(len(self._audit_records()), 3)
|
||||
|
||||
def test_upgrade_never_touches_tenant_data_or_config(self):
|
||||
notes = os.path.join(self.home, "notes.txt")
|
||||
with open(notes, "w", encoding="utf-8") as fh:
|
||||
fh.write("tenant notes, must survive\n")
|
||||
config = os.path.join(self.home, "config.toml")
|
||||
with open(config, "w", encoding="utf-8") as fh:
|
||||
fh.write('[user]\nname = "tenant"\n\n'
|
||||
'[mcp_servers.playwright.env]\n'
|
||||
'FOO = "bar"\n')
|
||||
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
|
||||
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
|
||||
with open(notes, encoding="utf-8") as fh:
|
||||
self.assertEqual(fh.read(), "tenant notes, must survive\n")
|
||||
with open(config, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
# Existing sections intact, playwright section not
|
||||
# duplicated (it was absent, so appended exactly once).
|
||||
self.assertIn('[user]\nname = "tenant"', text)
|
||||
self.assertIn('FOO = "bar"', text)
|
||||
self.assertEqual(text.count("[mcp_servers.playwright]"), 1)
|
||||
|
||||
def test_known_shipped_hashes_are_well_formed(self):
|
||||
self.assertEqual(set(pb.KNOWN_SHIPPED_SCRIPT_HASHES), set(NAMES))
|
||||
for value in pb.KNOWN_SHIPPED_SCRIPT_HASHES.values():
|
||||
self.assertEqual(len(value), 64)
|
||||
int(value, 16) # must be hex
|
||||
Reference in new issue
Block a user