diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_android_shell.py | 210 |
1 files changed, 210 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py new file mode 100644 index 0000000..b2e0e4d --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -0,0 +1,210 @@ +""" +The Android shell's security contract, pinned by reading its source. + +The same treatment `test_desktop_shell.py` gives the Electron application, for +the same reason: an emulator or a phone is what proves the shell runs, and the +suite has neither. What this proves is that the properties the design depends +on (docs/MESHBAY_DESIGN.md §8.2, as the Android plan restates them) are in the +source, and it fails when one is removed. The JVM unit tests run too when an +Android SDK is present. +""" + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +ANDROID = PACKAGES / "meshbay-android" +APP = ANDROID / "app" +SRC = APP / "src" / "main" / "kotlin" / "org" / "meshbay" / "client" +SHIM = APP / "src" / "main" / "assets" / "bridge" / "meshbay-bridge.js" +CLIENT = PACKAGES / "meshbay-client" + +pytestmark = pytest.mark.skipif(not ANDROID.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _kotlin() -> str: + return "\n".join(_read(p) for p in sorted(SRC.rglob("*.kt"))) + + +def _strip_js_comments(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"(?m)//.*$", "", source) + + +# ── The page comes from the package ────────────────────────────────────────── + +def test_the_interface_is_copied_from_its_single_source_and_never_committed(): + build = _read(APP / "build.gradle.kts") + assert '"../meshbay-hub/src/meshbay_hub/static"' in build + # The desktop application's page: the hub's carries a /a/<hash>/ prefix + # that would point back at the hub. + assert '"../meshbay-client/scripts/index.html"' in build + assert "deleteRecursively()" in build, "a stale file could survive a rebuild" + assert "addGeneratedSourceDirectory" in build + assert "build/" in _read(ANDROID / ".gitignore") + assert not (APP / "src" / "main" / "assets" / "ui").exists(), \ + "a copy of the interface is in the source tree, which is how a fork begins" + + +def test_the_webview_loads_the_package_and_nothing_else(): + activity = _read(SRC / "MainActivity.kt") + loads = re.findall(r"\.loadUrl\(([^)]*)\)", activity) + assert loads and set(loads) == {"UiAssets.START"}, loads + assert "loadDataWithBaseURL" not in activity and "loadData(" not in activity + # The hub never becomes the document origin; a link out leaves the app. + assert "shouldOverrideUrlLoading" in activity and "openExternally" in activity + + +def test_the_policy_is_the_desktop_policy_sent_as_a_header(): + """One interface, one policy for the packaged builds — and the desktop's + is already held to the hub's by test_the_two_policies_stay_in_step.""" + def directives(text: str, start: str, end: str) -> dict[str, str]: + body = text.split(start, 1)[1].split(end, 1)[0] + out = {} + for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body): + d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC") + out[d.split()[0]] = d + return out + + desktop = directives(_read(CLIENT / "src" / "main.js"), "const CSP = [", "].join") + kotlin = _read(SRC / "shell" / "UiAssets.kt") + android = directives(kotlin, "val CSP = listOf(", ").joinToString") + assert desktop and android == desktop, set(android.items()) ^ set(desktop.items()) + + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '"Content-Security-Policy" to CSP' in assets + recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"' + assert recaptcha in assets + markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S) + assert "Content-Security-Policy" not in markup + + +def test_the_asset_handler_cannot_be_walked_out_of(): + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '".."' in assets and 'val asset = "ui/"' in assets + + +def test_plain_http_is_refused_except_to_loopback(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "The hub address must be https" in hub + assert 'Regex("^http://(localhost|127\\\\.)")' in hub + config = _read(APP / "src" / "main" / "res" / "xml" / "network_security_config.xml") + assert '<base-config cleartextTrafficPermitted="false"' in config + allowed = re.findall(r"<domain[^>]*>([^<]+)</domain>", config) + assert set(allowed) == {"localhost", "127.0.0.1"} + + +def test_the_page_reaches_the_signed_in_hub_only(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "Refused: not this hub" in hub and "sameOrigin(target, hub)" in hub + assert ".followRedirects(false)" in hub, "a redirect would carry the token elsewhere" + + +# ── The bridge ────────────────────────────────────────────────────────────── + +def test_no_javascript_interface_is_ever_added(): + """addJavascriptInterface injects into every frame of every origin.""" + for path in APP.rglob("*.kt"): + assert "addJavascriptInterface" not in _read(path), path + + +def test_every_message_is_checked_for_our_top_level_document(): + bridge = _read(SRC / "bridge" / "Bridge.kt") + check = bridge.split("override fun onPostMessage", 1)[1].split("work.execute", 1)[0] + assert "!isMainFrame" in check and "UiAssets.ORIGIN" in check + activity = _read(SRC / "MainActivity.kt") + assert "addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN)" in activity + assert re.search(r"addDocumentStartJavaScript\(web, .*setOf\(UiAssets\.ORIGIN\)\)", activity) + + +def _shim_channels() -> set[str]: + return set(re.findall(r"call\('([\w:-]+)'", _strip_js_comments(_read(SHIM)))) + + +def test_the_shim_offers_desktop_channels_and_native_answers_each(): + preload_js = _read(CLIENT / "src" / "preload.js") + preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js)) + channels_kt = _read(SRC / "bridge" / "Channels.kt") + native = set(re.findall(r'^\s*"([\w:-]+)" ->', channels_kt, flags=re.M)) + shim = _shim_channels() + assert shim, "no channel found in the shim" + assert shim <= preload, f"channels the desktop does not have: {shim - preload}" + assert shim == native, f"shim and native disagree: {shim ^ native}" + + +def test_what_a_phone_does_not_have_is_absent_not_refusing(): + """platform.js decides what to show from whether an object exists + (`platform.node.available`, `platform.folder.available`, …): an object that + only refused would put screens on the page that fail when used.""" + shim = _strip_js_comments(_read(SHIM)) + exposed = shim.split("const meshbay = {", 1)[1].split("\n };", 1)[0] + for absent in ("node:", "rootPicker:", "minimizeToTray:", "setTrayLabels:"): + assert absent not in exposed, absent + assert "nodeAdmin: false" in exposed and "localFolders: false" in exposed + + +def test_the_bridge_is_frozen_and_the_port_hidden(): + shim = _strip_js_comments(_read(SHIM)) + assert "delete window.meshbayNative" in shim + assert "window.top !== window" in shim + assert "writable: false, configurable: false" in shim + assert "Object.freeze" in shim + + +def test_file_system_access_is_removed_from_the_page(): + """The WebView exposes it (spike S-1) and cannot back it with anything.""" + shim = _strip_js_comments(_read(SHIM)) + for name in ("showDirectoryPicker", "showSaveFilePicker", "showOpenFilePicker"): + assert f"'{name}'" in shim, name + + +def test_the_hub_address_is_injected_not_fetched(): + """platform.hubBase() is called while modules load, before anything can await.""" + assert "HUB_BASE" in _strip_js_comments(_read(SHIM)) + assert "const HUB_BASE = ${JSONObject.quote(hub.base)}" in _read(SRC / "MainActivity.kt") + + +# ── The window ────────────────────────────────────────────────────────────── + +def test_nothing_is_granted_and_video_may_go_fullscreen(): + activity = _read(SRC / "MainActivity.kt") + assert "onPermissionRequest(request: PermissionRequest) = request.deny()" in activity + # Without a custom view, requestFullscreen() never settles (CLAUDE.md). + assert "override fun onShowCustomView" in activity + assert "override fun onHideCustomView" in activity + + +def test_no_backup_carries_the_keys_away(): + manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") + assert 'android:allowBackup="false"' in manifest + assert 'android:dataExtractionRules="@xml/data_extraction_rules"' in manifest + + +def test_the_gradle_distribution_is_pinned_by_checksum(): + props = _read(ANDROID / "gradle" / "wrapper" / "gradle-wrapper.properties") + assert re.search(r"^distributionSha256Sum=[0-9a-f]{64}$", props, flags=re.M) + + +def test_the_version_is_the_packages_version(): + """All packages share one version (CLAUDE.md); this one reads it rather + than writing it a second time.""" + build = _read(APP / "build.gradle.kts") + assert 'rootDir.resolve("../meshbay-client/package.json")' in build + assert not re.search(r'versionName = "\d', build) + + +@pytest.mark.skipif(not os.environ.get("ANDROID_HOME") or shutil.which("java") is None, + reason="no Android SDK in the environment") +def test_the_jvm_unit_tests_pass(): + result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testDebugUnitTest"], + cwd=ANDROID, capture_output=True, text=True, timeout=900) + assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-3000:] |