diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1ada0c2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,143 @@ +"""Shared fixtures: fixture paths, a temporary database, and a local web server.""" + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import threading + +import pytest + +from mews import db + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +@pytest.fixture +def fail_page(): + """Read one of the non-conforming fixture pages.""" + + def read(name: str) -> bytes: + return (FIXTURES / "fail" / f"{name}.html").read_bytes() + + return read + + +@pytest.fixture +def connection(tmp_path): + """An empty database.""" + handle = db.connect(str(tmp_path / "mews.db")) + db.init(handle) + yield handle + handle.close() + + +class Site: + """A web server on loopback whose responses the test writes.""" + + def __init__(self) -> None: + self.routes: dict[str, tuple[int, dict[str, str], bytes, bool]] = {} + self.requests: list[tuple[str, dict[str, str]]] = [] + self.delay = 0.0 + self._server = ThreadingHTTPServer(("127.0.0.1", 0), _handler(self)) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + @property + def base(self) -> str: + """The address the server answers on.""" + host, port = self._server.server_address[:2] + return f"http://{host}:{port}" + + def add( + self, + path: str, + body: bytes = b"", + status: int = 200, + headers: dict[str, str] | None = None, + declare_length: bool = True, + ) -> str: + """Serve body at path, and return its full address. + + With declare_length off the response carries no Content-Length and ends + at the connection close, which is how a streaming body reaches the + fetcher's byte cap. + """ + self.routes[path] = (status, headers or {}, body, declare_length) + return self.base + path + + def close(self) -> None: + """Stop the server.""" + self._server.shutdown() + self._server.server_close() + + +def _handler(site: "Site"): + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + import time + + site.requests.append((self.path, dict(self.headers))) + if site.delay: + time.sleep(site.delay) + status, headers, body, declare_length = site.routes.get( + self.path, (404, {}, b"not here", True) + ) + if not declare_length: + self.protocol_version = "HTTP/1.0" + self.close_connection = True + self.send_response(status) + for name, value in headers.items(): + self.send_header(name, value) + if "Content-Type" not in headers and status == 200: + self.send_header("Content-Type", "text/html; charset=utf-8") + if declare_length: + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def log_message(self, *args): + pass + + def handle_one_request(self): + try: + super().handle_one_request() + except (BrokenPipeError, ConnectionResetError): + self.close_connection = True + + return Handler + + +@pytest.fixture +def site(): + """A local web server for the checks that need a live page.""" + server = Site() + yield server + server.close() + + +@pytest.fixture +def conforming(): + """A page that follows the spec, with a stylesheet link the test can point.""" + + def build(stylesheet: str = "mews-0.1.css", body: str = "

Hello.

") -> bytes: + return f""" + + + + Test site + + + + + + + {body} + + +""".encode() + + return build diff --git a/tests/fixtures/fail/bad-body-class.html b/tests/fixtures/fail/bad-body-class.html new file mode 100644 index 0000000..5eada79 --- /dev/null +++ b/tests/fixtures/fail/bad-body-class.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/bad-feed-type.html b/tests/fixtures/fail/bad-feed-type.html new file mode 100644 index 0000000..4cc3ce3 --- /dev/null +++ b/tests/fixtures/fail/bad-feed-type.html @@ -0,0 +1,15 @@ + + + + + Test + + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/billion-laughs.html b/tests/fixtures/fail/billion-laughs.html new file mode 100644 index 0000000..211cce1 --- /dev/null +++ b/tests/fixtures/fail/billion-laughs.html @@ -0,0 +1,18 @@ + + + + +]> + + + Test + + + + + +

&lol2;

+ + diff --git a/tests/fixtures/fail/bom.html b/tests/fixtures/fail/bom.html new file mode 100644 index 0000000..2bfb323 --- /dev/null +++ b/tests/fixtures/fail/bom.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/class-on-p.html b/tests/fixtures/fail/class-on-p.html new file mode 100644 index 0000000..830b9a3 --- /dev/null +++ b/tests/fixtures/fail/class-on-p.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/data-uri-img.html b/tests/fixtures/fail/data-uri-img.html new file mode 100644 index 0000000..e878901 --- /dev/null +++ b/tests/fixtures/fail/data-uri-img.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

x

+ + diff --git a/tests/fixtures/fail/div.html b/tests/fixtures/fail/div.html new file mode 100644 index 0000000..05401a4 --- /dev/null +++ b/tests/fixtures/fail/div.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/empty-body.html b/tests/fixtures/fail/empty-body.html new file mode 100644 index 0000000..3ab8ac7 --- /dev/null +++ b/tests/fixtures/fail/empty-body.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + + + + diff --git a/tests/fixtures/fail/head-link-before-meta.html b/tests/fixtures/fail/head-link-before-meta.html new file mode 100644 index 0000000..440480d --- /dev/null +++ b/tests/fixtures/fail/head-link-before-meta.html @@ -0,0 +1,13 @@ + + + + + Test + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/img-no-alt.html b/tests/fixtures/fail/img-no-alt.html new file mode 100644 index 0000000..16cc948 --- /dev/null +++ b/tests/fixtures/fail/img-no-alt.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

+ + diff --git a/tests/fixtures/fail/img-width.html b/tests/fixtures/fail/img-width.html new file mode 100644 index 0000000..8cdb740 --- /dev/null +++ b/tests/fixtures/fail/img-width.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

A square

+ + diff --git a/tests/fixtures/fail/input-type-email.html b/tests/fixtures/fail/input-type-email.html new file mode 100644 index 0000000..6dd029c --- /dev/null +++ b/tests/fixtures/fail/input-type-email.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

+ + diff --git a/tests/fixtures/fail/internal-subset.html b/tests/fixtures/fail/internal-subset.html new file mode 100644 index 0000000..efe6980 --- /dev/null +++ b/tests/fixtures/fail/internal-subset.html @@ -0,0 +1,16 @@ + + +]> + + + Test + + + + + +

&boom;

+ + diff --git a/tests/fixtures/fail/link-bad-rel.html b/tests/fixtures/fail/link-bad-rel.html new file mode 100644 index 0000000..8931463 --- /dev/null +++ b/tests/fixtures/fail/link-bad-rel.html @@ -0,0 +1,15 @@ + + + + + Test + + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/marker-wrong-version.html b/tests/fixtures/fail/marker-wrong-version.html new file mode 100644 index 0000000..1a1c5de --- /dev/null +++ b/tests/fixtures/fail/marker-wrong-version.html @@ -0,0 +1,13 @@ + + + + + Test + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/meta-bad-name.html b/tests/fixtures/fail/meta-bad-name.html new file mode 100644 index 0000000..508e6ed --- /dev/null +++ b/tests/fixtures/fail/meta-bad-name.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/nested-table.html b/tests/fixtures/fail/nested-table.html new file mode 100644 index 0000000..c6d2969 --- /dev/null +++ b/tests/fixtures/fail/nested-table.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +
x
+ + diff --git a/tests/fixtures/fail/no-lang.html b/tests/fixtures/fail/no-lang.html new file mode 100644 index 0000000..75d4855 --- /dev/null +++ b/tests/fixtures/fail/no-lang.html @@ -0,0 +1,12 @@ + + + + + Test + + + + +

Hello.

+ diff --git a/tests/fixtures/fail/no-marker.html b/tests/fixtures/fail/no-marker.html new file mode 100644 index 0000000..b4498e4 --- /dev/null +++ b/tests/fixtures/fail/no-marker.html @@ -0,0 +1,13 @@ + + + + + Test + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/no-viewport.html b/tests/fixtures/fail/no-viewport.html new file mode 100644 index 0000000..d2b0e19 --- /dev/null +++ b/tests/fixtures/fail/no-viewport.html @@ -0,0 +1,13 @@ + + + + + Test + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/no-xml-decl.html b/tests/fixtures/fail/no-xml-decl.html new file mode 100644 index 0000000..81ea8f8 --- /dev/null +++ b/tests/fixtures/fail/no-xml-decl.html @@ -0,0 +1,13 @@ + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/not-wellformed.html b/tests/fixtures/fail/not-wellformed.html new file mode 100644 index 0000000..1c4b662 --- /dev/null +++ b/tests/fixtures/fail/not-wellformed.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello. + + diff --git a/tests/fixtures/fail/offsite-img.html b/tests/fixtures/fail/offsite-img.html new file mode 100644 index 0000000..71afec2 --- /dev/null +++ b/tests/fixtures/fail/offsite-img.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

x

+ + diff --git a/tests/fixtures/fail/script.html b/tests/fixtures/fail/script.html new file mode 100644 index 0000000..456a39f --- /dev/null +++ b/tests/fixtures/fail/script.html @@ -0,0 +1,15 @@ + + + + + Test + + + + + +

Hello.

+ + + diff --git a/tests/fixtures/fail/style-attr.html b/tests/fixtures/fail/style-attr.html new file mode 100644 index 0000000..3cffc45 --- /dev/null +++ b/tests/fixtures/fail/style-attr.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/style-element.html b/tests/fixtures/fail/style-element.html new file mode 100644 index 0000000..2d53139 --- /dev/null +++ b/tests/fixtures/fail/style-element.html @@ -0,0 +1,15 @@ + + + + + Test + + + + + +

Hello.

+ + + diff --git a/tests/fixtures/fail/sup.html b/tests/fixtures/fail/sup.html new file mode 100644 index 0000000..561e1bb --- /dev/null +++ b/tests/fixtures/fail/sup.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

E = mc2

+ + diff --git a/tests/fixtures/fail/table-in-li.html b/tests/fixtures/fail/table-in-li.html new file mode 100644 index 0000000..cd87b2f --- /dev/null +++ b/tests/fixtures/fail/table-in-li.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + + + + diff --git a/tests/fixtures/fail/two-markers.html b/tests/fixtures/fail/two-markers.html new file mode 100644 index 0000000..4b3338c --- /dev/null +++ b/tests/fixtures/fail/two-markers.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/two-stylesheets.html b/tests/fixtures/fail/two-stylesheets.html new file mode 100644 index 0000000..0296ba6 --- /dev/null +++ b/tests/fixtures/fail/two-stylesheets.html @@ -0,0 +1,15 @@ + + + + + Test + + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/wrong-doctype.html b/tests/fixtures/fail/wrong-doctype.html new file mode 100644 index 0000000..16a6999 --- /dev/null +++ b/tests/fixtures/fail/wrong-doctype.html @@ -0,0 +1,14 @@ + + + + + Test + + + + + +

Hello.

+ + diff --git a/tests/fixtures/fail/wrong-namespace.html b/tests/fixtures/fail/wrong-namespace.html new file mode 100644 index 0000000..edbbe9a --- /dev/null +++ b/tests/fixtures/fail/wrong-namespace.html @@ -0,0 +1,12 @@ + + + + + Test + + + + +

Hello.

+ diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..2828374 --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,50 @@ +"""The validator against the spec it implements. + +These are the tests that notice when the spec moves and the validator doesn't. +""" + +from pathlib import Path +import re + +from mews.lint import CHECKS, NOT_CHECKED + +SPEC = Path(__file__).resolve().parent.parent / "doc" / "SPEC.md" +KEYWORD = re.compile(r"\b(MUST NOT|MUST|SHOULD NOT|SHOULD)\b") +HEADING = re.compile(r"^#{2,3} (\d+(?:\.\d+)?)[. ]") + + +def sections_with_rules() -> set[str]: + """Every section of SPEC.md 3 to 7 that states a MUST or a SHOULD.""" + found: set[str] = set() + current = "" + for line in SPEC.read_text().splitlines(): + heading = HEADING.match(line) + if heading: + current = heading.group(1) + continue + if current[:1] in "34567" and KEYWORD.search(line): + found.add(current) + return found + + +def test_every_rule_in_the_spec_is_checked_or_listed_as_unchecked(): + covered = {check.section for check in CHECKS} | set(NOT_CHECKED) + missing = sections_with_rules() - covered + assert not missing, ( + f"sections {sorted(missing)} of SPEC.md state rules that mews.lint " + "neither checks nor lists in NOT_CHECKED" + ) + + +def test_the_unchecked_list_says_why(): + for section, reason in NOT_CHECKED.items(): + assert len(reason) > 20, section + + +def test_check_codes_are_unique(): + codes = [check.code for check in CHECKS] + assert len(codes) == len(set(codes)) + + +def test_every_check_is_a_must_or_a_should(): + assert {check.level for check in CHECKS} == {"must", "should"} diff --git a/tests/test_css.py b/tests/test_css.py new file mode 100644 index 0000000..ed08ce7 --- /dev/null +++ b/tests/test_css.py @@ -0,0 +1,60 @@ +"""The section 5.2 comparison: an unmodified copy, plus added @font-face rules.""" + +from mews import css + +CANONICAL = ( + __import__("pathlib").Path(__file__).resolve().parent.parent / "mews-0.1.css" +).read_text() + +FONT_FACE = """@font-face { + font-family: "Atkinson Hyperlegible"; + src: url(/fonts/atkinson.woff2) format("woff2"); +} + +""" + + +def test_an_unmodified_copy_passes(): + assert css.compare(CANONICAL).equal + + +def test_line_endings_and_trailing_spaces_do_not_matter(): + mangled = CANONICAL.replace("\n", " \r\n") + "\n\n\n" + assert css.compare(mangled).equal + + +def test_an_added_font_face_passes_and_is_read(): + result = css.compare(FONT_FACE + CANONICAL) + assert result.equal + assert result.font_urls == ["/fonts/atkinson.woff2"] + + +def test_a_font_face_at_the_end_also_passes(): + assert css.compare(CANONICAL + "\n" + FONT_FACE).equal + + +def test_a_changed_rule_fails_with_a_line_number(): + result = css.compare(CANONICAL.replace("#faf8f3", "#ffffff")) + assert not result.equal + assert result.diff_line == 5 + assert "background-color" in result.diff + + +def test_a_reordered_rule_fails(): + lines = CANONICAL.split("\n") + swapped = "\n".join([*lines[:4], lines[5], lines[4], *lines[6:]]) + assert not css.compare(swapped).equal + + +def test_a_removed_rule_fails(): + assert not css.compare(CANONICAL.replace("hr {", "hr-off {")).equal + + +def test_an_import_is_noticed(): + assert css.compare('@import url("x.css");\n' + CANONICAL).has_import + + +def test_a_font_face_inside_a_media_query_is_not_stripped(): + inner = "@media screen {\n" + FONT_FACE + "}\n" + result = css.compare(CANONICAL + inner) + assert not result.equal diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..956dad8 --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,30 @@ +"""The packaged copies of the stylesheet and the DTD, against their originals. + +The validator has to find both when it is installed as a wheel with nothing but +the package on disk, so mews/data holds copies. They must not drift. +""" + +from importlib import resources +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +COPIES = { + "mews-0.1.css": ROOT / "mews-0.1.css", + "mews-0.1.dtd": ROOT / "dtd" / "mews-0.1.dtd", +} + + +def test_packaged_copies_match_the_originals(): + for name, original in COPIES.items(): + packaged = resources.files("mews.data").joinpath(name).read_bytes() + assert packaged == original.read_bytes(), ( + f"mews/data/{name} differs from {original}. Copy the original over it." + ) + + +def test_the_spec_quotes_the_same_stylesheet(): + """SPEC.md 5.1 prints the stylesheet in full; it has to be the same file.""" + spec = (ROOT / "doc" / "SPEC.md").read_text() + quoted = spec.split("```css\n", 1)[1].split("```", 1)[0] + assert quoted.strip() == (ROOT / "mews-0.1.css").read_text().strip() diff --git a/tests/test_domains.py b/tests/test_domains.py new file mode 100644 index 0000000..9bb3468 --- /dev/null +++ b/tests/test_domains.py @@ -0,0 +1,31 @@ +"""Registrable-domain comparison, which is what the same-site rules rest on.""" + +import pytest + +from mews.fetch import registered_domain, same_site + +CASES = [ + ("example.com", "example.com"), + ("img.example.com", "example.com"), + ("a.b.example.co.uk", "example.co.uk"), + ("EXAMPLE.COM", "example.com"), + ("example.com.", "example.com"), + ("a.github.io", "a.github.io"), + ("not-a-tld", None), + ("", None), +] + + +@pytest.mark.parametrize(("host", "expected"), CASES) +def test_registrable_domain(host, expected): + assert registered_domain(host) == expected + + +def test_subdomains_of_one_domain_are_one_site(): + assert same_site("example.com", "img.example.com") + + +def test_two_sites_on_a_shared_suffix_are_not_one_site(): + """The reason this uses a public suffix list rather than counting labels.""" + assert not same_site("a.github.io", "b.github.io") + assert not same_site("example.co.uk", "evil.co.uk") diff --git a/tests/test_fetch.py b/tests/test_fetch.py new file mode 100644 index 0000000..0f461ca --- /dev/null +++ b/tests/test_fetch.py @@ -0,0 +1,175 @@ +"""The fetcher's refusals. + +The submit endpoint fetches addresses strangers type, so this is the test that +keeps the service from being used as a probe against its own machine. +""" + +import pytest + +from mews import fetch +from mews.fetch import Fetcher, FetchError, UrlError, normalise_url, vet_address + +PRIVATE = [ + "127.0.0.1", + "127.1.2.3", + "10.0.0.1", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "0.0.0.0", + "100.64.0.1", + "192.0.0.1", + "198.18.0.1", + "192.0.2.1", + "203.0.113.1", + "240.0.0.1", + "255.255.255.255", + "::1", + "::", + "fd00::1", + "fe80::1", + "::ffff:127.0.0.1", + "2002:7f00:1::", + "64:ff9b::7f00:1", + "2001:db8::1", +] + +PUBLIC = ["93.184.216.34", "1.1.1.1", "194.68.44.29", "2606:2800:220:1::"] + +REFUSED = [ + "file:///etc/passwd", + "javascript:alert(1)", + "gopher://example.com/", + "http://user:pw@example.com/", + "http://example.com:22/", + "http://127.0.0.1/", + "http://2130706433/", + "http://0x7f.1/", + "http://[::1]/", + "http://localhost/", + "http://box.internal/", + "http://printer.local/", + "http://not-a-tld/", + "https://exa mple.com/", + "https://a_b.example.com/", + "", +] + + +@pytest.mark.parametrize("address", PRIVATE) +def test_addresses_off_the_public_internet_are_refused(address): + with pytest.raises(UrlError): + vet_address(address) + + +@pytest.mark.parametrize("address", PUBLIC) +def test_public_addresses_pass(address): + vet_address(address) + + +@pytest.mark.parametrize("url", REFUSED) +def test_addresses_the_checker_will_not_read(url): + with pytest.raises(UrlError): + normalise_url(url) + + +def test_a_bare_domain_becomes_https(): + assert normalise_url("example.com") == "https://example.com/" + + +def test_the_fragment_is_dropped(): + assert normalise_url("https://example.com/a#b") == "https://example.com/a" + + +def test_the_host_is_lowercased_and_punycoded(): + assert normalise_url("https://EXAMPLE.com/") == "https://example.com/" + + +def test_the_extra_blocked_ranges_come_from_the_environment(monkeypatch): + """The deployment blocks its own public address this way.""" + monkeypatch.setattr( + fetch, "_EXTRA_NETS", [__import__("ipaddress").ip_network("194.68.44.28/32")] + ) + with pytest.raises(UrlError): + vet_address("194.68.44.28") + vet_address("194.68.44.29") + + +def test_loopback_is_allowed_only_when_asked(): + with pytest.raises(UrlError): + vet_address("127.0.0.1") + vet_address("127.0.0.1", allow_loopback=True) + + +# --- against a real server on loopback --------------------------------- + + +def test_a_page_is_read(site): + url = site.add("/a.html", b"

hi

") + with Fetcher(allow_loopback=True) as fetcher: + response = fetcher.get(url) + assert response.status == 200 + assert response.body == b"

hi

" + + +def test_the_host_header_carries_the_name_not_the_address(site): + url = site.add("/a.html", b"x") + with Fetcher(allow_loopback=True) as fetcher: + fetcher.get(url) + _, headers = site.requests[-1] + assert headers["Host"] == url.split("//")[1].split("/")[0] + + +def test_a_redirect_is_followed(site): + target = site.add("/b.html", b"there") + site.add("/a.html", status=302, headers={"Location": target}) + with Fetcher(allow_loopback=True) as fetcher: + response = fetcher.get(site.base + "/a.html") + assert response.body == b"there" + assert response.url == target + + +def test_a_redirect_to_a_private_address_is_refused(site): + site.add("/a.html", status=302, headers={"Location": "http://10.0.0.1/"}) + with Fetcher(allow_loopback=True) as fetcher, pytest.raises(UrlError): + fetcher.get(site.base + "/a.html") + + +def test_a_redirect_loop_stops(site): + site.add("/a.html", status=302, headers={"Location": site.base + "/a.html"}) + with Fetcher(allow_loopback=True) as fetcher, pytest.raises(FetchError): + fetcher.get(site.base + "/a.html") + + +def test_a_body_over_the_cap_is_cut_short(site): + url = site.add("/big.html", b"x" * 400_000, declare_length=False) + with Fetcher(allow_loopback=True) as fetcher: + response = fetcher.get(url) + assert response.truncated + assert len(response.body) == fetch.PAGE_CAP + + +def test_a_declared_length_over_the_cap_is_refused_unread(site): + url = site.add("/big.html", b"x" * 100, headers={"Content-Length": "9999999"}) + with Fetcher(allow_loopback=True) as fetcher, pytest.raises(FetchError): + fetcher.get(url) + + +def test_a_slow_page_times_out(site): + url = site.add("/slow.html", b"x") + site.delay = 0.3 + with ( + Fetcher(allow_loopback=True, budget=0.05) as fetcher, + pytest.raises(FetchError), + ): + fetcher.get(url) + + +def test_a_missing_page_is_reported_by_status(site): + with Fetcher(allow_loopback=True) as fetcher: + assert fetcher.get(site.base + "/nope.html").status == 404 + + +def test_a_name_that_does_not_resolve_is_reported(): + with Fetcher() as fetcher, pytest.raises(FetchError): + fetcher.get("https://this-name-does-not-exist.example/") diff --git a/tests/test_lint_dtd.py b/tests/test_lint_dtd.py new file mode 100644 index 0000000..bf29cb9 --- /dev/null +++ b/tests/test_lint_dtd.py @@ -0,0 +1,78 @@ +"""The DTD check, and the mapping from libxml2's messages to spec sections. + +One test per message shape, so the mapping table in mews.lint stays pinned to +what libxml2 actually says. +""" + +import pytest + +from mews.lint import validate_bytes + +# fixture name -> (section, level, code) +CASES = { + "script": ("4.1", "must", "dtd-element"), + "div": ("4.1", "must", "dtd-element"), + "sup": ("4.1", "must", "dtd-element"), + "style-element": ("5", "must", "dtd-styling"), + "style-attr": ("5", "must", "dtd-styling"), + "class-on-p": ("4.1", "must", "dtd-attribute"), + "img-width": ("4.1", "must", "dtd-attribute"), + "img-no-alt": ("4.1", "must", "dtd-required-attribute"), + "input-type-email": ("4.1", "must", "dtd-value"), + "nested-table": ("4.2", "must", "dtd-nested-table"), + "table-in-li": ("4.2", "must", "dtd-nested-table"), + "bad-body-class": ("5.3", "must", "dtd-variant"), + "empty-body": ("4.1", "must", "dtd-content"), + "head-link-before-meta": ("3.3", "must", "dtd-head"), + "link-bad-rel": ("3.3", "must", "link-rel"), +} + + +@pytest.mark.parametrize(("name", "expected"), CASES.items()) +def test_dtd_failure_maps_to_its_section(fail_page, name, expected): + report = validate_bytes(fail_page(name)) + section, level, code = expected + match = [f for f in report.findings if f.code == code] + assert match, f"{name}: no {code} finding in {[f.code for f in report.findings]}" + assert match[0].section == section + assert match[0].level == level + assert not report.conforms + + +def test_one_mistake_is_one_finding(fail_page): + """A ', " + "'listed', '2026-01-01T00:00:00+00:00')" + ) + connection.commit() + target = tmp_path / "directory.html" + pages.write_directory(str(target), db.listed(connection)) + text = target.read_text() + assert "