Refactor user REST API (#14334)

### What problem does this PR solve?
Refactor user REST API

### Type of change
- [x] Refactoring
This commit is contained in:
Wang Qi
2026-04-24 10:25:15 +08:00
committed by GitHub
parent c41b5e8a5d
commit 199fbceb72
17 changed files with 58 additions and 507 deletions

View File

@@ -20,7 +20,7 @@ oauth_config = {
"authorization_url": "https://your-oauth-provider.com/oauth/authorize",
"token_url": "https://your-oauth-provider.com/oauth/token",
"userinfo_url": "https://your-oauth-provider.com/oauth/userinfo",
"redirect_uri": "https://your-app.com/v1/user/oauth/callback/<channel>"
"redirect_uri": "https://your-app.com/api/v1/auth/oauth/<channel>/callback"
}
# OIDC configuration
@@ -29,7 +29,7 @@ oidc_config = {
"issuer": "https://your-oauth-provider.com/oidc",
"client_id": "your_client_id",
"client_secret": "your_client_secret",
"redirect_uri": "https://your-app.com/v1/user/oauth/callback/<channel>"
"redirect_uri": "https://your-app.com/api/v1/auth/oauth/<channel>/callback"
}
# Github OAuth configuration

View File

@@ -13,7 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
import json
import logging
import string
import os
@@ -60,10 +59,9 @@ from api.utils.web_utils import (
captcha_key,
)
from common import settings
from common.http_client import async_request
@manager.route("/login", methods=["POST", "GET"]) # noqa: F821
@manager.route("/auth/login", methods=["POST"]) # noqa: F821
async def login():
"""
User login endpoint.
@@ -140,7 +138,7 @@ async def login():
)
@manager.route("/login/channels", methods=["GET"]) # noqa: F821
@manager.route("/auth/login/channels", methods=["GET"]) # noqa: F821
async def get_login_channels():
"""
Get all supported authentication channels.
@@ -161,7 +159,7 @@ async def get_login_channels():
return get_json_result(data=[], message=f"Load channels failure, error: {str(e)}", code=RetCode.EXCEPTION_ERROR)
@manager.route("/login/<channel>", methods=["GET"]) # noqa: F821
@manager.route("/auth/login/<channel>", methods=["GET"]) # noqa: F821
async def oauth_login(channel):
channel_config = settings.OAUTH_CONFIG.get(channel)
if not channel_config:
@@ -174,7 +172,7 @@ async def oauth_login(channel):
return redirect(auth_url)
@manager.route("/oauth/callback/<channel>", methods=["GET"]) # noqa: F821
@manager.route("/auth/oauth/<channel>/callback", methods=["GET"]) # noqa: F821
async def oauth_callback(channel):
"""
Handle the OAuth/OIDC callback for various channels dynamically.
@@ -269,224 +267,7 @@ async def oauth_callback(channel):
return redirect(f"/?error={str(e)}")
@manager.route("/github_callback", methods=["GET"]) # noqa: F821
async def github_callback():
"""
**Deprecated**, Use `/oauth/callback/<channel>` instead.
GitHub OAuth callback endpoint.
---
tags:
- OAuth
parameters:
- in: query
name: code
type: string
required: true
description: Authorization code from GitHub.
responses:
200:
description: Authentication successful.
schema:
type: object
"""
res = await async_request(
"POST",
settings.GITHUB_OAUTH.get("url"),
data={
"client_id": settings.GITHUB_OAUTH.get("client_id"),
"client_secret": settings.GITHUB_OAUTH.get("secret_key"),
"code": request.args.get("code"),
},
headers={"Accept": "application/json"},
)
res = res.json()
if "error" in res:
return redirect("/?error=%s" % res["error_description"])
if "user:email" not in res["scope"].split(","):
return redirect("/?error=user:email not in scope")
session["access_token"] = res["access_token"]
session["access_token_from"] = "github"
user_info = await user_info_from_github(session["access_token"])
email_address = user_info["email"]
users = UserService.query(email=email_address)
user_id = get_uuid()
if not users:
# User isn't try to register
try:
try:
avatar = await download_img(user_info["avatar_url"])
except Exception as e:
logging.exception(e)
avatar = ""
users = user_register(
user_id,
{
"access_token": session["access_token"],
"email": email_address,
"avatar": avatar,
"nickname": user_info["login"],
"login_channel": "github",
"last_login_time": get_format_time(),
"is_superuser": False,
},
)
if not users:
raise Exception(f"Fail to register {email_address}.")
if len(users) > 1:
raise Exception(f"Same email: {email_address} exists!")
# Try to log in
user = users[0]
login_user(user)
return redirect("/?auth=%s" % user.get_id())
except Exception as e:
rollback_user_registration(user_id)
logging.exception(e)
return redirect("/?error=%s" % str(e))
# User has already registered, try to log in
user = users[0]
user.access_token = get_uuid()
if user and hasattr(user, 'is_active') and user.is_active == "0":
return redirect("/?error=user_inactive")
login_user(user)
user.save()
return redirect("/?auth=%s" % user.get_id())
@manager.route("/feishu_callback", methods=["GET"]) # noqa: F821
async def feishu_callback():
"""
Feishu OAuth callback endpoint.
---
tags:
- OAuth
parameters:
- in: query
name: code
type: string
required: true
description: Authorization code from Feishu.
responses:
200:
description: Authentication successful.
schema:
type: object
"""
app_access_token_res = await async_request(
"POST",
settings.FEISHU_OAUTH.get("app_access_token_url"),
data=json.dumps(
{
"app_id": settings.FEISHU_OAUTH.get("app_id"),
"app_secret": settings.FEISHU_OAUTH.get("app_secret"),
}
),
headers={"Content-Type": "application/json; charset=utf-8"},
)
app_access_token_res = app_access_token_res.json()
if app_access_token_res["code"] != 0:
return redirect("/?error=%s" % app_access_token_res)
res = await async_request(
"POST",
settings.FEISHU_OAUTH.get("user_access_token_url"),
data=json.dumps(
{
"grant_type": settings.FEISHU_OAUTH.get("grant_type"),
"code": request.args.get("code"),
}
),
headers={
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Bearer {app_access_token_res['app_access_token']}",
},
)
res = res.json()
if res["code"] != 0:
return redirect("/?error=%s" % res["message"])
if "contact:user.email:readonly" not in res["data"]["scope"].split():
return redirect("/?error=contact:user.email:readonly not in scope")
session["access_token"] = res["data"]["access_token"]
session["access_token_from"] = "feishu"
user_info = await user_info_from_feishu(session["access_token"])
email_address = user_info["email"]
users = UserService.query(email=email_address)
user_id = get_uuid()
if not users:
# User isn't try to register
try:
try:
avatar = await download_img(user_info["avatar_url"])
except Exception as e:
logging.exception(e)
avatar = ""
users = user_register(
user_id,
{
"access_token": session["access_token"],
"email": email_address,
"avatar": avatar,
"nickname": user_info["en_name"],
"login_channel": "feishu",
"last_login_time": get_format_time(),
"is_superuser": False,
},
)
if not users:
raise Exception(f"Fail to register {email_address}.")
if len(users) > 1:
raise Exception(f"Same email: {email_address} exists!")
# Try to log in
user = users[0]
login_user(user)
return redirect("/?auth=%s" % user.get_id())
except Exception as e:
rollback_user_registration(user_id)
logging.exception(e)
return redirect("/?error=%s" % str(e))
# User has already registered, try to log in
user = users[0]
if user and hasattr(user, 'is_active') and user.is_active == "0":
return redirect("/?error=user_inactive")
user.access_token = get_uuid()
login_user(user)
user.save()
return redirect("/?auth=%s" % user.get_id())
async def user_info_from_feishu(access_token):
headers = {
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Bearer {access_token}",
}
res = await async_request("GET", "https://open.feishu.cn/open-apis/authen/v1/user_info", headers=headers)
user_info = res.json()["data"]
user_info["email"] = None if user_info.get("email") == "" else user_info["email"]
return user_info
async def user_info_from_github(access_token):
headers = {"Accept": "application/json", "Authorization": f"token {access_token}"}
res = await async_request("GET", f"https://api.github.com/user?access_token={access_token}", headers=headers)
user_info = res.json()
email_info_response = await async_request(
"GET",
f"https://api.github.com/user/emails?access_token={access_token}",
headers=headers,
)
email_info = email_info_response.json()
user_info["email"] = next((email for email in email_info if email["primary"]), None)["email"]
return user_info
@manager.route("/logout", methods=["GET"]) # noqa: F821
@manager.route("/auth/logout", methods=["POST"]) # noqa: F821
@login_required
async def log_out():
"""
@@ -508,7 +289,7 @@ async def log_out():
return get_json_result(data=True)
@manager.route("/setting", methods=["POST"]) # noqa: F821
@manager.route("/users/me", methods=["PATCH"]) # noqa: F821
@login_required
async def setting_user():
"""
@@ -576,7 +357,7 @@ async def setting_user():
return get_json_result(data=False, message="Update failure!", code=RetCode.EXCEPTION_ERROR)
@manager.route("/info", methods=["GET"]) # noqa: F821
@manager.route("/users/me", methods=["GET"]) # noqa: F821
@login_required
async def user_profile():
"""
@@ -667,7 +448,7 @@ def user_register(user_id, user):
return UserService.query(email=user["email"])
@manager.route("/register", methods=["POST"]) # noqa: F821
@manager.route("/users", methods=["POST"]) # noqa: F821
@validate_request("nickname", "email", "password")
async def user_add():
"""
@@ -761,7 +542,7 @@ async def user_add():
)
@manager.route("/tenant_info", methods=["GET"]) # noqa: F821
@manager.route("/users/me/models", methods=["GET"]) # noqa: F821
@login_required
async def tenant_info():
"""
@@ -799,7 +580,7 @@ async def tenant_info():
return server_error_response(e)
@manager.route("/set_tenant_info", methods=["POST"]) # noqa: F821
@manager.route("/users/me/models", methods=["PATCH"]) # noqa: F821
@login_required
@validate_request("tenant_id", "asr_id", "embd_id", "img2txt_id", "llm_id")
async def set_tenant_info():
@@ -849,7 +630,7 @@ async def set_tenant_info():
return server_error_response(e)
@manager.route("/forget/captcha", methods=["GET"]) # noqa: F821
@manager.route("/auth/password/forgot/captcha", methods=["POST"]) # noqa: F821
async def forget_get_captcha():
"""
GET /forget/captcha?email=<email>
@@ -877,7 +658,7 @@ async def forget_get_captcha():
return response
@manager.route("/forget/otp", methods=["POST"]) # noqa: F821
@manager.route("/auth/password/forgot/otp", methods=["POST"]) # noqa: F821
async def forget_send_otp():
"""
POST /forget/otp
@@ -947,7 +728,7 @@ def _verified_key(email: str) -> str:
return f"otp:verified:{email}"
@manager.route("/forget/verify-otp", methods=["POST"]) # noqa: F821
@manager.route("/auth/password/forgot/otp/verify", methods=["POST"]) # noqa: F821
async def forget_verify_otp():
"""
Verify email + OTP only. On success:
@@ -1008,7 +789,7 @@ async def forget_verify_otp():
return get_json_result(data=True, code=RetCode.SUCCESS, message="otp verified")
@manager.route("/forget/reset-password", methods=["POST"]) # noqa: F821
@manager.route("/auth/password/reset", methods=["POST"]) # noqa: F821
async def forget_reset_password():
"""
Reset password after successful OTP verification.

View File

@@ -40,7 +40,7 @@ X8f7fp9c7vUsfOCkM+gHY3PadG+QHa7KI7mzTKgUTZImK6BZtfRBATDTthEUbbaTewY4H0MnWiCeeDhc
def register():
url = HOST_ADDRESS + "/v1/user/register"
url = HOST_ADDRESS + "/api/v1/users"
name = "user"
register_data = {"email": EMAIL, "nickname": name, "password": PASSWORD}
res = requests.post(url=url, json=register_data)
@@ -50,7 +50,7 @@ def register():
def login():
url = HOST_ADDRESS + "/v1/user/login"
url = HOST_ADDRESS + "/api/v1/auth/login"
login_data = {"email": EMAIL, "password": PASSWORD}
response = requests.post(url=url, json=login_data)
res = response.json()
@@ -119,7 +119,7 @@ def add_models(auth):
def get_tenant_info(auth):
url = HOST_ADDRESS + "/v1/user/tenant_info"
url = HOST_ADDRESS + "/api/v1/users/me/models"
authorization = {"Authorization": auth}
response = requests.get(url=url, headers=authorization)
res = response.json()
@@ -136,7 +136,7 @@ def set_tenant_info(get_auth):
tenant_id = get_tenant_info(auth)
except Exception as e:
pytest.exit(f"Error in set_tenant_info: {str(e)}")
url = HOST_ADDRESS + "/v1/user/set_tenant_info"
url = HOST_ADDRESS + "/api/v1/users/me/models"
authorization = {"Authorization": get_auth}
tenant_info = {
"tenant_id": tenant_id,
@@ -146,7 +146,7 @@ def set_tenant_info(get_auth):
"asr_id": "",
"tts_id": None,
}
response = requests.post(url=url, headers=authorization, json=tenant_info)
response = requests.patch(url=url, headers=authorization, json=tenant_info)
res = response.json()
if res.get("code") != 0:
raise Exception(res.get("message"))

View File

@@ -55,7 +55,7 @@ Auth and bootstrap flags (used when --api-key is not provided)
--login-password
Login password (encrypted client-side). Requires pycryptodomex in the test group.
--allow-register
Attempt /user/register before login (best effort).
Attempt /users before login (best effort).
--token-name
Optional API token name for /system/new_token.
--bootstrap-llm
@@ -70,7 +70,7 @@ Auth and bootstrap flags (used when --api-key is not provided)
Optional LLM API base URL.
Env: RAGFLOW_LLM_API_BASE
--set-tenant-info
Set tenant defaults via /user/set_tenant_info.
Set tenant defaults via /users/me/models.
--tenant-llm-id
Tenant chat model ID.
Env: RAGFLOW_TENANT_LLM_ID

View File

@@ -18,7 +18,7 @@ def encrypt_password(password_plain: str) -> str:
def register_user(client: HttpClient, email: str, nickname: str, password_enc: str) -> None:
payload = {"email": email, "nickname": nickname, "password": password_enc}
res = client.request_json("POST", "/user/register", use_api_base=False, auth_kind=None, json_body=payload)
res = client.request_json("POST", "/users", use_api_base=True, auth_kind=None, json_body=payload)
if res.get("code") == 0:
return
msg = res.get("message", "")
@@ -29,7 +29,7 @@ def register_user(client: HttpClient, email: str, nickname: str, password_enc: s
def login_user(client: HttpClient, email: str, password_enc: str) -> str:
payload = {"email": email, "password": password_enc}
response = client.request("POST", "/user/login", use_api_base=False, auth_kind=None, json_body=payload)
response = client.request("POST", "/auth/login", use_api_base=True, auth_kind=None, json_body=payload)
try:
res = response.json()
except Exception as exc:
@@ -76,13 +76,13 @@ def set_llm_api_key(
def get_tenant_info(client: HttpClient) -> Dict[str, Any]:
res = client.request_json("GET", "/user/tenant_info", use_api_base=False, auth_kind="login")
res = client.request_json("GET", "/users/me/models", use_api_base=True, auth_kind="login")
if res.get("code") != 0:
raise AuthError(f"Failed to get tenant info: {res.get('message')}")
return res.get("data", {})
def set_tenant_info(client: HttpClient, payload: Dict[str, Any]) -> None:
res = client.request_json("POST", "/user/set_tenant_info", use_api_base=False, auth_kind="login", json_body=payload)
res = client.request_json("PATCH", "/users/me/models", use_api_base=True, auth_kind="login", json_body=payload)
if res.get("code") != 0:
raise AuthError(f"Failed to set tenant info: {res.get('message')}")

View File

@@ -59,7 +59,7 @@ def _parse_args() -> argparse.Namespace:
base_parser.add_argument("--login-email", default=os.getenv("RAGFLOW_EMAIL"), help="Login email")
base_parser.add_argument("--login-nickname", default=os.getenv("RAGFLOW_NICKNAME"), help="Nickname for registration")
base_parser.add_argument("--login-password", help="Login password (encrypted client-side)")
base_parser.add_argument("--allow-register", action="store_true", help="Attempt /user/register before login")
base_parser.add_argument("--allow-register", action="store_true", help="Attempt /users before login")
base_parser.add_argument("--token-name", help="Optional API token name")
base_parser.add_argument("--bootstrap-llm", action="store_true", help="Ensure LLM factory API key is configured")
base_parser.add_argument("--llm-factory", default=os.getenv("RAGFLOW_LLM_FACTORY"), help="LLM factory name")

View File

@@ -167,7 +167,7 @@ def step_03_submit_registration(
snap("retry_submitted" if retried else "submitted"),
),
lambda resp: resp.request.method == "POST"
and "/v1/user/register" in resp.url,
and "/api/v1/users" in resp.url,
timeout_ms=RESULT_TIMEOUT_MS,
)
except PlaywrightTimeoutError as exc:

View File

@@ -172,7 +172,7 @@ def step_03_register_user(
snap("register_submitted"),
),
lambda resp: resp.request.method == "POST"
and "/v1/user/register" in resp.url,
and "/api/v1/users" in resp.url,
timeout_ms=RESULT_TIMEOUT_MS,
)
except PlaywrightTimeoutError as exc:

View File

@@ -30,7 +30,7 @@ def step_02_initiate_sso(flow_page, flow_state, login_url, active_auth_context,
if not clicked:
pytest.skip("SSO buttons were present but not interactable")
page.wait_for_url(re.compile(r".*/v1/user/login/"), timeout=5000)
page.wait_for_url(re.compile(r".*/api/v1/auth/login/"), timeout=5000)
flow_state["sso_clicked"] = True
snap("sso_clicked")

View File

@@ -429,7 +429,7 @@ def _is_register_disabled_message(message: str) -> bool:
def _api_register_user(base_url: str, email: str, password: str, nickname: str) -> None:
url = _build_url(base_url, "/v1/user/register")
url = _build_url(base_url, "/api/v1/users")
encrypted_password = _rsa_encrypt_password(password)
status, payload = _api_post_json(
url,
@@ -446,7 +446,7 @@ def _api_register_user(base_url: str, email: str, password: str, nickname: str)
def _api_login_user(base_url: str, email: str, password: str) -> None:
url = _build_url(base_url, "/v1/user/login")
url = _build_url(base_url, "/api/v1/auth/login")
encrypted_password = _rsa_encrypt_password(password)
status, payload = _api_post_json(
url,
@@ -1047,7 +1047,7 @@ def _ensure_model_provider_ready_via_api(base_url: str, auth_header: str) -> dic
pytest.skip("No model provider configured and ZHIPU_AI_API_KEY is not set.")
_, tenant_payload = _api_request_json(
_build_url(base_url, "/v1/user/tenant_info"), headers=headers
_build_url(base_url, "/api/v1/users/me/models"), headers=headers
)
tenant_data = _response_data(tenant_payload)
tenant_id = tenant_data.get("tenant_id")
@@ -1123,8 +1123,8 @@ def _ensure_model_provider_ready_via_api(base_url: str, auth_header: str) -> dic
"tts_id": target_tts,
}
_, set_tenant_payload = _api_request_json(
_build_url(base_url, "/v1/user/set_tenant_info"),
method="POST",
_build_url(base_url, "/api/v1/users/me/models"),
method="PATCH",
payload=tenant_payload,
headers=headers,
)

View File

@@ -306,8 +306,8 @@ def select_default_model(
capture_response(
page,
trigger,
lambda resp: resp.request.method == "POST"
and "/v1/user/set_tenant_info" in resp.url,
lambda resp: resp.request.method == "PATCH"
and "/api/v1/users/me/models" in resp.url,
)
except PlaywrightTimeoutError:
if not selected[0]:

View File

@@ -128,7 +128,7 @@ def pytest_configure(config: pytest.Config) -> None:
def register():
url = HOST_ADDRESS + f"/{VERSION}/user/register"
url = HOST_ADDRESS + f"/api/{VERSION}/users"
name = "qa"
register_data = {"email": EMAIL, "nickname": name, "password": PASSWORD}
res = requests.post(url=url, json=register_data)
@@ -138,7 +138,7 @@ def register():
def login():
url = HOST_ADDRESS + f"/{VERSION}/user/login"
url = HOST_ADDRESS + f"/api/{VERSION}/auth/login"
login_data = {"email": EMAIL, "password": PASSWORD}
response = requests.post(url=url, json=login_data)
res = response.json()
@@ -198,7 +198,7 @@ def add_models(auth):
def get_tenant_info(auth):
url = HOST_ADDRESS + f"/{VERSION}/user/tenant_info"
url = HOST_ADDRESS + f"/api/{VERSION}/users/me/models"
authorization = {"Authorization": auth}
response = requests.get(url=url, headers=authorization)
res = response.json()
@@ -215,7 +215,7 @@ def set_tenant_info(auth):
tenant_id = get_tenant_info(auth)
except Exception as e:
pytest.exit(f"Error in set_tenant_info: {str(e)}")
url = HOST_ADDRESS + f"/{VERSION}/user/set_tenant_info"
url = HOST_ADDRESS + f"/api/{VERSION}/users/me/models"
authorization = {"Authorization": auth}
tenant_info = {
"tenant_id": tenant_id,
@@ -225,7 +225,7 @@ def set_tenant_info(auth):
"asr_id": "",
"tts_id": None,
}
response = requests.post(url=url, headers=authorization, json=tenant_info)
response = requests.patch(url=url, headers=authorization, json=tenant_info)
res = response.json()
if res.get("code") != 0:
raise Exception(res.get("message"))

View File

@@ -151,7 +151,7 @@ class TestDeleteUserApiKey:
user_name: str = EMAIL
# create second user
url: str = HOST_ADDRESS + f"/{VERSION}/user/register"
url: str = HOST_ADDRESS + f"/api/{VERSION}/users"
user2_email: str = "qa2@ragflow.io"
register_data: dict[str, str] = {"email": user2_email, "nickname": "qa2", "password": PASSWORD}
res: Any = requests.post(url=url, json=register_data)

View File

@@ -450,7 +450,7 @@ def _load_user_app(monkeypatch):
monkeypatch.setitem(sys.modules, "rag.utils.redis_conn", redis_mod)
module_name = "test_user_app_unit_module"
module_path = repo_root / "api" / "apps" / "user_app.py"
module_path = repo_root / "api" / "apps" / "restful_apis" / "user_api.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
module.manager = _DummyManager()
@@ -689,236 +689,6 @@ def test_oauth_callback_matrix_unit(monkeypatch):
assert login_calls and login_calls[-1] is existing_user
@pytest.mark.p2
def test_github_callback_matrix_unit(monkeypatch):
module = _load_user_app(monkeypatch)
_set_request_args(monkeypatch, module, {"code": "code"})
module.session.clear()
async def _request_error(_method, _url, **_kwargs):
return _DummyHTTPResponse({"error": "bad", "error_description": "boom"})
monkeypatch.setattr(module, "async_request", _request_error)
res = _run(module.github_callback())
assert res["redirect"] == "/?error=boom"
async def _request_scope_missing(_method, _url, **_kwargs):
return _DummyHTTPResponse({"scope": "repo", "access_token": "token-gh"})
monkeypatch.setattr(module, "async_request", _request_scope_missing)
res = _run(module.github_callback())
assert res["redirect"] == "/?error=user:email not in scope"
async def _request_token(_method, _url, **_kwargs):
return _DummyHTTPResponse({"scope": "user:email,repo", "access_token": "token-gh"})
monkeypatch.setattr(module, "async_request", _request_token)
monkeypatch.setattr(
module,
"user_info_from_github",
lambda _token: _AwaitableValue({"email": "gh@example.com", "avatar_url": "http://img", "login": "gh-user"}),
)
monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [])
rollback_calls = []
monkeypatch.setattr(module, "rollback_user_registration", lambda user_id: rollback_calls.append(user_id))
monkeypatch.setattr(module, "get_uuid", lambda: "gh-user-id")
def _raise_download(_url):
raise RuntimeError("download explode")
monkeypatch.setattr(module, "download_img", _raise_download)
monkeypatch.setattr(module, "user_register", lambda _user_id, _user: None)
res = _run(module.github_callback())
assert "Fail to register gh@example.com." in res["redirect"]
assert rollback_calls == ["gh-user-id"]
monkeypatch.setattr(module, "download_img", lambda _url: "avatar")
monkeypatch.setattr(
module,
"user_register",
lambda _user_id, _user: [_DummyUser("dup-1", "gh@example.com"), _DummyUser("dup-2", "gh@example.com")],
)
rollback_calls.clear()
res = _run(module.github_callback())
assert "Same email: gh@example.com exists!" in res["redirect"]
assert rollback_calls == ["gh-user-id"]
new_user = _DummyUser("gh-new-user", "gh@example.com")
login_calls = []
monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user))
monkeypatch.setattr(module, "user_register", lambda _user_id, _user: [new_user])
res = _run(module.github_callback())
assert res["redirect"] == "/?auth=gh-new-user"
assert login_calls and login_calls[-1] is new_user
inactive_user = _DummyUser("gh-existing", "gh@example.com", is_active="0")
monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [inactive_user])
res = _run(module.github_callback())
assert res["redirect"] == "/?error=user_inactive"
existing_user = _DummyUser("gh-existing", "gh@example.com")
login_calls.clear()
monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [existing_user])
monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user))
monkeypatch.setattr(module, "get_uuid", lambda: "gh-existing-token")
res = _run(module.github_callback())
assert res["redirect"] == "/?auth=gh-existing"
assert existing_user.access_token == "gh-existing-token"
assert existing_user.save_calls == 1
assert login_calls and login_calls[-1] is existing_user
@pytest.mark.p2
def test_feishu_callback_matrix_unit(monkeypatch):
module = _load_user_app(monkeypatch)
_set_request_args(monkeypatch, module, {"code": "code"})
module.session.clear()
def _patch_async_queue(payloads):
queue = list(payloads)
async def _request(_method, _url, **_kwargs):
return _DummyHTTPResponse(queue.pop(0))
monkeypatch.setattr(module, "async_request", _request)
_patch_async_queue([{"code": 1}])
res = _run(module.feishu_callback())
assert "/?error=" in res["redirect"]
_patch_async_queue(
[
{"code": 0, "app_access_token": "app-token"},
{"code": 1, "message": "bad token"},
]
)
res = _run(module.feishu_callback())
assert res["redirect"] == "/?error=bad token"
_patch_async_queue(
[
{"code": 0, "app_access_token": "app-token"},
{"code": 0, "data": {"scope": "other", "access_token": "feishu-access"}},
]
)
res = _run(module.feishu_callback())
assert "contact:user.email:readonly not in scope" in res["redirect"]
_patch_async_queue(
[
{"code": 0, "app_access_token": "app-token"},
{"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}},
]
)
monkeypatch.setattr(
module,
"user_info_from_feishu",
lambda _token: _AwaitableValue({"email": "fs@example.com", "avatar_url": "http://img", "en_name": "fs-user"}),
)
monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [])
rollback_calls = []
monkeypatch.setattr(module, "rollback_user_registration", lambda user_id: rollback_calls.append(user_id))
monkeypatch.setattr(module, "get_uuid", lambda: "fs-user-id")
def _raise_download(_url):
raise RuntimeError("download explode")
monkeypatch.setattr(module, "download_img", _raise_download)
monkeypatch.setattr(module, "user_register", lambda _user_id, _user: None)
res = _run(module.feishu_callback())
assert "Fail to register fs@example.com." in res["redirect"]
assert rollback_calls == ["fs-user-id"]
_patch_async_queue(
[
{"code": 0, "app_access_token": "app-token"},
{"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}},
]
)
monkeypatch.setattr(module, "download_img", lambda _url: "avatar")
monkeypatch.setattr(
module,
"user_register",
lambda _user_id, _user: [_DummyUser("dup-1", "fs@example.com"), _DummyUser("dup-2", "fs@example.com")],
)
rollback_calls.clear()
res = _run(module.feishu_callback())
assert "Same email: fs@example.com exists!" in res["redirect"]
assert rollback_calls == ["fs-user-id"]
_patch_async_queue(
[
{"code": 0, "app_access_token": "app-token"},
{"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}},
]
)
new_user = _DummyUser("fs-new-user", "fs@example.com")
login_calls = []
monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user))
monkeypatch.setattr(module, "user_register", lambda _user_id, _user: [new_user])
res = _run(module.feishu_callback())
assert res["redirect"] == "/?auth=fs-new-user"
assert login_calls and login_calls[-1] is new_user
_patch_async_queue(
[
{"code": 0, "app_access_token": "app-token"},
{"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}},
]
)
inactive_user = _DummyUser("fs-existing", "fs@example.com", is_active="0")
monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [inactive_user])
res = _run(module.feishu_callback())
assert res["redirect"] == "/?error=user_inactive"
_patch_async_queue(
[
{"code": 0, "app_access_token": "app-token"},
{"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}},
]
)
existing_user = _DummyUser("fs-existing", "fs@example.com")
login_calls.clear()
monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [existing_user])
monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user))
monkeypatch.setattr(module, "get_uuid", lambda: "fs-existing-token")
res = _run(module.feishu_callback())
assert res["redirect"] == "/?auth=fs-existing"
assert existing_user.access_token == "fs-existing-token"
assert existing_user.save_calls == 1
assert login_calls and login_calls[-1] is existing_user
@pytest.mark.p2
def test_oauth_user_info_helpers_unit(monkeypatch):
module = _load_user_app(monkeypatch)
async def _request_feishu(_method, _url, **_kwargs):
return _DummyHTTPResponse({"data": {"email": "", "en_name": "Feishu User"}})
monkeypatch.setattr(module, "async_request", _request_feishu)
feishu_user = _run(module.user_info_from_feishu("token-feishu"))
assert feishu_user["email"] is None
assert feishu_user["en_name"] == "Feishu User"
async def _request_github(_method, url, **_kwargs):
if "emails" in url:
return _DummyHTTPResponse(
[
{"email": "secondary@example.com", "primary": False},
{"email": "primary@example.com", "primary": True},
]
)
return _DummyHTTPResponse({"login": "gh-user"})
monkeypatch.setattr(module, "async_request", _request_github)
github_user = _run(module.user_info_from_github("token-github"))
assert github_user["login"] == "gh-user"
assert github_user["email"] == "primary@example.com"
@pytest.mark.p2
def test_logout_setting_profile_matrix_unit(monkeypatch):
module = _load_user_app(monkeypatch)

View File

@@ -33,7 +33,7 @@ const methods = {
},
logout: {
url: logout,
method: 'get',
method: 'post',
},
register: {
url: register,
@@ -41,7 +41,7 @@ const methods = {
},
setting: {
url: setting,
method: 'post',
method: 'patch',
},
userInfo: {
url: userInfo,
@@ -53,7 +53,7 @@ const methods = {
},
setTenantInfo: {
url: setTenantInfo,
method: 'post',
method: 'patch',
},
factoriesList: {
url: factoriesList,

View File

@@ -5,15 +5,15 @@ export { restAPIv1, webAPI };
export default {
// user
login: `${webAPI}/user/login`,
logout: `${webAPI}/user/logout`,
register: `${webAPI}/user/register`,
setting: `${webAPI}/user/setting`,
userInfo: `${webAPI}/user/info`,
tenantInfo: `${webAPI}/user/tenant_info`,
setTenantInfo: `${webAPI}/user/set_tenant_info`,
loginChannels: `${webAPI}/user/login/channels`,
loginChannel: (channel: string) => `${webAPI}/user/login/${channel}`,
login: `${restAPIv1}/auth/login`,
logout: `${restAPIv1}/auth/logout`,
register: `${restAPIv1}/users`,
setting: `${restAPIv1}/users/me`,
userInfo: `${restAPIv1}/users/me`,
tenantInfo: `${restAPIv1}/users/me/models`,
setTenantInfo: `${restAPIv1}/users/me/models`,
loginChannels: `${restAPIv1}/auth/login/channels`,
loginChannel: (channel: string) => `${restAPIv1}/auth/login/${channel}`,
// team
addTenantUser: (tenantId: string) => `${restAPIv1}/tenants/${tenantId}/users`,

View File

@@ -78,7 +78,7 @@ const modelParamMap: ModelParamMap = {
// API endpoint whitelist - only these endpoints will have tenant parameters added
const API_WHITELIST = [
'/v1/user/set_tenant_info',
'/api/v1/users/me/models',
'/api/v1/chats',
'/v1/canvas/set',
'/v1/canvas/setting',