feat: add servicelink RPC mesh endpoint
Build and Push Docker Container / build-and-push (push) Successful in 3m21s
Build and Push Docker Container / build-and-push (push) Successful in 3m21s
- Add the servicelink submodule and register POST /rpc for node-to-node file operations. - Require bearer tokens with the mesh scope and apply rate/body-size limits to RPC calls. - Map database connectivity failures to the existing 504 database error flow, with JSON responses for API routes. - Cover the new RPC handlers and database error handling with focused pytest tests. - Bump the NanoShare package version to 1.21.0.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from quart import Blueprint, Quart, request
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
class FakeLimiter:
|
||||
def exempt(self, func):
|
||||
return func
|
||||
|
||||
def limit(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
class FakeLogger:
|
||||
def __init__(self):
|
||||
self.errors = []
|
||||
|
||||
def error(self, message):
|
||||
self.errors.append(message)
|
||||
|
||||
def warning(self, message):
|
||||
pass
|
||||
|
||||
class FailingConvex:
|
||||
async def get_current_favicon(self):
|
||||
raise httpx.ConnectError("[Errno -2] Name or service not known")
|
||||
|
||||
def load_module(module_name, module_path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def install_route_test_modules(monkeypatch, app, logger):
|
||||
fake_setup = types.ModuleType("my_modules.app.setup")
|
||||
fake_setup.app = app
|
||||
fake_setup.LIMITER = FakeLimiter()
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.setup", fake_setup)
|
||||
|
||||
fake_constens = types.ModuleType("my_modules.app.constens")
|
||||
fake_constens.BLOCKED_IPS_ACCESSING_TIMES = 3
|
||||
fake_constens.BLOCKED_IPS_STORED_TIMEFRAME = 60
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.constens", fake_constens)
|
||||
|
||||
fake_logger_module = types.ModuleType("my_modules.app.logger")
|
||||
fake_logger_module.logger = logger
|
||||
monkeypatch.setitem(sys.modules, "my_modules.app.logger", fake_logger_module)
|
||||
|
||||
fake_functions = types.ModuleType("my_modules.functions")
|
||||
fake_functions.get_ip = lambda: "203.0.113.10"
|
||||
fake_functions.enforce_custom_limit = lambda *_args, **_kwargs: None
|
||||
fake_functions.get_request_context = lambda: types.SimpleNamespace(path=request.path)
|
||||
fake_functions.is_valid_uuid = lambda value: True
|
||||
monkeypatch.setitem(sys.modules, "my_modules.functions", fake_functions)
|
||||
|
||||
def register_template_routes(app):
|
||||
side_main = Blueprint("side_main", __name__)
|
||||
|
||||
async def index():
|
||||
return "ok"
|
||||
|
||||
side_main.add_url_rule("/", "index", index)
|
||||
side_main.add_url_rule("/files", "files_list", index)
|
||||
side_main.add_url_rule("/access", "access_list", index)
|
||||
app.register_blueprint(side_main)
|
||||
|
||||
auth_login = Blueprint("auth_login", __name__)
|
||||
auth_login.add_url_rule("/login", "login", index)
|
||||
auth_login.add_url_rule("/logout", "logout", index)
|
||||
app.register_blueprint(auth_login)
|
||||
|
||||
|
||||
def load_errors_and_basics(monkeypatch, app):
|
||||
logger = FakeLogger()
|
||||
install_route_test_modules(monkeypatch, app, logger)
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
errors = load_module("test_routes_handeling_errorsAndBots", root / "routes" / "handeling" / "errorsAndBots.py")
|
||||
basics = load_module("test_routes_handeling_basics", root / "routes" / "handeling" / "basics.py")
|
||||
return errors, basics, logger
|
||||
|
||||
def test_convex_connect_error_is_returned_as_global_database_error(monkeypatch):
|
||||
async def run_test():
|
||||
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
|
||||
register_template_routes(app)
|
||||
app.convex = FailingConvex()
|
||||
_errors, basics, logger = load_errors_and_basics(monkeypatch, app)
|
||||
app.register_blueprint(basics.basic_bp)
|
||||
|
||||
response = await app.test_client().get("/favicon.ico")
|
||||
|
||||
assert response.status_code == 504
|
||||
assert any("Name or service not known" in str(error) for error in logger.errors)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_api_convex_connect_error_returns_json_database_error(monkeypatch):
|
||||
async def run_test():
|
||||
app = Quart(__name__, template_folder=str(Path(__file__).resolve().parents[1] / "templates" / "side"))
|
||||
load_errors_and_basics(monkeypatch, app)
|
||||
|
||||
@app.get("/api/failing")
|
||||
async def failing_api():
|
||||
raise httpx.ConnectError("[Errno -2] Name or service not known")
|
||||
|
||||
response = await app.test_client().get("/api/failing")
|
||||
payload = await response.get_json()
|
||||
|
||||
assert response.status_code == 504
|
||||
assert payload["error"] == "Database Error"
|
||||
|
||||
asyncio.run(run_test())
|
||||
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from quart import Quart
|
||||
|
||||
from servicelink import Request
|
||||
|
||||
# Load routes/api/link.py in isolation. Importing it as `routes.api.link` would
|
||||
# run routes/__init__.py -> my_modules.app.setup -> constens (asyncio.run at
|
||||
# import), which fails under pytest's output capture. We stub my_modules.app.setup
|
||||
# with a no-op limiter; link.py's other deps (my_modules.expiry/file_meta,
|
||||
# servicelink, quart) are side-effect free.
|
||||
class _NoOpLimiter:
|
||||
def limit(self, *args, **kwargs):
|
||||
return lambda fn: fn
|
||||
|
||||
_setup_stub = types.ModuleType('my_modules.app.setup')
|
||||
_setup_stub.LIMITER = _NoOpLimiter()
|
||||
sys.modules.setdefault('my_modules.app.setup', _setup_stub)
|
||||
|
||||
_LINK_PATH = Path(__file__).resolve().parents[1] / 'routes' / 'api' / 'link.py'
|
||||
_spec = importlib.util.spec_from_file_location('picoshare_link_under_test', _LINK_PATH)
|
||||
link = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(link)
|
||||
|
||||
class FakeConvex:
|
||||
def __init__(self):
|
||||
self.files = {}
|
||||
|
||||
async def decode_access_token_payload(self, access_token, required_scope=None, required_scopes=None):
|
||||
if access_token == 'good':
|
||||
return {'sub': 'user_123', 'scope': 'all'}
|
||||
if access_token == 'nomesh':
|
||||
return {'sub': 'user_123', 'scope': 'files'}
|
||||
return {'error': 'invalid token'}
|
||||
|
||||
async def send_to_storage(self, data, content_type):
|
||||
return 'storage_1'
|
||||
|
||||
async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id):
|
||||
self.files[file_name] = {
|
||||
'file_name': file_name,
|
||||
'note': note,
|
||||
'content_type': content_type,
|
||||
'storage_id': storage_id,
|
||||
'user_id': user_id,
|
||||
}
|
||||
return self.files[file_name]
|
||||
|
||||
async def get_file(self, file_id):
|
||||
return self.files.get(file_id)
|
||||
|
||||
def _call(envelope, token=None):
|
||||
# Call the handler the /rpc route delegates to, bypassing the LIMITER/size-cap
|
||||
# wrapper; exercises the real verify (mesh scope) + dispatch + handlers.
|
||||
async def run():
|
||||
app = Quart(__name__)
|
||||
app.convex = FakeConvex()
|
||||
async with app.test_request_context('/rpc', method='POST'):
|
||||
status, body, _ = await link.handle_envelope(
|
||||
link.router,
|
||||
json.dumps(envelope).encode('utf-8'),
|
||||
authorization=f'Bearer {token}' if token else None,
|
||||
verify=link._verify,
|
||||
content_type='application/json',
|
||||
)
|
||||
return status, json.loads(body)
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
def _request(method, params=None):
|
||||
return Request.create(method, params or {}, source='browser-cli', target='picoshare').to_dict()
|
||||
|
||||
def test_text_upload_roundtrip():
|
||||
status, body = _call(_request('files.upload', {'text': 'hello mesh', 'file_name': 'note.txt', 'note': 'scraped'}), token='good')
|
||||
assert status == 200
|
||||
assert body['ok'] is True
|
||||
assert body['result']['file_name'] == 'note.txt'
|
||||
assert body['result']['content_type'] == 'text/plain'
|
||||
assert body['result']['size'] == len('hello mesh')
|
||||
|
||||
def test_upload_requires_content():
|
||||
status, body = _call(_request('files.upload', {'file_name': 'x'}), token='good')
|
||||
assert status == 422
|
||||
assert body['error']['code'] == 'invalid_params'
|
||||
|
||||
def test_get_missing_file_is_not_found():
|
||||
status, body = _call(_request('files.get', {'file_id': 'ghost'}), token='good')
|
||||
assert status == 404
|
||||
assert body['error']['code'] == 'not_found'
|
||||
|
||||
def test_missing_token_is_unauthorized():
|
||||
status, body = _call(_request('files.list'))
|
||||
assert status == 401
|
||||
assert body['error']['code'] == 'unauthorized'
|
||||
|
||||
def test_token_without_mesh_scope_is_forbidden():
|
||||
status, body = _call(_request('files.list'), token='nomesh')
|
||||
assert status == 403
|
||||
assert body['error']['code'] == 'forbidden'
|
||||
Reference in New Issue
Block a user