Add OAuth2/OIDC per-user Gitea authentication
Introduce a GiteaOAuthValidator for JWT and userinfo validation and fallbacks, add /oauth/token proxy, and thread per-user tokens through the request context and automation paths. Update config and .env.example for OAuth-first mode, add OpenAPI, extensive unit/integration tests, GitHub/Gitea CI workflows, docs, and lint/test enforcement (>=80% cov).
This commit is contained in:
@@ -1,158 +1,144 @@
|
||||
"""Tests for MCP server endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aegis_gitea_mcp.auth import reset_validator
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError, GiteaAuthorizationError
|
||||
from aegis_gitea_mcp.oauth import reset_oauth_validator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
def reset_state() -> None:
|
||||
"""Reset global state between tests."""
|
||||
reset_settings()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
yield
|
||||
reset_settings()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env(monkeypatch):
|
||||
"""Set up test environment."""
|
||||
def oauth_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set OAuth-first environment for server tests."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_auth_disabled(monkeypatch):
|
||||
"""Set up test environment with auth disabled."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
def mock_oauth_validation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Mock OAuth validator outcomes by token value."""
|
||||
|
||||
async def _validate(_self, token: str | None, _ip: str, _ua: str):
|
||||
if token == "valid-read":
|
||||
return True, None, {"login": "alice", "scopes": ["read:repository"]}
|
||||
if token == "valid-write":
|
||||
return (
|
||||
True,
|
||||
None,
|
||||
{
|
||||
"login": "alice",
|
||||
"scopes": ["read:repository", "write:repository"],
|
||||
},
|
||||
)
|
||||
return False, "Invalid or expired OAuth token.", None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token",
|
||||
_validate,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_env):
|
||||
"""Create test client."""
|
||||
# Import after setting env vars
|
||||
def client(oauth_env: None, mock_oauth_validation: None) -> TestClient:
|
||||
"""Create FastAPI test client."""
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_no_auth(mock_env_auth_disabled):
|
||||
"""Create test client with auth disabled."""
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_root_endpoint(client):
|
||||
"""Test root endpoint returns server info."""
|
||||
def test_root_endpoint(client: TestClient) -> None:
|
||||
"""Root endpoint returns server metadata."""
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "AegisGitea MCP Server"
|
||||
assert "version" in data
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
def test_health_endpoint(client):
|
||||
"""Test health check endpoint."""
|
||||
def test_health_endpoint(client: TestClient) -> None:
|
||||
"""Health endpoint does not require auth."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
|
||||
def test_oauth_protected_resource_metadata(client: TestClient) -> None:
|
||||
"""OAuth protected-resource metadata contains required OpenAI-compatible fields."""
|
||||
response = client.get("/.well-known/oauth-protected-resource")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert data["resource"] == "https://gitea.example.com"
|
||||
assert data["authorization_servers"] == ["https://gitea.example.com"]
|
||||
assert data["bearer_methods_supported"] == ["header"]
|
||||
assert data["scopes_supported"] == ["read:repository", "write:repository"]
|
||||
assert "resource_documentation" in data
|
||||
|
||||
|
||||
def test_metrics_endpoint(client):
|
||||
"""Metrics endpoint should be available for observability."""
|
||||
response = client.get("/metrics")
|
||||
def test_oauth_authorization_server_metadata(client: TestClient) -> None:
|
||||
"""Auth server metadata includes expected OAuth endpoints and scopes."""
|
||||
response = client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
assert "aegis_http_requests_total" in response.text
|
||||
payload = response.json()
|
||||
assert payload["authorization_endpoint"].endswith("/login/oauth/authorize")
|
||||
assert payload["token_endpoint"].endswith("/oauth/token")
|
||||
assert payload["scopes_supported"] == ["read:repository", "write:repository"]
|
||||
|
||||
|
||||
def test_health_endpoint_no_auth_required(client):
|
||||
"""Test that health check doesn't require authentication."""
|
||||
response = client.get("/health")
|
||||
|
||||
# Should work without Authorization header
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_list_tools_without_auth(client):
|
||||
"""Test that /mcp/tools is public (Mixed mode for ChatGPT)."""
|
||||
def test_list_tools_without_auth(client: TestClient) -> None:
|
||||
"""Tool listing remains discoverable without auth."""
|
||||
response = client.get("/mcp/tools")
|
||||
|
||||
# Tool listing is public to support ChatGPT discovery
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
assert "tools" in response.json()
|
||||
|
||||
|
||||
def test_list_tools_with_invalid_key(client):
|
||||
"""Test /mcp/tools works even with invalid key (public endpoint)."""
|
||||
response = client.get(
|
||||
"/mcp/tools",
|
||||
headers={"Authorization": "Bearer invalid-key-12345678901234567890123456789012"},
|
||||
def test_call_tool_without_auth_returns_challenge(client: TestClient) -> None:
|
||||
"""Tool calls without bearer token return 401 + WWW-Authenticate challenge."""
|
||||
response = client.post("/mcp/tool/call", json={"tool": "list_repositories", "arguments": {}})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
challenge = response.headers["WWW-Authenticate"]
|
||||
assert 'resource_metadata="http://testserver/.well-known/oauth-protected-resource"' in challenge
|
||||
assert 'scope="read:repository"' in challenge
|
||||
|
||||
|
||||
def test_call_tool_invalid_token_returns_challenge(client: TestClient) -> None:
|
||||
"""Invalid bearer token returns 401 + WWW-Authenticate challenge."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
|
||||
# Tool listing is public, so even invalid keys can list tools
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
|
||||
def test_list_tools_with_valid_key(client, mock_env):
|
||||
"""Test /mcp/tools with valid API key."""
|
||||
response = client.get("/mcp/tools", headers={"Authorization": f"Bearer {'a' * 64}"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
assert len(data["tools"]) > 0
|
||||
|
||||
# Check tool structure
|
||||
tool = data["tools"][0]
|
||||
assert "name" in tool
|
||||
assert "description" in tool
|
||||
assert "inputSchema" in tool
|
||||
|
||||
|
||||
def test_list_tools_with_query_param(client):
|
||||
"""Test /mcp/tools with API key in query parameter."""
|
||||
response = client.get(f"/mcp/tools?api_key={'a' * 64}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
assert len(data["tools"]) > 0
|
||||
|
||||
|
||||
def test_list_tools_no_auth_when_disabled(client_no_auth):
|
||||
"""Test that /mcp/tools works without auth when disabled."""
|
||||
response = client_no_auth.get("/mcp/tools")
|
||||
|
||||
# Should work without Authorization header when auth is disabled
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
|
||||
|
||||
def test_sse_tools_list_returns_camel_case_schema(client):
|
||||
def test_sse_tools_list_returns_camel_case_schema(client: TestClient) -> None:
|
||||
"""SSE tools/list returns MCP-compatible camelCase inputSchema."""
|
||||
response = client.post(
|
||||
f"/mcp/sse?api_key={'a' * 64}",
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"},
|
||||
)
|
||||
|
||||
@@ -160,48 +146,95 @@ def test_sse_tools_list_returns_camel_case_schema(client):
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
assert "tools" in data["result"]
|
||||
tool = data["result"]["tools"][0]
|
||||
assert "inputSchema" in tool
|
||||
assert "type" in tool["inputSchema"]
|
||||
assert "inputSchema" in data["result"]["tools"][0]
|
||||
|
||||
|
||||
def test_call_tool_without_auth(client):
|
||||
"""Test that /mcp/tool/call requires authentication."""
|
||||
response = client.post("/mcp/tool/call", json={"tool": "list_repositories", "arguments": {}})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_call_tool_with_invalid_key(client):
|
||||
"""Test /mcp/tool/call with invalid API key."""
|
||||
def test_sse_initialize_message(client: TestClient) -> None:
|
||||
"""SSE initialize message returns protocol and server metadata."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer invalid-key-12345678901234567890123456789012"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["result"]["protocolVersion"] == "2024-11-05"
|
||||
assert payload["result"]["serverInfo"]["name"] == "AegisGitea MCP"
|
||||
|
||||
|
||||
def test_call_nonexistent_tool(client):
|
||||
"""Test calling a tool that doesn't exist."""
|
||||
def test_sse_tools_call_success_response(
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""SSE tools/call wraps successful tool output in text content."""
|
||||
|
||||
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
|
||||
assert tool_name == "list_repositories"
|
||||
assert isinstance(arguments, dict)
|
||||
assert correlation_id
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "call-1",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_repositories", "arguments": {}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '"ok": true' in response.json()["result"]["content"][0]["text"].lower()
|
||||
|
||||
|
||||
def test_sse_tools_call_http_exception(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""SSE tools/call maps HTTPException to JSON-RPC error envelope."""
|
||||
|
||||
async def _fake_execute(_tool_name: str, _arguments: dict, _correlation_id: str) -> dict:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=403, detail="Insufficient scope")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
|
||||
|
||||
response = client.post(
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "call-2",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "create_issue", "arguments": {}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["error"]["code"] == -32000
|
||||
assert "insufficient scope" in body["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_call_nonexistent_tool(client: TestClient) -> None:
|
||||
"""Unknown tools return 404 after successful auth."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "nonexistent_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
# Tool not found returns 404 (auth passes but tool missing)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "not found" in data["detail"].lower()
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_write_tool_denied_by_default_policy(client):
|
||||
"""Write tools must be denied when write mode is disabled."""
|
||||
def test_write_scope_enforced_before_policy(client: TestClient) -> None:
|
||||
"""Write tools require write:repository scope."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"tool": "create_issue",
|
||||
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
|
||||
@@ -209,96 +242,74 @@ def test_write_tool_denied_by_default_policy(client):
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert "policy denied" in data["detail"].lower()
|
||||
assert "required scope: write:repository" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_sse_endpoint_without_auth(client):
|
||||
"""Test that SSE endpoint requires authentication."""
|
||||
response = client.get("/mcp/sse")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_auth_header_formats(client):
|
||||
"""Test various Authorization header formats on protected endpoint."""
|
||||
# Test with /mcp/tool/call since /mcp/tools is now public
|
||||
tool_data = {"tool": "list_repositories", "arguments": {}}
|
||||
|
||||
# Missing "Bearer" prefix
|
||||
response = client.post("/mcp/tool/call", headers={"Authorization": "a" * 64}, json=tool_data)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Wrong case
|
||||
def test_write_tool_denied_by_default_policy(client: TestClient) -> None:
|
||||
"""Even with write scope, write mode stays denied by default policy."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call", headers={"Authorization": "bearer " + "a" * 64}, json=tool_data
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-write"},
|
||||
json={
|
||||
"tool": "create_issue",
|
||||
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Extra spaces
|
||||
response = client.post(
|
||||
"/mcp/tool/call", headers={"Authorization": f"Bearer {'a' * 64}"}, json=tool_data
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 403
|
||||
assert "write mode is disabled" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_rate_limiting(client):
|
||||
"""Test rate limiting after multiple failed auth attempts."""
|
||||
tool_data = {"tool": "list_repositories", "arguments": {}}
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_event_fails_when_discovery_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Startup validation fails with clear guidance if OIDC discovery is unreachable."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
# Make 6 failed attempts on protected endpoint
|
||||
for _ in range(6):
|
||||
response = client.post(
|
||||
"/mcp/tool/call", headers={"Authorization": "Bearer " + "x" * 64}, json=tool_data
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(
|
||||
side_effect=httpx.RequestError("connect failed", request=MagicMock())
|
||||
)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
# Last response should mention rate limiting
|
||||
data = response.json()
|
||||
assert "Too many failed" in data["message"]
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="unable to reach Gitea OIDC discovery endpoint",
|
||||
):
|
||||
await server.startup_event()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_event_fails_with_authentication_guidance(monkeypatch):
|
||||
"""Startup validation should fail with explicit auth guidance on 401."""
|
||||
async def test_startup_event_succeeds_when_discovery_ready(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Startup validation succeeds when OIDC discovery returns HTTP 200."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("OAUTH_MODE", "true")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
|
||||
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
async def raise_auth_error(*_args, **_kwargs):
|
||||
raise GiteaAuthenticationError("Authentication failed - check bot token")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
monkeypatch.setattr(server.GiteaClient, "get_current_user", raise_auth_error)
|
||||
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match=r"Startup validation failed: Gitea authentication was rejected"
|
||||
):
|
||||
await server.startup_event()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_event_fails_with_authorization_guidance(monkeypatch):
|
||||
"""Startup validation should fail with explicit permission guidance on 403."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
async def raise_authorization_error(*_args, **_kwargs):
|
||||
raise GiteaAuthorizationError("Bot user lacks permission for this operation")
|
||||
|
||||
monkeypatch.setattr(server.GiteaClient, "get_current_user", raise_authorization_error)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match=r"Startup validation failed: Gitea token lacks permission for /api/v1/user",
|
||||
):
|
||||
await server.startup_event()
|
||||
|
||||
Reference in New Issue
Block a user