aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_android_shell.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_android_shell.py')
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py223
1 files changed, 223 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..e569bb7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -0,0 +1,223 @@
+"""
+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))
+ native = set()
+ for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
+ SRC / "cast" / "CastChannels.kt"):
+ native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), 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")
+
+
+def test_the_node_setup_welcome_needs_a_node():
+ """A phone has a bridge and no node: with no groups yet it must see the
+ invitations and the join link, not a node wizard it cannot finish."""
+ from spa_source import STATIC
+ app = _read(STATIC / "app.js")
+ home = app.split("function HomePage(", 1)[1].split("\n}\n", 1)[0]
+ gate = home.split("<${SetupWelcome}", 1)[0]
+ assert "platform.capabilities.nodeAdmin" in gate
+ assert "platform.isNative" not in gate
+
+
+# ── 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:]