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:
@@ -7,3 +7,6 @@
|
|||||||
[submodule "quart_common"]
|
[submodule "quart_common"]
|
||||||
path = quart_common
|
path = quart_common
|
||||||
url = git@git.yiprawr.dev:submodules/python-quart-common.git
|
url = git@git.yiprawr.dev:submodules/python-quart-common.git
|
||||||
|
[submodule "servicelink"]
|
||||||
|
path = servicelink
|
||||||
|
url = git@git.yiprawr.dev:submodules/servicelink.git
|
||||||
|
|||||||
+1
-1
Submodule my_helpers updated: 7793ff7958...059338cffb
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanoshare"
|
name = "nanoshare"
|
||||||
version = "1.20.0"
|
version = "1.21.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
+1
-1
Submodule quart_common updated: 5c35d9ea2e...77823f57d1
@@ -0,0 +1,139 @@
|
|||||||
|
'''ServiceLink mesh endpoint for the picoshare/NanoShare node.
|
||||||
|
|
||||||
|
Lets other nodes (browser-cli, website) push files in and read file metadata
|
||||||
|
over the shared servicelink envelope at POST /rpc. Sits alongside the existing
|
||||||
|
web UI and /api routes.
|
||||||
|
|
||||||
|
Security: every call needs a bearer token that decodes to the `mesh` scope
|
||||||
|
(or `all`); the endpoint is rate limited and body-size capped. Intended to be
|
||||||
|
reachable only from the internal node network (lock it down at the reverse
|
||||||
|
proxy), with the token as defence-in-depth.
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, current_app, request
|
||||||
|
|
||||||
|
from my_modules.app.setup import LIMITER
|
||||||
|
from my_modules.expiry import ensure_utc, parse_expires
|
||||||
|
from my_modules.file_meta import format_size, iso_stamp_filename
|
||||||
|
from servicelink import Forbidden, InvalidParams, NotFound, Router, Unauthorized, bearer_verifier, handle_envelope
|
||||||
|
|
||||||
|
# Cap the /rpc body. base64 inflates ~33%, so this bounds upload size too.
|
||||||
|
MAX_RPC_BODY = 16 * 1024 * 1024
|
||||||
|
MESH_SCOPE = 'mesh'
|
||||||
|
|
||||||
|
router = Router('picoshare')
|
||||||
|
|
||||||
|
def _user_id(ctx):
|
||||||
|
if ctx.principal is None:
|
||||||
|
raise Unauthorized('authentication required')
|
||||||
|
return ctx.principal.subject
|
||||||
|
|
||||||
|
@router.method('files.upload')
|
||||||
|
async def files_upload(params, ctx):
|
||||||
|
user_id = _user_id(ctx)
|
||||||
|
text = params.get('text')
|
||||||
|
content_b64 = params.get('content_b64')
|
||||||
|
note = params.get('note', '')
|
||||||
|
expires_at = ensure_utc(parse_expires(params.get('expires', '')))
|
||||||
|
|
||||||
|
if content_b64:
|
||||||
|
data = base64.b64decode(content_b64)
|
||||||
|
content_type = params.get('content_type') or 'application/octet-stream'
|
||||||
|
default_ext = 'bin'
|
||||||
|
elif text is not None:
|
||||||
|
data = text.encode('utf-8')
|
||||||
|
content_type = 'text/plain'
|
||||||
|
default_ext = 'txt'
|
||||||
|
else:
|
||||||
|
raise InvalidParams('provide text or content_b64')
|
||||||
|
|
||||||
|
file_name = params.get('file_name') or iso_stamp_filename('mesh', default_ext)
|
||||||
|
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
|
||||||
|
await current_app.convex.add_file(
|
||||||
|
file_name=file_name,
|
||||||
|
file_size=format_size(len(data)),
|
||||||
|
note=note,
|
||||||
|
content_type=content_type,
|
||||||
|
expires_at=expires_at,
|
||||||
|
storage_id=storage_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
return {'file_name': file_name, 'size': len(data), 'content_type': content_type}
|
||||||
|
|
||||||
|
@router.method('files.list')
|
||||||
|
async def files_list(params, ctx):
|
||||||
|
return {'files': await current_app.convex.get_files(user_id=_user_id(ctx))}
|
||||||
|
|
||||||
|
@router.method('files.get')
|
||||||
|
async def files_get(params, ctx):
|
||||||
|
file_id = params.get('file_id')
|
||||||
|
if not file_id:
|
||||||
|
raise InvalidParams('file_id is required')
|
||||||
|
meta = await current_app.convex.get_file(file_id)
|
||||||
|
if not meta:
|
||||||
|
raise NotFound('no such file', data={'file_id': file_id})
|
||||||
|
return meta
|
||||||
|
|
||||||
|
@router.method('files.info')
|
||||||
|
async def files_info(params, ctx):
|
||||||
|
file_id = params.get('file_id')
|
||||||
|
if not file_id:
|
||||||
|
raise InvalidParams('file_id is required')
|
||||||
|
return await current_app.convex.get_file_informations(file_id, _user_id(ctx))
|
||||||
|
|
||||||
|
@router.method('files.update')
|
||||||
|
async def files_update(params, ctx):
|
||||||
|
file_id = params.get('file_id')
|
||||||
|
file_name = params.get('file_name')
|
||||||
|
if not file_id or not file_name:
|
||||||
|
raise InvalidParams('file_id and file_name are required')
|
||||||
|
await current_app.convex.update_file(
|
||||||
|
file_id=file_id,
|
||||||
|
file_name=file_name,
|
||||||
|
note=params.get('note', ''),
|
||||||
|
expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
|
||||||
|
user_id=_user_id(ctx),
|
||||||
|
)
|
||||||
|
return {'updated': True}
|
||||||
|
|
||||||
|
@router.method('files.delete')
|
||||||
|
async def files_delete(params, ctx):
|
||||||
|
file_id = params.get('file_id')
|
||||||
|
if not file_id:
|
||||||
|
raise InvalidParams('file_id is required')
|
||||||
|
await current_app.convex.delete_file(file_id, _user_id(ctx))
|
||||||
|
return {'deleted': True}
|
||||||
|
|
||||||
|
async def _decode_access_token(token):
|
||||||
|
payload = await current_app.convex.decode_access_token_payload(access_token=token)
|
||||||
|
if not payload or payload.get('error') or not payload.get('sub'):
|
||||||
|
raise ValueError((payload or {}).get('error', 'invalid token'))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
_base_verify = bearer_verifier(_decode_access_token)
|
||||||
|
|
||||||
|
async def _verify(authorization, req):
|
||||||
|
principal = await _base_verify(authorization, req)
|
||||||
|
if principal is None or not principal.has_scope(MESH_SCOPE):
|
||||||
|
raise Forbidden(f'token lacks the {MESH_SCOPE!r} scope')
|
||||||
|
return principal
|
||||||
|
|
||||||
|
link_bp = Blueprint('servicelink_picoshare', __name__)
|
||||||
|
|
||||||
|
@link_bp.post('/rpc')
|
||||||
|
@LIMITER.limit('30 per minute')
|
||||||
|
async def rpc_endpoint():
|
||||||
|
if (request.content_length or 0) > MAX_RPC_BODY:
|
||||||
|
return Response('{"error":"payload too large"}', status=413, content_type='application/json')
|
||||||
|
raw = await request.get_data()
|
||||||
|
status, body, content_type = await handle_envelope(
|
||||||
|
router,
|
||||||
|
raw,
|
||||||
|
authorization=request.headers.get('Authorization'),
|
||||||
|
verify=_verify,
|
||||||
|
content_type=request.headers.get('Content-Type', 'application/json'),
|
||||||
|
)
|
||||||
|
return Response(body, status=status, content_type=content_type)
|
||||||
@@ -13,6 +13,7 @@ from my_modules.functions import (
|
|||||||
)
|
)
|
||||||
from quart_common.web.env import is_development_environment
|
from quart_common.web.env import is_development_environment
|
||||||
from quart_common.web.wide_event import add_wide_event_context
|
from quart_common.web.wide_event import add_wide_event_context
|
||||||
|
import httpx
|
||||||
|
|
||||||
IGNORED_404_PATHS = [
|
IGNORED_404_PATHS = [
|
||||||
"/.well-known/",
|
"/.well-known/",
|
||||||
@@ -186,6 +187,11 @@ async def internal_server_error(e):
|
|||||||
file={'name': '500.webp', 'alt': "Astronaut jumping and clicking on random Buttons as a red alert gone off - They is a Text on the Image saying: Why don't shit Work!?!"},
|
file={'name': '500.webp', 'alt': "Astronaut jumping and clicking on random Buttons as a red alert gone off - They is a Text on the Image saying: Why don't shit Work!?!"},
|
||||||
), 500
|
), 500
|
||||||
|
|
||||||
|
@app.errorhandler(httpx.HTTPError)
|
||||||
|
@app.errorhandler(TimeoutError)
|
||||||
|
async def database_unavailable_error(e):
|
||||||
|
return await database_server_error(e)
|
||||||
|
|
||||||
@app.errorhandler(504)
|
@app.errorhandler(504)
|
||||||
async def database_server_error(e):
|
async def database_server_error(e):
|
||||||
try:
|
try:
|
||||||
@@ -193,8 +199,12 @@ async def database_server_error(e):
|
|||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
return await to_many_requests(e)
|
return await to_many_requests(e)
|
||||||
|
|
||||||
|
context = get_request_context()
|
||||||
add_wide_event_context(error={"type": type(e).__name__, "message": str(e)})
|
add_wide_event_context(error={"type": type(e).__name__, "message": str(e)})
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
if context.path.startswith("/api"):
|
||||||
|
return jsonify({"error": "Database Error", "message": "The database is currently unavailable. Please try again in a moment."}), 504
|
||||||
|
|
||||||
return await render_template('views/basics/error.htm',
|
return await render_template('views/basics/error.htm',
|
||||||
title='Database Error',
|
title='Database Error',
|
||||||
header={'title': '504 - Database Error', 'message': "It looks like something is broke on our end... but don't worry, we're fixing it! Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"},
|
header={'title': '504 - Database Error', 'message': "It looks like something is broke on our end... but don't worry, we're fixing it! Either way, thanks for helping us find new ways to crash our system. Stay curious, hacker-friend!"},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from routes import (
|
|||||||
upload_bp,
|
upload_bp,
|
||||||
health_bp
|
health_bp
|
||||||
)
|
)
|
||||||
|
from routes.api.link import link_bp as servicelink_bp
|
||||||
|
|
||||||
# Views for Requests adding the uris
|
# Views for Requests adding the uris
|
||||||
app.register_blueprint(basic_bp)
|
app.register_blueprint(basic_bp)
|
||||||
@@ -21,6 +22,9 @@ app.register_blueprint(auth_login_bp)
|
|||||||
app.register_blueprint(side_main_bp)
|
app.register_blueprint(side_main_bp)
|
||||||
app.register_blueprint(upload_bp)
|
app.register_blueprint(upload_bp)
|
||||||
|
|
||||||
|
# ServiceLink node-to-node mesh endpoint (POST /rpc)
|
||||||
|
app.register_blueprint(servicelink_bp)
|
||||||
|
|
||||||
app.register_blueprint(health_bp, url_prefix='/health')
|
app.register_blueprint(health_bp, url_prefix='/health')
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Submodule
+1
Submodule servicelink added at 946d79c95d
@@ -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