Deploy Stride with encrypted manual and scheduled Garmin sync
This commit is contained in:
@@ -86,6 +86,27 @@ def test_owner_dashboard_activity_json_and_settings_render(app):
|
||||
assert "point" not in data["chart"][0]
|
||||
|
||||
|
||||
def test_dashboard_replays_latest_accessible_gps_run(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
page = client.get("/dashboard").data
|
||||
assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page
|
||||
assert b'id="toggle-route-replay"' in page
|
||||
assert b'vendor/leaflet.js' in page
|
||||
assert b'class="dashboard-map"' not in page
|
||||
client.post("/logout")
|
||||
login(client, "other")
|
||||
page = client.get("/dashboard").data
|
||||
assert b'No GPS running route available yet.' in page
|
||||
assert b'/activities/1/data' not in page
|
||||
with app.app_context():
|
||||
db.session.get(Activity, 1).samples = []
|
||||
db.session.commit()
|
||||
client.post("/logout")
|
||||
login(client)
|
||||
assert b'No GPS running route available yet.' in client.get("/dashboard").data
|
||||
|
||||
|
||||
def test_friend_access_requires_acceptance_and_revokes_immediately(app):
|
||||
owner = app.test_client()
|
||||
friend = app.test_client()
|
||||
@@ -132,6 +153,66 @@ def test_full_route_includes_start_and_finish():
|
||||
assert activity_data(row)["segments"] == [points]
|
||||
|
||||
|
||||
def test_public_run_link_copy_view_revoke_and_recreate(app):
|
||||
owner = app.test_client()
|
||||
anonymous = app.test_client()
|
||||
login(owner)
|
||||
assert b'id="copy-run-link"' in owner.get("/activities/1").data
|
||||
response = owner.post("/activities/1/share")
|
||||
assert response.status_code == 200
|
||||
url = response.json["url"]
|
||||
assert url.startswith("http://localhost/shared/runs/")
|
||||
assert len(url.rsplit("/", 1)[1]) >= 40
|
||||
assert owner.post("/activities/1/share").json["url"] == url
|
||||
page = anonymous.get(url)
|
||||
assert page.status_code == 200
|
||||
assert b"A quiet morning run" in page.data
|
||||
assert b"Shared via link" in page.data
|
||||
assert b'id="copy-run-link"' not in page.data
|
||||
assert b"/activities/1/edit" not in page.data
|
||||
assert b"/u/runner" not in page.data
|
||||
assert (url.removeprefix("http://localhost") + "/data").encode() in page.data
|
||||
assert page.headers["X-Robots-Tag"] == "noindex, nofollow"
|
||||
assert page.headers["Referrer-Policy"] == "no-referrer"
|
||||
data = anonymous.get(url + "/data")
|
||||
assert data.status_code == 200
|
||||
assert data.json["segments"] == [[[-33, 151]]]
|
||||
assert "no-store" in data.headers["Cache-Control"]
|
||||
assert anonymous.get("/activities/1").status_code == 404
|
||||
assert anonymous.get("/activities/1/data").status_code == 404
|
||||
with app.app_context():
|
||||
assert db.session.get(Activity, 1).visibility == "private"
|
||||
assert owner.post("/activities/1/share/revoke").status_code == 302
|
||||
assert anonymous.get(url).status_code == 404
|
||||
assert anonymous.get(url + "/data").status_code == 404
|
||||
assert owner.post("/activities/1/share").json["url"] != url
|
||||
|
||||
|
||||
def test_only_owner_can_create_or_revoke_public_run_links(app):
|
||||
client = app.test_client()
|
||||
for suffix in ("share", "share/revoke"):
|
||||
assert client.post(f"/activities/1/{suffix}").status_code == 302
|
||||
login(client, "other")
|
||||
with app.app_context():
|
||||
db.session.add(Friendship(low_id=1, high_id=2, requested_by=1, status="accepted"))
|
||||
db.session.get(Activity, 1).visibility = "friends"
|
||||
db.session.commit()
|
||||
assert client.get("/activities/1").status_code == 200
|
||||
assert b'id="copy-run-link"' not in client.get("/activities/1").data
|
||||
for suffix in ("share", "share/revoke"):
|
||||
assert client.post(f"/activities/1/{suffix}").status_code == 404
|
||||
for suffix in ("", "/data"):
|
||||
assert client.get(f"/shared/runs/invalid-token{suffix}").status_code == 404
|
||||
|
||||
|
||||
def test_public_run_link_mutations_require_csrf(app):
|
||||
client = app.test_client()
|
||||
login(client)
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
for suffix in ("share", "share/revoke"):
|
||||
assert client.post(f"/activities/1/{suffix}").status_code == 400
|
||||
|
||||
|
||||
def test_register_creates_separate_user_with_no_local_account(app):
|
||||
client = app.test_client()
|
||||
response = client.post(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import io
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from garminconnect import GarminConnectAuthenticationError
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
from app.models import GarminConnection, User
|
||||
from app.sync import run_due, session_lock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connected(tmp_path, monkeypatch):
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"SQLALCHEMY_DATABASE_URI": "sqlite://",
|
||||
"SECRET_KEY": "testing",
|
||||
"WTF_CSRF_ENABLED": False,
|
||||
"RATELIMIT_ENABLED": False,
|
||||
"LOCAL_SETUP": False,
|
||||
"TOKEN_ENCRYPTION_KEY": Fernet.generate_key().decode(),
|
||||
"DATA_ROOT": str(tmp_path / "data"),
|
||||
}
|
||||
)
|
||||
app.instance_path = str(tmp_path / "instance")
|
||||
api = Mock(profile_id=123)
|
||||
api.client.dumps.return_value = '{"rotated":"private-session"}'
|
||||
monkeypatch.setattr("app.sync.Garmin", lambda: api)
|
||||
sync = Mock(return_value={"failed": 0})
|
||||
monkeypatch.setattr("app.sync.sync_activities", sync)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
user = User(username="owner", email="owner@example.test", display_name="Owner")
|
||||
user.set_password("long-test-password")
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
client = app.test_client()
|
||||
client.post("/login", data={"email": "owner@example.test", "password": "long-test-password"})
|
||||
payload = json.dumps(
|
||||
{k: "secret-value" for k in ("di_token", "di_refresh_token", "di_client_id")}
|
||||
)
|
||||
response = client.post(
|
||||
"/garmin/connect", data={"tokens": (io.BytesIO(payload.encode()), "session.json")}
|
||||
)
|
||||
assert response.status_code == 302
|
||||
return app, client, api, sync
|
||||
|
||||
|
||||
def test_manual_scheduled_encryption_and_disconnect(connected):
|
||||
app, client, _api, sync = connected
|
||||
with app.app_context():
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert "private-session" not in connection.tokens
|
||||
assert run_due() == 1
|
||||
assert connection.last_success
|
||||
assert not connection.pending
|
||||
assert run_due() == 0
|
||||
connection.next_sync = datetime.now(UTC) - timedelta(minutes=1)
|
||||
db.session.commit()
|
||||
assert run_due() == 1
|
||||
assert sync.call_count == 2
|
||||
client.post("/garmin/schedule", data={"enabled": "no"})
|
||||
client.post("/garmin/sync")
|
||||
with app.app_context():
|
||||
assert run_due() == 1
|
||||
assert sync.call_count == 3
|
||||
client.post("/garmin/disconnect")
|
||||
with app.app_context():
|
||||
assert run_due() == 0
|
||||
assert db.session.scalar(db.select(GarminConnection)) is None
|
||||
assert db.session.get(User, 1).local_account_key is None
|
||||
|
||||
|
||||
def test_retry_and_rotated_tokens_survive_failure(connected):
|
||||
app, client, _api, sync = connected
|
||||
api = _api
|
||||
sync.side_effect = RuntimeError("provider secret must not appear")
|
||||
with app.app_context():
|
||||
assert run_due() == 1
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert connection.failures == 1
|
||||
assert "provider secret" not in connection.message
|
||||
assert (
|
||||
Fernet(app.config["TOKEN_ENCRYPTION_KEY"]).decrypt(connection.tokens.encode())
|
||||
== api.client.dumps.return_value.encode()
|
||||
)
|
||||
assert run_due() == 0
|
||||
client.post("/garmin/sync")
|
||||
api.login.side_effect = GarminConnectAuthenticationError("private error")
|
||||
with app.app_context():
|
||||
run_due()
|
||||
connection = db.session.scalar(db.select(GarminConnection))
|
||||
assert not connection.scheduled
|
||||
assert "expired" in connection.message
|
||||
|
||||
|
||||
def test_lock_prevents_overlapping_sync_and_disconnect(connected):
|
||||
app, client, _api, sync = connected
|
||||
with app.app_context(), session_lock(1):
|
||||
run_due()
|
||||
sync.assert_not_called()
|
||||
response = client.post("/garmin/disconnect", follow_redirects=True)
|
||||
assert b"sync is running" in response.data
|
||||
assert db.session.scalar(db.select(GarminConnection)) is not None
|
||||
|
||||
|
||||
def test_sync_requires_login_and_csrf(connected):
|
||||
app, client, _api, _sync = connected
|
||||
assert app.test_client().post("/garmin/sync").status_code == 302
|
||||
app.config["WTF_CSRF_ENABLED"] = True
|
||||
assert client.post("/garmin/sync").status_code == 400
|
||||
Reference in New Issue
Block a user