135 lines
4.4 KiB
Python
135 lines
4.4 KiB
Python
"""APK distribution: /apk page, /apk/info, the artifact, and its QR.
|
|
|
|
These are deliberately unauthenticated — a new member opens /apk before holding
|
|
a token, exactly like /app.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
|
|
def test_apk_page_is_public(client):
|
|
r = client.get("/apk")
|
|
assert r.status_code == 200, r.text
|
|
assert "text/html" in r.headers["content-type"]
|
|
body = r.text
|
|
assert "/apk/dsh-sync.apk" in body
|
|
assert "/apk/info" in body
|
|
assert "bash android/build.sh" in body
|
|
|
|
|
|
def test_apk_page_linked_from_app_shell(client):
|
|
r = client.get("/app")
|
|
assert 'href="/apk"' in r.text
|
|
|
|
|
|
def test_apk_info_reports_unavailable_without_build(client, monkeypatch):
|
|
"""An unbuilt server must say so instead of 500ing or offering a dead link."""
|
|
from dsh_sync import storage
|
|
from dsh_sync.main import create_app
|
|
|
|
monkeypatch.setattr(storage, "apk_path", lambda cfg: cfg.data_dir / "dist" / "missing.apk")
|
|
import dsh_sync.routers.pages as pages
|
|
|
|
monkeypatch.setattr(pages, "apk_path", lambda cfg: cfg.data_dir / "dist" / "missing.apk")
|
|
|
|
r = client.get("/apk/info")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["available"] is False
|
|
assert "build.sh" in data["build_hint"]
|
|
|
|
|
|
def test_apk_download_404_when_not_built(client, monkeypatch):
|
|
import dsh_sync.routers.pages as pages
|
|
|
|
monkeypatch.setattr(pages, "apk_path", lambda cfg: cfg.data_dir / "dist" / "missing.apk")
|
|
r = client.get("/apk/dsh-sync.apk")
|
|
assert r.status_code == 404
|
|
assert "not been built" in r.json()["detail"]
|
|
|
|
|
|
def test_apk_download_serves_artifact(client, monkeypatch):
|
|
import dsh_sync.routers.pages as pages
|
|
|
|
payload = b"PK\x03\x04fake-apk-bytes"
|
|
real_path = pages.apk_path(client.app.state.cfg)
|
|
real_path.parent.mkdir(parents=True, exist_ok=True)
|
|
real_path.write_bytes(payload)
|
|
monkeypatch.setattr(pages, "apk_meta_path", lambda cfg: cfg.data_dir / "dist" / "nope.json")
|
|
|
|
r = client.get("/apk/dsh-sync.apk")
|
|
assert r.status_code == 200
|
|
assert r.content == payload
|
|
assert r.headers["content-type"] == "application/vnd.android.package-archive"
|
|
assert "dsh-sync.apk" in r.headers["content-disposition"]
|
|
|
|
|
|
def test_apk_info_merges_sidecar_metadata(client, monkeypatch):
|
|
import dsh_sync.routers.pages as pages
|
|
|
|
apk = pages.apk_path(client.app.state.cfg)
|
|
apk.parent.mkdir(parents=True, exist_ok=True)
|
|
apk.write_bytes(b"x" * 32)
|
|
pages.apk_meta_path(client.app.state.cfg).write_text(
|
|
json.dumps({"version": "9.9.9", "sha256": "deadbeef", "application_id": "com.dsh.sync"}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
data = client.get("/apk/info").json()
|
|
assert data["available"] is True
|
|
assert data["size_bytes"] == 32
|
|
assert data["version"] == "9.9.9"
|
|
assert data["sha256"] == "deadbeef"
|
|
|
|
|
|
def test_apk_info_survives_corrupt_sidecar(client):
|
|
"""A broken sidecar must not take the download down with it."""
|
|
import dsh_sync.routers.pages as pages
|
|
|
|
apk = pages.apk_path(client.app.state.cfg)
|
|
apk.parent.mkdir(parents=True, exist_ok=True)
|
|
apk.write_bytes(b"y" * 16)
|
|
pages.apk_meta_path(client.app.state.cfg).write_text("{not json", encoding="utf-8")
|
|
|
|
data = client.get("/apk/info").json()
|
|
assert data["available"] is True
|
|
assert data["size_bytes"] == 16
|
|
assert "version" not in data
|
|
|
|
|
|
def test_apk_qr_is_svg(client):
|
|
r = client.get("/apk/qr")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"].startswith("image/svg+xml")
|
|
assert "<svg" in r.text
|
|
|
|
|
|
def test_apk_qr_host_override(client):
|
|
r = client.get("/apk/qr", params={"host": "192.168.9.9"})
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"].startswith("image/svg+xml")
|
|
|
|
|
|
def test_apk_qr_encodes_the_apk_page(client):
|
|
"""Decode-back: rebuild the QR for the expected URL and compare bytes."""
|
|
import io
|
|
|
|
import segno
|
|
|
|
r = client.get("/apk/qr", params={"host": "192.168.5.2"})
|
|
base = "192.168.5.2"
|
|
buf = io.BytesIO()
|
|
segno.make(f"http://{base}/apk", error="m").save(
|
|
buf, kind="svg", scale=8, border=2, dark="#000000", light="#ffffff"
|
|
)
|
|
assert r.content == buf.getvalue()
|
|
|
|
|
|
def test_favicon_redirects_to_icon(client):
|
|
"""Browsers request /favicon.ico unprompted; it must not 404."""
|
|
r = client.get("/favicon.ico", follow_redirects=False)
|
|
assert r.status_code == 307
|
|
assert r.headers["location"] == "/icon.svg"
|
|
assert client.get("/favicon.ico").status_code == 200
|