from pathlib import Path import pytest from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.http import HttpResponse from django.template.loader import render_to_string from django.test import RequestFactory from archivebox.base_models.admin import KeyValueWidget from archivebox.config.common import get_config from archivebox.core.middleware import AdminCookieIsolationMiddleware from archivebox.core.setup_wizard import get_base_url_mismatch_context, get_setup_wizard_context from archivebox.core.templatetags.core_tags import system_warnings_banner from archivebox.core.views import HealthCheckView STATIC_DIR = Path(__file__).parents[1] / "templates" / "static" SETUP_WIZARD_CSS = (STATIC_DIR / "setup_wizard.css").read_text() SETUP_WIZARD_JS = (STATIC_DIR / "setup_wizard.js").read_text() def test_key_value_widget_renders_enum_autocomplete_metadata(): html = str( KeyValueWidget().render( "config", {"CHROME_WAIT_FOR": "load"}, attrs={"id": "id_config"}, ), ) assert '"enum": ["domcontentloaded", "load", "networkidle0", "networkidle2"]' in html assert 'class="kv-value-options"' in html assert 'class="kv-help"' in html assert "configureValueInput_id_config" in html assert "describeMeta_id_config" in html assert "validateValueAgainstMeta_id_config" in html def test_key_value_widget_renders_numeric_and_pattern_constraints(): html = str(KeyValueWidget().render("config", {}, attrs={"id": "id_config"})) assert '"minimum": 0' in html assert '"pattern": "^\\\\d+,\\\\d+$"' in html assert "Expected: " in html assert "Example: " in html assert "setValueValidationState_id_config" in html assert "coerceValueForStorage_id_config" in html def test_key_value_widget_accepts_common_boolean_spellings(): html = str(KeyValueWidget().render("config", {"CHECK_SSL_VALIDITY": "True"}, attrs={"id": "id_config"})) assert "enumValues = ['True', 'False']" in html assert "raw.toLowerCase()" in html assert "lowered === 'true' || raw === '1'" in html assert "lowered === 'false' || raw === '0'" in html def test_key_value_widget_shows_array_and_object_examples_and_binary_rules(): html = str(KeyValueWidget().render("config", {"NODE_BINARY": "node"}, attrs={"id": "id_config"})) assert 'Example: ["--extra-arg"]' in html assert "Example: wget or /usr/bin/wget" in html assert "validateBinaryValue_id_config" in html assert "meta.key.endsWith('_BINARY')" in html assert "Binary paths cannot contain quotes" in html def test_key_value_widget_falls_back_to_binary_validation_for_unknown_binary_keys(): html = str( KeyValueWidget().render( "config", {"NODE_BINARY": "/opt/homebrew/bin/node"}, attrs={"id": "id_config"}, ), ) assert "function getMetaForKey_id_config" in html assert "if (key.endsWith('_BINARY'))" in html assert "Path to binary executable" in html def test_key_value_widget_prefills_known_config_values_from_query_string(): html = str(KeyValueWidget().render("config", {}, attrs={"id": "id_config"})) assert "new URLSearchParams(window.location.search)" in html assert "prefillConfigFromQuery_id_config" in html assert "configMeta_id_config[key]" in html assert "valueInput.value = value" in html assert "params.forEach(function(value, key)" in html assert "configMeta_id_config[key]" in html assert "consumedConfigKeys.forEach" in html assert "window.history.replaceState(null, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash)" in html def test_unconfigured_superuser_banner_uses_browser_assisted_setup_wizard(): html = render_to_string( "core/system_warnings_banner.html", { "mode": "unconfigured", "can_configure": True, "canonical_host": "archivebox.example.test:8000", "suggested_base_url": "http://archivebox.example.test:8000", "machine_admin_url": "/admin/machine/machine/current/change/", "public_index": True, "public_add_view": False, "permissions": "private", }, ) assert html.count('id="archivebox-setup-wizard"') == 1 assert 'id="archivebox-system-warning-banner"' not in html assert "⚠ base_url not set" not in html assert "Setup access to your ArchiveBox Server: archivebox.example.test:8000" in html assert 'id="archivebox-setup-base-url"' in html assert 'id="archivebox-setup-security-mode"' in html assert 'id="archivebox-setup-public-index"' in html assert 'id="archivebox-setup-public-add"' in html assert 'id="archivebox-setup-permissions"' in html assert 'id="archivebox-setup-effective-mode"' in html assert 'id="archivebox-setup-browser-url"' in html assert 'id="archivebox-setup-configured-url"' in html assert 'id="archivebox-setup-url-match"' in html assert 'name="archivebox-hosting-location"' in html assert 'name="archivebox-dns-mode"' in html assert 'name="archivebox-tls-mode"' in html assert ( html.index('name="archivebox-dns-mode" value="localhost"') < html.index( 'name="archivebox-dns-mode" value="single"', ) < html.index('name="archivebox-dns-mode" value="wildcard"') ) assert ( html.index('name="archivebox-tls-mode" value="localhost"') < html.index( 'name="archivebox-tls-mode" value="none"', ) < html.index('name="archivebox-tls-mode" value="single"') < html.index('name="archivebox-tls-mode" value="wildcard"') ) assert 'id="archivebox-setup-later"' not in html assert 'id="archivebox-setup-review" disabled' in html assert "as BASE_URL below" not in html assert "Required:" not in html assert "safe-onedomain-nojsreplay" in html assert "simplest self-hosted setup" in html assert "safe-subdomains-fullreplay" in html assert "unsafe-onedomain-noadmin" in html assert "danger-onedomain-fullreplay" in html assert "Wildcard DNS" in html assert "Modern browsers support wildcard *.archivebox.localhost with no DNS or HTTPS setup required." in html assert "A archivebox.example.com" in html assert "JavaScript still runs during capture" in html assert "will not replay JavaScript unless wildcard DNS is used" in html assert "Wildcard TLS" in html assert "Never enable on-demand TLS or request individual certificates for snapshot subdomains." in html assert "Restart ArchiveBox after saving an HTTPS BASE_URL for the first time." in html assert "Cloudflare, Nginx Proxy Manager, Caddy, Traefik, Tailscale" in html assert "How will HTTPS traffic reach this ArchiveBox server?" in html assert "This mode is not allowed unless also using Single-domain DNS." in html assert "No TLS, private networks only" in html assert "trusted LAN, VPN, or private network" in html assert "In-browser WARC viewing will be disabled unless using localhost or HTTPS" in html assert "BASE_URL" in html assert "SERVER_SECURITY_MODE" in html assert "PUBLIC_INDEX" in html assert "PUBLIC_ADD_VIEW" in html assert "Be careful archiving any secret URLs or private share URLs" in html assert "Beware capturing / viewing archives of URLs that contain malicious JS" in html assert "PERMISSIONS" in html assert "/admin/machine/machine/current/change/" in html assert "setup_wizard.css" in html assert 'data-status-id="archivebox-setup-hosting-status"' in html assert 'aria-controls="archivebox-setup-hosting-options"' in html assert 'data-status-id="archivebox-setup-dns-status"' in html assert 'aria-controls="archivebox-setup-dns-options"' in html assert 'data-status-id="archivebox-setup-tls-status"' in html assert 'aria-controls="archivebox-setup-tls-options"' in html assert "setup_wizard.js?v=20260726-1" in html def test_first_admin_setup_suppresses_unconfigured_warning(): assert system_warnings_banner({"first_admin_setup": True}) == {"mode": ""} def test_setup_wizard_assets_enforce_selection_and_access_requirements(): assert "border-color:#15803d; background:#f0fdf4" in SETUP_WIZARD_CSS assert "accent-color:#15803d" in SETUP_WIZARD_CSS assert "#archivebox-setup-title code { font-size:inherit; line-height:inherit; }" in SETUP_WIZARD_CSS assert ".abx-question.is-collapsed .abx-question-options { display:none; }" in SETUP_WIZARD_CSS assert ".abx-question.is-collapsed { padding:8px 12px 10px; }" in SETUP_WIZARD_CSS assert ".abx-question.is-collapsed .abx-question-status { margin:0; padding:3px 6px 0; background:transparent; }" in SETUP_WIZARD_CSS assert ".abx-question legend { box-sizing:border-box; width:100%;" in SETUP_WIZARD_CSS assert "flex:0 0 22px" in SETUP_WIZARD_CSS assert ".abx-question.is-valid legend { background:#f0fdf4; }" in SETUP_WIZARD_CSS assert ".abx-question.is-valid { border-color:#15803d; background:#f0fdf4" in SETUP_WIZARD_CSS assert ".abx-question.is-invalid { border-color:#dc2626;" in SETUP_WIZARD_CSS assert "updateQuestionDefaults" not in SETUP_WIZARD_JS assert "function initializeQuestionSections()" in SETUP_WIZARD_JS assert "var isValid = statusText.indexOf('✅') === 0" in SETUP_WIZARD_JS assert "var isInvalid = statusText.indexOf('❌') === 0" in SETUP_WIZARD_JS assert "if (isValid) setCollapsed(true)" in SETUP_WIZARD_JS assert "if (isInvalid) setCollapsed(false)" in SETUP_WIZARD_JS assert "toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true')" in SETUP_WIZARD_JS assert "toggleIcon.textContent = collapsed ? '▸' : '▾'" in SETUP_WIZARD_JS assert "canonicalHost" not in SETUP_WIZARD_JS assert "Browser URL matches BASE_URL." in SETUP_WIZARD_JS assert "Browser URL matches admin.BASE_URL as expected." in SETUP_WIZARD_JS assert "Browser URL does not match BASE_URL" in SETUP_WIZARD_JS assert "saved URLs may contain private share tokens or other secrets." in SETUP_WIZARD_JS assert "archive intranet URLs" in SETUP_WIZARD_JS assert "tlsMode === 'single' && dnsMode !== 'single'" in SETUP_WIZARD_JS assert "Single-domain HTTPS is only allowed with Single-domain DNS." in SETUP_WIZARD_JS assert "Never enable on-demand TLS or request individual snapshot certificates." in SETUP_WIZARD_JS assert "expectedBrowserOrigin: usesSubdomains ? adminOrigin : parsed.origin" in SETUP_WIZARD_JS assert "Waiting for a matching browser URL and valid setup options" in SETUP_WIZARD_JS assert "Finish the selected DNS, ingress, and TLS setup" in SETUP_WIZARD_JS for target in ("adminUrl", "apiUrl", "indexUrl"): assert f"probeUrl(preview.{target}" in SETUP_WIZARD_JS assert "wildcardHealthUrl" not in SETUP_WIZARD_JS assert "results.wildcard" not in SETUP_WIZARD_JS assert "var coreReachable = results.admin && results.api && results.index && results.web && results.snapshot;" in SETUP_WIZARD_JS assert "probeUrl(webOrigin + '/web/https://example.com'" not in SETUP_WIZARD_JS assert "credentials: 'omit'" in SETUP_WIZARD_JS assert "function probeUrl(url, generation, requireArchiveBoxHealth)" in SETUP_WIZARD_JS assert "mode: requireArchiveBoxHealth ? 'cors' : 'no-cors'" in SETUP_WIZARD_JS assert "response.ok && response.headers.get('X-ArchiveBox-Health') === 'OK'" in SETUP_WIZARD_JS for target in ("webHealthUrl", "snapshotHealthUrl", "originalHealthUrl"): assert f"probeUrl(preview.{target}, generation, true)" in SETUP_WIZARD_JS def test_health_check_is_identifiable_across_ingress_origins(): response = HealthCheckView.as_view()(RequestFactory().get("/health/")) assert response.status_code == 200 assert response.content == b"OK" assert response["Access-Control-Allow-Origin"] == "*" assert response["Access-Control-Expose-Headers"] == "X-ArchiveBox-Health" assert response["X-ArchiveBox-Health"] == "OK" @pytest.mark.parametrize( ("request_host", "secure", "expected_display_host"), ( ("admin.archivebox.localhost:8000", False, "archivebox.localhost:8000"), ("127.0.0.1:8000", False, "archivebox.localhost:8000"), ("0.0.0.0:8000", False, "archivebox.localhost:8000"), ("archivebox.io:80", False, "archivebox.io"), ("archivebox.io:443", True, "archivebox.io"), ("192.0.2.10:443", True, "192.0.2.10"), ), ) def test_unconfigured_banner_displays_canonical_url_host(request_host, secure, expected_display_host): request = RequestFactory().get("/admin/", secure=secure, HTTP_HOST=request_host) request.user = AnonymousUser() context = get_setup_wizard_context(request, get_config(include_machine=False)) assert context["display_host"] == expected_display_host def test_unconfigured_banner_honors_forwarded_https_from_ingress(): request = RequestFactory().get( "/admin/", HTTP_HOST="archivebox.example.test", HTTP_X_FORWARDED_PROTO="https", ) request.user = AnonymousUser() context = get_setup_wizard_context(request, get_config(include_machine=False)) assert context["suggested_base_url"] == "https://archivebox.example.test" @pytest.mark.parametrize( ("base_url", "request_host"), ( ("", "archivebox.example.test:18443"), ("http://archivebox.localhost:8000", "admin.archivebox.localhost:18010"), ), ) def test_admin_cookie_isolation_accepts_first_run_and_external_port_mapping(base_url, request_host): config = get_config(include_machine=False).model_copy( update={"BASE_URL": base_url, "SERVER_SECURITY_MODE": "auto"}, ) request = RequestFactory().get("/admin/login/", HTTP_HOST=request_host) request.archivebox_config = config response = HttpResponse() response.set_cookie(settings.CSRF_COOKIE_NAME, "test-token") actual_response = AdminCookieIsolationMiddleware(lambda _request: response)(request) assert settings.CSRF_COOKIE_NAME in actual_response.cookies def test_unconfigured_banner_does_not_show_setup_wizard_to_non_superusers(): html = render_to_string( "core/system_warnings_banner.html", { "mode": "unconfigured", "can_configure": False, "suggested_base_url": "http://archivebox.example.test:8000", }, ) assert 'id="archivebox-setup-wizard"' not in html assert html.count('id="archivebox-system-warning-banner"') == 1 assert "Ask an ArchiveBox superuser to finish server setup" in html @pytest.mark.parametrize( ("context", "expected_text"), ( ({"mode": "low_disk", "free_gb": "0.50"}, "Only