| # Copyright (c) 2009, Giampaolo Rodola'. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Smoke tests against the live docs site. |
| |
| Run with: |
| |
| PSUTIL_DOCS_ONLINE=1 python3 -m pytest docs/test_docs_online.py |
| """ |
| |
| import http.client |
| import os |
| import pathlib |
| import re |
| import socket |
| import ssl |
| import sys |
| import urllib.error |
| import urllib.request |
| import xml.etree.ElementTree as ET |
| from datetime import datetime |
| from datetime import timezone |
| from urllib.parse import urljoin |
| from urllib.parse import urlsplit |
| |
| import pytest |
| |
| HERE = pathlib.Path(__file__).resolve().parent |
| sys.path.insert(0, str(HERE)) |
| import conf # noqa: E402 |
| from testutil import UTILITY_PAGES # noqa: E402 |
| from testutil import feed_urls # noqa: E402 |
| from testutil import find_canonical # noqa: E402 |
| from testutil import og_value # noqa: E402 |
| |
| BASE = conf.html_baseurl.rstrip("/") + "/" |
| _parts = urlsplit(BASE) |
| ORIGIN = f"{_parts.scheme}://{_parts.netloc}/" |
| |
| pytestmark = pytest.mark.skipif( |
| not os.environ.get("PSUTIL_DOCS_ONLINE"), |
| reason="set PSUTIL_DOCS_ONLINE=1 to run live-site smoke tests", |
| ) |
| |
| |
| def fetch(url): |
| req = urllib.request.Request( |
| url, headers={"User-Agent": "psutil-docs-test"} |
| ) |
| with urllib.request.urlopen(req, timeout=30) as resp: |
| return resp.status, resp.read() |
| |
| |
| class TestLiveSite: |
| |
| def test_homepage_ok(self): |
| status, _ = fetch(BASE) |
| assert status == 200 |
| |
| def test_key_endpoints_reachable(self, subtests): |
| urls = [ |
| BASE + "sitemap.xml", |
| BASE + "objects.inv", |
| BASE + "blog/atom.xml", |
| ORIGIN + "robots.txt", |
| ] |
| for url in urls: |
| with subtests.test(url=url): |
| status, _ = fetch(url) |
| assert status == 200 |
| |
| def test_sitemap_has_many_urls(self): |
| _, body = fetch(BASE + "sitemap.xml") |
| assert body.count(b"<url>") >= 20 |
| |
| def test_fonts_are_self_hosted(self, subtests): |
| for name in ("inter-400.woff2", "fa-solid-subset.woff2"): |
| with subtests.test(font=name): |
| status, _ = fetch(BASE + "_static/fonts/" + name) |
| assert status == 200 |
| |
| def test_homepage_canonical_matches_baseurl(self): |
| url = find_canonical(fetch(BASE)[1].decode("utf-8", "replace")) |
| assert url is not None |
| assert url.startswith(BASE) |
| |
| def test_homepage_has_og_tags(self, subtests): |
| html = fetch(BASE)[1].decode("utf-8", "replace") |
| for prop in ("og:title", "og:image"): |
| with subtests.test(prop=prop): |
| assert f'property="{prop}"' in html |
| |
| def test_og_urls_match_baseurl(self, subtests): |
| html = fetch(BASE)[1].decode("utf-8", "replace") |
| for prop, prefix in (("og:url", BASE), ("og:image", ORIGIN)): |
| with subtests.test(prop=prop): |
| val = og_value(html, prop) |
| assert val is not None |
| assert val.startswith(prefix) |
| |
| def test_search_index_reachable(self): |
| status, _ = fetch(BASE + "searchindex.js") |
| assert status == 200 |
| |
| def test_http_redirects_to_https(self): |
| # Enforce-HTTPS: http must redirect to https. |
| conn = http.client.HTTPConnection(urlsplit(BASE).netloc, timeout=30) |
| try: |
| conn.request( |
| "GET", "/", headers={"User-Agent": "psutil-docs-test"} |
| ) |
| resp = conn.getresponse() |
| assert resp.status in {301, 302, 307, 308} |
| assert resp.getheader("Location", "").startswith("https://") |
| finally: |
| conn.close() |
| |
| def test_custom_404_page(self): |
| # A missing path returns the custom 404, not a bare error. |
| with pytest.raises(urllib.error.HTTPError) as exc: |
| fetch(BASE + "no-such-page-zzz.html") |
| assert exc.value.code == 404 |
| assert b"psutil" in exc.value.read().lower() |
| |
| def test_old_html_urls_are_gone(self): |
| # dirhtml serves content at /faq/, not /faq.html. The old .html |
| # form must 404, not silently serve a stale duplicate. |
| with pytest.raises(urllib.error.HTTPError) as exc: |
| fetch(BASE + "faq.html") |
| assert exc.value.code == 404 |
| |
| def test_404_deep_path_links_absolute(self, subtests): |
| # The custom 404 is served for any missing path, deep ones |
| # included. Every link on it must be absolute, else it resolves |
| # against the request path and 404s again. Regression: dirhtml |
| # left the body cross-references and logo relative. |
| with pytest.raises(urllib.error.HTTPError) as exc: |
| fetch(BASE + "no/such/deep/page-zzz") |
| assert exc.value.code == 404 |
| html = exc.value.read().decode("utf-8", "replace") |
| links = re.findall(r'(?:href|src)="([^"]+)"', html) |
| rel = [ |
| u |
| for u in links |
| if not u.startswith( |
| ("/", "http://", "https://", "//", "#", "mailto:") |
| ) |
| ] |
| assert rel == [] |
| # The absolute nav links must actually resolve. |
| targets = sorted(set(re.findall(r'"(/[a-z][a-z-]*/)"', html)))[:5] |
| assert targets |
| for t in targets: |
| with subtests.test(url=t): |
| assert fetch(ORIGIN.rstrip("/") + t)[0] == 200 |
| |
| def test_content_pages_reachable(self, subtests): |
| # dirhtml serves /api/; the no-slash /api 301-redirects to it |
| # (urllib follows). The .html form no longer exists. |
| for page in ("api/", "faq/", "api", "faq"): |
| with subtests.test(page=page): |
| status, body = fetch(BASE + page) |
| assert status == 200 |
| assert b"psutil" in body.lower() |
| |
| def test_atom_feed_rooted_at_baseurl(self): |
| root = ET.fromstring(fetch(BASE + "blog/atom.xml")[1]) |
| bad = [u for u in feed_urls(root) if not u.startswith(BASE)] |
| assert bad == [] |
| |
| def test_no_readthedocs_in_metadata(self): |
| html = fetch(BASE)[1].decode("utf-8", "replace") |
| head = html.split("</head>", 1)[0] |
| assert "readthedocs" not in head.lower() |
| |
| def test_homepage_internal_links_resolve(self, subtests): |
| html = fetch(BASE)[1].decode("utf-8", "replace") |
| pages = set() |
| for h in re.findall(r'href="([^"]+)"', html): |
| path = h.split("#", 1)[0] |
| if ( |
| h.startswith(("http://", "https://", "//", "mailto:", "#")) |
| or "_static/" in h |
| or "_images/" in h |
| or path.rstrip("/").rsplit("/", 1)[-1] in UTILITY_PAGES |
| ): |
| continue |
| pages.add(urljoin(BASE, path)) |
| for url in sorted(pages): |
| with subtests.test(link=url): |
| status, _ = fetch(url) |
| assert status == 200 |
| |
| def test_sitemap_has_lastmod(self): |
| # Regression: a shallow CI checkout drops git dates, leaving |
| # the sitemap without <lastmod>. Needs fetch-depth: 0. |
| body = fetch(BASE + "sitemap.xml")[1] |
| assert b"<lastmod>" in body |
| |
| def test_sitemap_urls_resolve(self, subtests): |
| # A page left in the sitemap after removal is a dead link handed |
| # to search engines. Spot-check a sample (homepage-links covers |
| # the linked pages; this covers the sitemap itself). |
| body = fetch(BASE + "sitemap.xml")[1].decode("utf-8", "replace") |
| for url in re.findall(r"<loc>([^<]+)</loc>", body)[::4]: |
| with subtests.test(url=url): |
| assert fetch(url)[0] == 200 |
| |
| def test_og_image_resolves(self): |
| # The social-card image must exist, else shared links render a |
| # blank preview. |
| img = og_value(fetch(BASE)[1].decode("utf-8", "replace"), "og:image") |
| assert img is not None |
| assert fetch(img)[0] == 200 |
| |
| def test_robots_references_sitemap(self): |
| body = fetch(ORIGIN + "robots.txt")[1].decode("utf-8", "replace") |
| assert "sitemap.xml" in body.lower() |
| |
| def test_no_external_stylesheets(self): |
| # Stylesheets / preloads must be self-hosted (analytics |
| # <script>s are deliberately external and don't count). |
| html = fetch(BASE)[1].decode("utf-8", "replace") |
| tags = re.findall( |
| r'<link\b[^>]*\brel="(?:stylesheet|preload)"[^>]*>', html |
| ) |
| hrefs = [h for t in tags for h in re.findall(r'\bhref="([^"]+)"', t)] |
| external = [ |
| u for u in hrefs if u.startswith(("http://", "https://", "//")) |
| ] |
| assert external == [] |
| |
| def test_utility_pages_noindex(self, subtests): |
| noindex = re.compile( |
| r'<meta[^>]*name="robots"[^>]*content="noindex"', re.IGNORECASE |
| ) |
| for name in ("genindex/", "404/"): |
| body = fetch(BASE + name)[1].decode("utf-8", "replace") |
| with subtests.test(page=name): |
| assert noindex.search(body) |
| |
| def test_tls_certificate_is_valid(self): |
| # create_default_context verifies chain + hostname, so an |
| # expired or mismatched cert raises here. Custom-domain certs |
| # are re-provisioned whenever the Pages domain is touched, |
| # which is exactly when they break. |
| host = urlsplit(BASE).netloc |
| ctx = ssl.create_default_context() |
| with socket.create_connection((host, 443), timeout=30) as sock: |
| with ctx.wrap_socket(sock, server_hostname=host) as ssock: |
| cert = ssock.getpeercert() |
| assert cert |
| expires = datetime.strptime( |
| cert["notAfter"], "%b %d %H:%M:%S %Y %Z" |
| ).replace(tzinfo=timezone.utc) |
| left = (expires - datetime.now(timezone.utc)).days |
| assert left > 20, f"TLS cert expires in {left} days" |
| |
| def test_no_slash_url_redirects_to_directory(self): |
| # /api/ is the canonical dirhtml URL. The no-slash form must |
| # 301 to it; serving 200 at both would be the same page under |
| # two URLs. |
| conn = http.client.HTTPSConnection(urlsplit(BASE).netloc, timeout=30) |
| try: |
| conn.request( |
| "GET", "/api", headers={"User-Agent": "psutil-docs-test"} |
| ) |
| resp = conn.getresponse() |
| assert resp.status in {301, 302, 307, 308} |
| assert resp.getheader("Location", "").endswith("/api/") |
| finally: |
| conn.close() |
| |
| def test_robots_allows_indexing(self): |
| # A stray "Disallow: /" would deindex the whole site, silently. |
| body = fetch(ORIGIN + "robots.txt")[1].decode("utf-8", "replace") |
| rules = [line.strip().lower() for line in body.splitlines()] |
| assert "disallow: /" not in rules |
| |
| def test_homepage_is_indexable(self): |
| # A global noindex (say a bad layout.html edit) would quietly |
| # drop the entire site from search results. |
| html = fetch(BASE)[1].decode("utf-8", "replace") |
| assert not re.search( |
| r'<meta[^>]*name="robots"[^>]*noindex', html, re.IGNORECASE |
| ) |
| |
| |
| class TestReadTheDocsRedirects: |
| """The old RTD site now redirects to psutil.io.""" |
| |
| HOST = "psutil.readthedocs.io" |
| |
| def get(self, path): |
| # Raw connection, so the redirect isn't followed. |
| conn = http.client.HTTPSConnection(self.HOST, timeout=30) |
| try: |
| conn.request( |
| "GET", path, headers={"User-Agent": "psutil-docs-test"} |
| ) |
| resp = conn.getresponse() |
| return resp.status, resp.getheader("Location", "") |
| finally: |
| conn.close() |
| |
| def test_dev_urls_redirect_to_psutil_io(self, subtests): |
| # 301, not 302: search engines only transfer ranking on a |
| # permanent redirect, and these URLs are linked all over. |
| for path in ("/en/latest/", "/latest/"): |
| with subtests.test(path=path): |
| status, location = self.get(path) |
| assert status == 301 |
| assert location == ORIGIN |
| |
| def test_stable_still_serves_released_docs(self): |
| # TEMPORARY, remove with the dev banner: it links here, so a |
| # catch-all redirect on the RTD side would break it. |
| status, _ = self.get("/stable/") |
| assert status == 200 |