From 1498c7d99b761138958d4056c58628ab511d1000 Mon Sep 17 00:00:00 2001 From: Elliot Mackenzie Date: Thu, 5 Nov 2026 06:56:59 +1000 Subject: [PATCH 1/3] Use developer Gateway routes for API-key remote access --- dataplicity_cli/cli.py | 12 +++++++++--- tests/test_devices_connection_quality.py | 2 +- tests/test_gateway_remote_auth.py | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 tests/test_gateway_remote_auth.py diff --git a/dataplicity_cli/cli.py b/dataplicity_cli/cli.py index 425feec..4dbc5af 100644 --- a/dataplicity_cli/cli.py +++ b/dataplicity_cli/cli.py @@ -3143,7 +3143,7 @@ def devices_connection_quality(ctx: typer.Context, device_hash: Optional[str] = payload = status_response.data else: detail_response = state.api.get(f"/api/developer/devices/{resolved_hash}/") - host_response = state.api.get(f"/api/remote/devices/{resolved_hash}/host/") + host_response = state.api.get(_remote_device_api_path(state, resolved_hash, "host")) if detail_response.ok and isinstance(detail_response.data, dict): detail = detail_response.data payload = { @@ -3278,13 +3278,19 @@ def devices_provisioning_key( state.console.print(message) +def _remote_device_api_path(state: AppContext, device_hash: str, resource: str) -> str: + """Organisation keys and logged-in users use distinct remote-access planes.""" + prefix = 'developer' if state.config.auth_method == 'api_key' else 'remote' + return f"/api/{prefix}/devices/{device_hash}/{resource}/" + + async def _resolve_m2m_url( state: AppContext, device_hash: str, *, request_timeout: Optional[int] = None, ) -> str: - path = f"/api/remote/devices/{device_hash}/host/" + path = _remote_device_api_path(state, device_hash, "host") if request_timeout is None: response = state.api.get(path) else: @@ -3308,7 +3314,7 @@ async def _resolve_m2m_url( def _open_remote_access_port(state: AppContext, device_hash: str, payload: Dict[str, Any]) -> Any: return state.api.post( - f"/api/remote/devices/{device_hash}/ports/", + _remote_device_api_path(state, device_hash, "ports"), json_data=payload, ) diff --git a/tests/test_devices_connection_quality.py b/tests/test_devices_connection_quality.py index 5d4328e..6ae78de 100644 --- a/tests/test_devices_connection_quality.py +++ b/tests/test_devices_connection_quality.py @@ -71,7 +71,7 @@ def fake_get(_self, path: str, *, params=None): # type: ignore[no-untyped-def] return ApiResponse(False, 404, {"detail": "Not found."}, '{"detail":"Not found."}') if path == f"/api/developer/devices/{self.device_hash}/": return ApiResponse(True, 200, {"status": "online", "last_heartbeat": 1719559200000}, "") - if path == f"/api/remote/devices/{self.device_hash}/host/": + if path == f"/api/developer/devices/{self.device_hash}/host/": return ApiResponse(True, 200, {"m2m_url": "wss://m2m.dataplicity.com/m2m/"}, "") raise AssertionError(f"unexpected GET path: {path}") diff --git a/tests/test_gateway_remote_auth.py b/tests/test_gateway_remote_auth.py new file mode 100644 index 0000000..ab526b5 --- /dev/null +++ b/tests/test_gateway_remote_auth.py @@ -0,0 +1,18 @@ +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import Mock +from dataplicity_cli.cli import _resolve_m2m_url, _open_remote_access_port + + +class GatewayRemoteAuthTests(unittest.TestCase): + def test_requests_select_the_authenticated_plane(self): + for method, prefix in [('api_key', 'developer'), ('jwt', 'remote')]: + api = Mock() + api.get.return_value = SimpleNamespace(ok=True, data={'m2m_url': 'https://relay.example/m2m/'}) + state = SimpleNamespace(config=SimpleNamespace(auth_method=method), api=api) + url = asyncio.run(_resolve_m2m_url(state, 'device')) + self.assertIn('device=device', url) + api.get.assert_called_once_with(f'/api/{prefix}/devices/device/host/') + _open_remote_access_port(state, 'device', {'service': 'terminal'}) + api.post.assert_called_once_with(f'/api/{prefix}/devices/device/ports/', json_data={'service': 'terminal'}) From 3b9ea3eeff045a7c338fc03be5b936c0d87db00a Mon Sep 17 00:00:00 2001 From: Elliot Mackenzie Date: Thu, 5 Nov 2026 07:00:22 +1000 Subject: [PATCH 2/3] Keep port-forward smoke mock from shadowing developer host route --- scripts/port_forward_mock_gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/port_forward_mock_gateway.py b/scripts/port_forward_mock_gateway.py index 0b74e79..e14b871 100755 --- a/scripts/port_forward_mock_gateway.py +++ b/scripts/port_forward_mock_gateway.py @@ -199,7 +199,7 @@ async def route_http( ] } ).encode("utf-8"), "application/json" - if method == "GET" and path.startswith("/api/developer/devices/"): + if method == "GET" and path == f"/api/developer/devices/{DEVICE_HASH}": return 200, json.dumps( { "hash": DEVICE_HASH, From 14c42ff689267f015dfedf3a7536c15421f23b2f Mon Sep 17 00:00:00 2001 From: Elliot Mackenzie Date: Thu, 5 Nov 2026 10:28:35 +1000 Subject: [PATCH 3/3] Use PATCH when invalidating timeline comments --- dataplicity_cli/cli.py | 4 ++-- tests/test_devices_history_cli.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dataplicity_cli/cli.py b/dataplicity_cli/cli.py index 4dbc5af..a04f2c1 100644 --- a/dataplicity_cli/cli.py +++ b/dataplicity_cli/cli.py @@ -3017,9 +3017,9 @@ def devices_history_delete( _require_auth(state) resolved_hash = _resolve_device_hash_interactive(state, device_hash, action_name="history delete") endpoint = f"/api/developer/devices/{resolved_hash}/timeline/{event_id}/" - _debug_api_request(state, method="DELETE", path=endpoint) + _debug_api_request(state, method="PATCH", path=endpoint) response = state.api.request( - "DELETE", + "PATCH", endpoint, ) if not response.ok: diff --git a/tests/test_devices_history_cli.py b/tests/test_devices_history_cli.py index 8316590..535fa28 100644 --- a/tests/test_devices_history_cli.py +++ b/tests/test_devices_history_cli.py @@ -195,7 +195,7 @@ def fake_request( allow_refresh=True, ): # type: ignore[no-untyped-def] _ = (params, json_data, data, headers, timeout, allow_refresh) - self.assertEqual(method, "DELETE") + self.assertEqual(method, "PATCH") self.assertEqual(path, f"/api/developer/devices/{self.device_hash}/timeline/777/") return ApiResponse(True, 200, deleted_payload, json.dumps(deleted_payload))