testsAndMisc/python_pkg/endurain_import/tests/test_sources.py
Krzysztof kuhy Rudnicki 558883f281 feat(endurain): self-hosted run viewer fed by RunnerUp WebDAV exports
Stands up Endurain behind the existing Caddy edge and imports RunnerUp
activities into it. Endurain is a VIEWER: screen-locker's workout gate still
reads the phone over adb and is untouched here, because a WebDAV drop folder is
spoofable by anything that can write to ~/cloud and must not become a
screen-unlock authority.

Primary path is phone -> WebDAV (existing dufs) -> ~/cloud/RunnerUp/ ->
importer -> Endurain, with an adb pull as fallback when a run has not been
uploaded yet. A systemd user timer runs the importer every 15 min.

Findings that shaped the implementation, each verified against the running
system rather than assumed:

- Endurain moved to Codeberg; the GitHub repo is a read-only mirror stale at
  v0.17.7 against v0.19.0. The image is pulled from Codeberg.
- postgres:18 moved PGDATA to /var/lib/postgresql/18/docker. Upstream's compose
  example still mounts the pre-18 path, which fails as an unexplained
  "container is unhealthy"; the real cause is only visible in docker logs.
- Endurain seeds a hardcoded admin/admin via an Alembic migration with no env
  override, and endurain.kuhy.duckdns.org already resolved publicly. Publishing
  is therefore a separate script that refuses to install the Caddy site while
  those credentials still work.
- Endurain performs NO duplicate detection, so the importer owns dedupe on two
  axes: a content hash, and an activity key that collapses the .gpx/.tcx pair
  RunnerUp writes for a single run (different bytes, same run).
- Ambiguous uploads (5xx/timeout) are never re-queued, since the activity may
  already have been committed; only definite 4xx rejections are staged for
  retry. The importer exits non-zero so the timer surfaces the problem.
- RunnerUp has no background sync at all, so every upload is a manual tap.
- Its WebDAV synchronizer needs HTTPS with a real cert (Android blocks
  cleartext at targetSdk 36; self-signed is broken upstream) and a URL with no
  trailing slash, and cannot MKCOL against dufs - so the inbox must pre-exist.

The phone gets a dufs credential scoped to /RunnerUp, verified unable to read
Keepass or gdrive_backups.

Verified: 52 tests at 100% branch coverage; the timer imports 4 real runs and
re-runs create zero duplicates; https://endurain.kuhy.duckdns.org serves 200
with a valid cert and the other edge sites are unaffected; screen-locker's
suite still passes 1090 tests at 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
2026-08-23 13:09:18 +02:00

171 lines
5.2 KiB
Python

"""Inbox scanning and the adb fallback.
The fallback is read-only with respect to the phone: screen-locker reads the
same directory as its screen-unlock gate, so nothing here may delete or
rewrite those files.
"""
from __future__ import annotations
from pathlib import Path
import subprocess
import pytest
from python_pkg.endurain_import import sources
def test_inbox_lists_only_activity_files(tmp_path: Path) -> None:
(tmp_path / "a.tcx").write_text("x")
(tmp_path / "b.gpx").write_text("x")
(tmp_path / "c.fit").write_text("x")
(tmp_path / "notes.txt").write_text("x")
names = {p.name for p in sources.inbox_files(tmp_path)}
assert names == {"a.tcx", "b.gpx", "c.fit"}
def test_inbox_skips_processed_subdir(tmp_path: Path) -> None:
(tmp_path / "processed").mkdir()
(tmp_path / "processed" / "old.tcx").write_text("x")
assert sources.inbox_files(tmp_path) == []
def test_missing_inbox_is_not_an_error(tmp_path: Path) -> None:
assert sources.inbox_files(tmp_path / "nope") == []
def test_inbox_is_ordered_oldest_first(tmp_path: Path) -> None:
first = tmp_path / "first.tcx"
second = tmp_path / "second.tcx"
first.write_text("x")
second.write_text("x")
import os
os.utime(first, (1000, 1000))
os.utime(second, (2000, 2000))
assert [p.name for p in sources.inbox_files(tmp_path)] == [
"first.tcx",
"second.tcx",
]
def test_phone_attached_parses_adb_devices(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sources,
"_adb",
lambda _a: (True, "List of devices attached\nABC123\tdevice\n"),
)
assert sources.phone_attached()
def test_phone_unauthorised_is_not_attached(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
sources,
"_adb",
lambda _a: (True, "List of devices attached\nABC123\tunauthorized\n"),
)
assert not sources.phone_attached()
def test_pull_skips_when_no_device(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(sources, "phone_attached", lambda: False)
assert sources.pull_from_phone(tmp_path) == []
def test_pull_ignores_already_present_files(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(sources, "phone_attached", lambda: True)
monkeypatch.setattr(sources, "_adb", lambda _a: (True, "RunnerUp_ts_Running.tcx\n"))
(tmp_path / "RunnerUp_ts_Running.tcx").write_text("already here")
assert sources.pull_from_phone(tmp_path) == []
def test_failed_pull_leaves_no_partial_file(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A half-transferred file must never look like a complete activity."""
monkeypatch.setattr(sources, "phone_attached", lambda: True)
def _adb(args: list[str]) -> tuple[bool, str]:
if args[0] == "shell":
return True, "RunnerUp_ts_Running.tcx\n"
return False, "pull failed"
monkeypatch.setattr(sources, "_adb", _adb)
assert sources.pull_from_phone(tmp_path) == []
assert list(tmp_path.iterdir()) == []
def test_adb_handles_missing_binary(monkeypatch: pytest.MonkeyPatch) -> None:
"""A machine with no adb reports 'no device', it does not crash the run."""
def _boom(*_a: object, **_k: object) -> None:
message = "adb not found"
raise OSError(message)
monkeypatch.setattr(subprocess, "run", _boom)
ok, out = sources._adb(["devices"])
assert not ok
assert "adb not found" in out
def test_adb_handles_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
def _slow(*_a: object, **_k: object) -> None:
raise subprocess.TimeoutExpired(cmd="adb", timeout=1)
monkeypatch.setattr(subprocess, "run", _slow)
ok, _ = sources._adb(["devices"])
assert not ok
def test_adb_returns_output_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
class _Proc:
returncode = 0
stdout = "out"
stderr = ""
monkeypatch.setattr(subprocess, "run", lambda *_a, **_k: _Proc())
ok, out = sources._adb(["devices"])
assert ok
assert out == "out"
def test_phone_attached_false_when_adb_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(sources, "_adb", lambda _a: (False, "boom"))
assert not sources.phone_attached()
def test_pull_reports_unlistable_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(sources, "phone_attached", lambda: True)
monkeypatch.setattr(sources, "_adb", lambda _a: (False, "no such dir"))
assert sources.pull_from_phone(tmp_path) == []
def test_successful_pull_moves_into_place(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(sources, "phone_attached", lambda: True)
def _adb(args: list[str]) -> tuple[bool, str]:
if args[0] == "shell":
return True, "RunnerUp_ts_Running.tcx\nnotes.txt\n"
Path(args[2]).write_text("pulled")
return True, ""
monkeypatch.setattr(sources, "_adb", _adb)
pulled = sources.pull_from_phone(tmp_path)
assert [p.name for p in pulled] == ["RunnerUp_ts_Running.tcx"]
assert (tmp_path / "RunnerUp_ts_Running.tcx").read_text() == "pulled"
assert not list(tmp_path.glob(".*partial"))