aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_licensing.py257
-rw-r--r--packages/meshbay-node/tests/test_root_eject.py128
2 files changed, 385 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_licensing.py b/packages/meshbay-node/tests/test_licensing.py
new file mode 100644
index 0000000..6e50c80
--- /dev/null
+++ b/packages/meshbay-node/tests/test_licensing.py
@@ -0,0 +1,257 @@
+"""
+Licensing: meshbay-common under the LGPL, everything else under the AGPL, and
+every third-party piece a build ships accounted for.
+
+Reads files and installed metadata; builds nothing. What it guards against is
+drift — a licence field nobody updates, a vendored file without its licence, a
+GPL dependency creeping into the one package that must stay usable under the
+LGPL.
+"""
+
+import importlib.util
+import json
+import re
+import tomllib
+from importlib import metadata
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+PACKAGES = ROOT / "packages"
+STATIC = PACKAGES / "meshbay-hub" / "src" / "meshbay_hub" / "static"
+VENDOR = STATIC / "vendor"
+DESKTOP = PACKAGES / "meshbay-client" / "src"
+ANDROID = PACKAGES / "meshbay-android" / "app" / "src" / "main" / "kotlin"
+KEYS = ANDROID / "org" / "meshbay" / "client" / "keys"
+SPDX_LGPL = "// SPDX-License-Identifier: LGPL-3.0-or-later\n"
+# The protocol layer in the clients (README, "Licence"): what a program needs to
+# speak to a hub and a node, under the LGPL in every language it exists in.
+LGPL_FILES = sorted(
+ [STATIC / f for f in ("keyderive.js", "crypto.js", "playlist-crypto.js")]
+ + [STATIC / "transport.js"]
+ + sorted(STATIC.glob("transport-*.js"))
+ + [DESKTOP / f for f in ("keyring.js", "transcripts.js", "argon2-wasm.js")]
+ + [KEYS / f for f in ("Kdf.kt", "Keyring.kt", "Transcripts.kt")]
+)
+EXPECTED = {
+ "meshbay-common": ("LGPL-3.0-or-later", ["COPYING", "COPYING.LESSER"]),
+ "meshbay-hub": ("AGPL-3.0-or-later", ["LICENSE"]),
+ "meshbay-node": ("AGPL-3.0-or-later", ["LICENSE"]),
+}
+
+
+def _notices():
+ spec = importlib.util.spec_from_file_location(
+ "third_party_notices", ROOT / "packaging" / "third_party_notices.py"
+ )
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+def test_each_python_package_declares_its_licence_and_ships_the_text():
+ for pkg, (expr, files) in EXPECTED.items():
+ project = tomllib.loads((PACKAGES / pkg / "pyproject.toml").read_text())["project"]
+ assert project["license"] == expr, pkg
+ assert project["license-files"] == files, pkg
+ for f in files:
+ assert (PACKAGES / pkg / f).is_file(), f"{pkg}/{f}"
+
+
+def test_licence_texts_are_the_right_ones():
+ agpl = (ROOT / "LICENSE").read_text()
+ assert "GNU AFFERO GENERAL PUBLIC LICENSE" in agpl and "Version 3" in agpl
+ # Copies, because a wheel's license-files cannot reach outside its package.
+ for pkg in ("meshbay-hub", "meshbay-node"):
+ assert (PACKAGES / pkg / "LICENSE").read_text() == agpl, pkg
+ common = PACKAGES / "meshbay-common"
+ assert "GNU LESSER GENERAL PUBLIC LICENSE" in (common / "COPYING.LESSER").read_text()
+ assert "GNU GENERAL PUBLIC LICENSE" in (common / "COPYING").read_text()
+
+
+def test_rpm_specs_and_the_client_agree_with_the_packages():
+ for pkg in ("meshbay-common", "meshbay-hub", "meshbay-node", "meshbay-client"):
+ spec = (ROOT / "packaging" / "rpm" / f"{pkg}.spec").read_text()
+ want = EXPECTED.get(pkg, ("AGPL-3.0-or-later",))[0]
+ assert re.search(rf"^License:\s+{re.escape(want)}\s*$", spec, re.M), pkg
+ assert "%license %{_licensedir}/%{name}" in spec, pkg
+ pkg_json = json.loads((PACKAGES / "meshbay-client" / "package.json").read_text())
+ assert pkg_json["license"] == "AGPL-3.0-or-later"
+
+
+def test_every_windows_target_ships_the_licence():
+ pkg_json = json.loads((PACKAGES / "meshbay-client" / "package.json").read_text())
+ assert {"from": "../../LICENSE", "to": "LICENSE.txt"} in pkg_json["build"]["win"][
+ "extraResources"
+ ]
+ for yml in ("electron-builder.light.yml", "electron-builder.msix.yml"):
+ text = (ROOT / "packaging" / "win" / yml).read_text()
+ assert "- from: ../../LICENSE\n to: LICENSE.txt" in text, yml
+ ps1 = (ROOT / "packaging" / "win" / "build-node-runtime.ps1").read_text()
+ assert "third_party_notices.py" in ps1 and "THIRD-PARTY-NOTICES.txt" in ps1
+
+
+def test_common_depends_on_nothing_copyleft():
+ """The LGPL is only worth something if the library can be taken alone."""
+ for req in metadata.distribution("meshbay-common").requires or []:
+ if "extra ==" in req:
+ continue
+ name = re.split(r"[\s\[<>=!~;(]", req, maxsplit=1)[0]
+ md = metadata.distribution(name).metadata
+ label = " ".join(
+ [md.get("License-Expression") or "", md.get("License") or ""]
+ + (md.get_all("Classifier") or [])
+ )
+ assert "GPL" not in label, f"{name}: {label[:120]}"
+
+
+def test_notices_follow_what_the_node_actually_ships():
+ mod = _notices()
+ dists = mod._closure(["meshbay-node"])
+ assert "mutagen" in dists and "guessit" in dists and "av" in dists
+ # Extras the node does not ask for, and dev tools, stay out.
+ assert "pytest" not in dists and "piexif" not in dists
+ text = mod.render(["meshbay-node"], [], with_python=False)
+ assert re.search(r"^ mutagen [\d.]+ — GPL", text, re.M)
+ libs = mod._native_libs(dists["av"])
+ if libs: # PyAV's FFmpeg is grafted in; the notice must say which
+ assert libs[0] in text
+
+
+def test_every_vendored_file_has_its_provenance_and_licence():
+ provenance = (VENDOR / "PROVENANCE.md").read_text()
+ licences = (VENDOR / "LICENSES.txt").read_text()
+ for f in VENDOR.iterdir():
+ if f.name in ("PROVENANCE.md", "LICENSES.txt"):
+ continue
+ assert f"## {f.name}" in provenance or f"### {f.name}" in provenance, f.name
+ assert f.name in licences, f.name
+
+
+def _sources():
+ for tree, pattern in ((STATIC, "*.js"), (DESKTOP, "*.js"), (ANDROID, "**/*.kt")):
+ for f in tree.glob(pattern):
+ if "vendor" not in f.parts and "locales" not in f.parts:
+ yield f
+
+
+def test_the_lgpl_files_are_exactly_the_ones_that_say_so():
+ marked = sorted(f for f in _sources() if f.read_text().startswith(SPDX_LGPL))
+ assert marked == LGPL_FILES
+
+
+def test_every_client_carries_the_licence_texts():
+ """static/ is the interface of the web, the desktop and Android alike."""
+ texts = STATIC / "licenses"
+ assert (texts / "AGPL-3.0.txt").read_text() == (ROOT / "LICENSE").read_text()
+ common = PACKAGES / "meshbay-common"
+ assert (texts / "LGPL-3.0.txt").read_text() == (common / "COPYING.LESSER").read_text()
+ assert (texts / "GPL-3.0.txt").read_text() == (common / "COPYING").read_text()
+
+
+def _strip_js(src: str) -> str:
+ src = re.sub(r"/\*[\s\S]*?\*/|//[^\n]*", "", src)
+ return re.sub(r"'(?:\\.|[^'\\\n])*'|\"(?:\\.|[^\"\\\n])*\"", "''", src)
+
+
+def test_the_lgpl_layer_depends_on_nothing_under_the_agpl():
+ """
+ One import of an AGPL module and a client built on the layer is under the
+ AGPL after all. What a host supplies (window.MeshBayPlatform, a Secrets
+ store) is an injected interface, and is not looked for here.
+ """
+ lgpl = set(LGPL_FILES)
+ top = re.compile(
+ r"^(?:export\s+)?(?:async\s+)?(?:function\*?\s+|(?:const|let|var|class)\s+)"
+ r"([A-Za-z_$][\w$]*)",
+ re.M,
+ )
+ defined_in_lgpl = {n for f in lgpl if f.suffix == ".js" for n in top.findall(f.read_text())}
+ agpl_globals = {
+ n: f.name
+ for f in STATIC.glob("*.js")
+ if f not in lgpl
+ for n in top.findall(f.read_text())
+ if n not in defined_in_lgpl
+ }
+ kt_decl = re.compile(r"^\s*(?:\w+\s+)*(?:class|object|interface)\s+(\w+)", re.M)
+ defined_in_lgpl_kt = {
+ n for f in lgpl if f.suffix == ".kt" for n in kt_decl.findall(f.read_text())
+ }
+ agpl_kotlin = {
+ n: f.name
+ for f in ANDROID.glob("**/*.kt")
+ if f not in lgpl
+ for n in kt_decl.findall(f.read_text())
+ if n not in defined_in_lgpl_kt
+ }
+ for f in LGPL_FILES:
+ src = f.read_text()
+ if f.suffix == ".kt":
+ for imp in re.findall(r"^import (org\.meshbay\.[\w.]+)", src, re.M):
+ owner = (
+ imp.split(".")[-2] if imp.split(".")[-1][0].islower() else imp.split(".")[-1]
+ )
+ assert owner in {g.stem for g in lgpl}, f"{f.name} imports {imp}"
+ code = _strip_js(src) # Kotlin's comments and strings take the same shapes
+ for name, owner in agpl_kotlin.items():
+ assert not re.search(rf"\b{name}\b", code), f"{f.name} uses {name} ({owner})"
+ continue
+ code = _strip_js(src)
+ uncommented = re.sub(r"/\*[\s\S]*?\*/|^\s*//[^\n]*", "", src, flags=re.M)
+ for spec in re.findall(
+ r"""(?:\bfrom|\bimport\(|\brequire\()\s*['"]([^'"\n]+)['"]""", uncommented
+ ):
+ if spec.startswith("node:") or "vendor" in spec:
+ continue
+ assert (f.parent / spec).resolve() in lgpl, f"{f.name} imports {spec}"
+ for name, owner in agpl_globals.items():
+ assert not re.search(rf"(?<![\w$.]){re.escape(name)}\s*\(", code), (
+ f"{f.name} calls {name}() from {owner}"
+ )
+
+
+APP_EXCEPTION = STATIC / "licenses" / "APPLICATION-EXCEPTION.txt"
+REFERENCE_APP = [STATIC / "helloworld-app.js", STATIC / "helloworld-app-settings.js"]
+
+
+def _interface_modules() -> set[str]:
+ """The modules the permission names — read from it, the one place they are listed."""
+ text = APP_EXCEPTION.read_text()
+ block = text.split("2. the names exported by these modules", 1)[1].split("3.", 1)[0]
+ return set(re.findall(r"^\s+([\w-]+\.js)\s*$", block, re.M))
+
+
+def test_the_application_interface_names_modules_that_exist():
+ modules = _interface_modules()
+ assert modules == {"i18n.js", "icon.js", "file-utils.js", "settings-ui.js", "folder-tree.js"}
+ for m in modules:
+ assert (STATIC / m).is_file(), m
+ assert not (STATIC / m).read_text().startswith(SPDX_LGPL), (
+ f"{m} is LGPL already; the permission is for the AGPL part"
+ )
+
+
+def test_the_reference_application_is_free_to_copy_and_stays_inside_the_interface():
+ """
+ Copying helloworld is how an application starts. Were it to import anything
+ outside the application interface, every application started from it would
+ be a work based on the AGPL interface without anybody having chosen that.
+ """
+ allowed = _interface_modules() | {f.name for f in LGPL_FILES if f.parent == STATIC}
+ for f in REFERENCE_APP:
+ src = f.read_text()
+ assert src.startswith("// SPDX-License-Identifier: 0BSD\n"), f.name
+ for spec in re.findall(r"""^import .* from ['"]\./([^'"]+)['"]""", src, re.M):
+ assert spec.startswith("vendor/") or spec in allowed, f"{f.name} imports {spec}"
+ assert "import(" not in _strip_js(src), f"{f.name}: a dynamic import escapes this check"
+
+
+def test_every_spdx_line_is_one_of_the_known_licences():
+ for f in _sources():
+ first = f.read_text().split("\n", 1)[0]
+ if "SPDX-License-Identifier" not in first:
+ continue
+ if f in REFERENCE_APP:
+ assert first.endswith(": 0BSD"), f.name
+ else:
+ assert f in LGPL_FILES, f"{f.name}: {first}"
diff --git a/packages/meshbay-node/tests/test_root_eject.py b/packages/meshbay-node/tests/test_root_eject.py
index d73e71c..b6b50aa 100644
--- a/packages/meshbay-node/tests/test_root_eject.py
+++ b/packages/meshbay-node/tests/test_root_eject.py
@@ -264,3 +264,131 @@ async def test_the_ejected_key_is_case_folded(tmp_path):
assert Roster.root_ejected_key("Films") == Roster.root_ejected_key("FILMS")
finally:
await roster.close()
+
+
+# ── The safety net's eject undoes itself; the operator's does not ───────────
+
+def _vanish(films: Path) -> None:
+ for f in films.iterdir():
+ f.unlink()
+ films.rmdir()
+
+
+async def test_an_auto_ejected_root_comes_back_with_its_own_files(tmp_path):
+ """
+ The drive that was not mounted yet when the node started (found on a node
+ started with the session, its USB drives mounted a minute later): the
+ safety net ejected it, and once the same files are readable at the same
+ place it is plugged back without anyone having to.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+ seen: list[tuple[str, bool]] = []
+
+ async def record(name: str, ejected: bool) -> None:
+ seen.append((name, ejected))
+
+ roots = _roots(films)
+ idx = await _indexer(roots, on_root_ejected=record)
+ hidden = tmp_path / "unmounted"
+ films.rename(hidden)
+ await idx.reconcile()
+ assert roots.roots[0].ejected is True
+
+ hidden.rename(films)
+ await idx.reconcile()
+ assert roots.roots[0].ejected is False
+ assert roots.roots[0].available is True
+ assert _names(idx) == {"a.mkv"}
+ assert seen == [("Films", True), ("Films", False)]
+
+
+@pytest.mark.parametrize("what_came_back", ["empty", "another drive"])
+async def test_an_auto_ejected_root_stays_out_when_its_files_are_not_there(
+ tmp_path, what_came_back):
+ """
+ What the safety net exists for: an empty mount point, or another drive
+ mounted at the same place, is not the library. Plugging it back would
+ rescan it, and the rescan would read the library as erased.
+ """
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+ roots = _roots(films)
+ idx = await _indexer(roots)
+ _vanish(films)
+ await idx.reconcile()
+
+ films.mkdir()
+ if what_came_back == "another drive":
+ (films / "other.mkv").write_bytes(b"something else")
+ await idx.reconcile()
+ assert roots.roots[0].ejected is True
+ assert _names(idx) == {"a.mkv"}, "the library was treated as erased"
+
+
+async def test_an_operator_eject_is_never_undone_automatically(tmp_path):
+ films = tmp_path / "Films"
+ films.mkdir()
+ (films / "a.mkv").write_bytes(b"a")
+ roots = _roots(films)
+ idx = await _indexer(roots)
+
+ idx.eject_root("Films")
+ await idx.reconcile()
+ assert roots.roots[0].ejected is True
+
+
+async def test_after_a_restart_the_cache_recognises_the_drive(tmp_path):
+ """
+ A restarted node has no index entry for an ejected root: the index is
+ rebuilt by scanning, and an ejected root is not scanned. What it does have is
+ the hash cache, with the size and mtime of every file it read there.
+ """
+ import os
+
+ from meshbay_node.indexer.cache import IndexCache
+
+ films = tmp_path / "Films"
+ films.mkdir()
+ movie = films / "a.mkv"
+ movie.write_bytes(b"a")
+ st = movie.stat()
+
+ def restarted_ejected() -> RootSet:
+ return RootSet.build([{"path": str(films), "removable": True,
+ "ejected": True, "ejected_auto": True}])
+
+ async with IndexCache(tmp_path / "cache.db") as cache:
+ await _indexer(_roots(films), cache=cache)
+
+ # Another drive at the same place, with a file of the same name.
+ movie.write_bytes(b"another drive")
+ roots = restarted_ejected()
+ await _indexer(roots, cache=cache)
+ assert roots.roots[0].ejected is True
+
+ # The drive itself.
+ movie.write_bytes(b"a")
+ os.utime(movie, ns=(st.st_atime_ns, st.st_mtime_ns))
+ roots = restarted_ejected()
+ idx = await _indexer(roots, cache=cache)
+ assert roots.roots[0].ejected is False
+ assert _names(idx) == {"a.mkv"}
+
+
+async def test_the_roster_keeps_an_auto_eject_apart(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_root_ejected("g1", "Films", True, set_by="op", auto=True)
+ await roster.set_root_ejected("g1", "Music", True, set_by="op")
+ assert await roster.ejected_roots("g1") == {"films", "music"}
+ assert await roster.auto_ejected_roots("g1") == {"films"}
+
+ # An operator eject of the same root replaces the safety net's.
+ await roster.set_root_ejected("g1", "Films", True, set_by="op")
+ assert await roster.auto_ejected_roots("g1") == set()
+ finally:
+ await roster.close()