mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
671 lines
23 KiB
Python
671 lines
23 KiB
Python
"""Member endpoints under /openapi/v1/workspaces/<id>/...
|
|
|
|
Coverage:
|
|
- Payload validation lands at 422 on the wire (unified via @accepts) — one
|
|
table for every `@accepts` route with a body or query model, the human-input
|
|
submit route included, since all of them share `admitted_bearer`
|
|
- Domain exception → HTTP code mapping is preserved with the service's
|
|
original message (so CLI users see what the console user sees)
|
|
- Response shape matches the Pydantic models
|
|
- Invitations persist through the application service and its repositories
|
|
|
|
Auth is not exercised here: `@endpoint` resolves the `Context` before the
|
|
handler runs, and the allow/deny answers live in `test_auth_matrix.py`. Body
|
|
tests call `__handler__` — the one seam — with a real `Context` over the test
|
|
database. The 422 tests cannot use it, because `@accepts` sits inside the guard
|
|
and `__handler__` is below it; they go over the wire through `admitted_bearer`
|
|
instead, and assert the canonical `ErrorBody` a client actually receives.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import builtins
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from http import HTTPMethod
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from flask.views import MethodView
|
|
from pydantic import BaseModel, ValidationError
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
from werkzeug.exceptions import BadRequest, NotFound
|
|
from werkzeug.test import TestResponse
|
|
|
|
from constants.oauth_bearer import TokenType
|
|
from controllers.openapi import bp as openapi_bp
|
|
from controllers.openapi import workspaces as workspaces_module
|
|
from controllers.openapi._errors import (
|
|
ErrorBody,
|
|
OpenApiErrorCode,
|
|
)
|
|
from controllers.openapi._models import MemberInvitePayload, MemberListQuery, MemberRoleUpdatePayload
|
|
from controllers.openapi.auth.context import Context
|
|
from controllers.openapi.auth.loaders import load_caller, load_workspace
|
|
from controllers.openapi.auth.subjects import AccountSubject
|
|
from controllers.openapi.workspaces import WorkspaceMemberApi, WorkspaceMembersApi
|
|
from libs.oauth_bearer import AuthContext
|
|
from models import Account, Tenant, TenantAccountJoin
|
|
from models.account import TenantAccountRole
|
|
from services.account_errors import AccountNotFoundError, AccountRegisterError
|
|
from services.errors.base import NoPermissionError
|
|
from services.errors.workspace import (
|
|
AccountAlreadyInTenantError,
|
|
CannotOperateSelfError,
|
|
InvalidWorkspaceMemberRoleError,
|
|
MemberNotInTenantError,
|
|
RoleAlreadyAssignedError,
|
|
WorkspaceInvitationQuotaError,
|
|
WorkspaceMemberLicenseQuotaError,
|
|
)
|
|
from tests.helpers.unit.account_domain import AccountDomain
|
|
from tests.helpers.unit.model_factories import make_account, make_tenant
|
|
from tests.unit_tests.controllers.openapi.conftest import AdmittedWorld
|
|
|
|
if not hasattr(builtins, "MethodView"):
|
|
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def openapi_app() -> Flask:
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(openapi_bp)
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def database_session(sqlite_session: Session):
|
|
return sqlite_session
|
|
|
|
|
|
def _auth_ctx(account_id: uuid.UUID | None = None) -> AuthContext:
|
|
return AuthContext(
|
|
subject_email="caller@example.com",
|
|
subject_issuer="dify:account",
|
|
account_id=account_id or uuid.uuid4(),
|
|
client_id="difyctl",
|
|
token_id=uuid.uuid4(),
|
|
token_type=TokenType.OAUTH_ACCOUNT,
|
|
expires_at=datetime.now(UTC),
|
|
)
|
|
|
|
|
|
def _context(session: Session, account_id: uuid.UUID, workspace_id: str) -> Context:
|
|
"""The context a handler is given *after* the pipeline ran: the caller's
|
|
subject, the request's session and its path params, plus the workspace and
|
|
caller these routes' requirements load off the same rows.
|
|
"""
|
|
ctx = Context(AccountSubject(_auth_ctx(account_id)), session, {"workspace_id": workspace_id})
|
|
load_workspace(ctx)
|
|
load_caller(ctx)
|
|
return ctx
|
|
|
|
|
|
def _account(account_id: str = "acct-1", email: str = "u@example.com") -> Account:
|
|
return make_account(account_id=account_id, name="User", email=email)
|
|
|
|
|
|
def _tenant(tenant_id: str = "ws-1") -> Tenant:
|
|
tenant = make_tenant(tenant_id=tenant_id, name="WS")
|
|
tenant.created_at = datetime(2026, 5, 18)
|
|
return tenant
|
|
|
|
|
|
def _persist_workspace(
|
|
session: Session,
|
|
workspace_id: str,
|
|
memberships: list[tuple[str, str, TenantAccountRole, bool]],
|
|
) -> tuple[Tenant, list[Account]]:
|
|
tenant = _tenant(workspace_id)
|
|
accounts: list[Account] = []
|
|
session.add(tenant)
|
|
for account_id, email, role, current in memberships:
|
|
account = _account(account_id=account_id, email=email)
|
|
membership = TenantAccountJoin(
|
|
tenant_id=tenant.id,
|
|
account_id=account.id,
|
|
current=current,
|
|
role=role,
|
|
)
|
|
accounts.append(account)
|
|
session.add_all([account, membership])
|
|
session.commit()
|
|
return tenant, accounts
|
|
|
|
|
|
def _persist_caller(session: Session, account_id: uuid.UUID) -> None:
|
|
"""These routes all declare `CheckWorkspaceMember`, which resolves the
|
|
caller, so a handler reached through `__handler__` needs the caller's row
|
|
even when its own body only reads the workspace.
|
|
"""
|
|
session.add(_account(account_id=str(account_id), email="caller@example.com"))
|
|
session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Payload validation lands at 422 on the wire (unified via @accepts)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _assert_validation_422(resp: TestResponse) -> None:
|
|
"""The wire contract for a rejected payload: 422 carrying the canonical body."""
|
|
assert resp.status_code == 422, resp.get_json()
|
|
wire = resp.get_json()
|
|
ErrorBody.model_validate(wire)
|
|
assert wire["code"] == OpenApiErrorCode.INVALID_PARAM
|
|
assert wire["details"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "payload"),
|
|
[
|
|
(MemberInvitePayload, {"email": "not-an-email", "role": TenantAccountRole.NORMAL}),
|
|
(MemberInvitePayload, {"email": "u@example.com", "role": TenantAccountRole.NORMAL, "extra": "x"}),
|
|
(MemberRoleUpdatePayload, {"role": TenantAccountRole.NORMAL, "extra": "x"}),
|
|
],
|
|
ids=["invite.bad_email", "invite.extra_field", "role.extra_field"],
|
|
)
|
|
def test_payload_models_reject(model: type[BaseModel], payload: dict[str, object]):
|
|
with pytest.raises(ValidationError):
|
|
model.model_validate(payload)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "path", "body"),
|
|
[
|
|
(
|
|
HTTPMethod.POST,
|
|
"/openapi/v1/workspaces/{workspace_id}/members",
|
|
{"email": "u@example.com", "role": TenantAccountRole.OWNER},
|
|
),
|
|
(
|
|
HTTPMethod.PATCH,
|
|
"/openapi/v1/workspaces/{workspace_id}/members/{member_id}",
|
|
{"role": TenantAccountRole.OWNER},
|
|
),
|
|
(HTTPMethod.GET, "/openapi/v1/workspaces/{workspace_id}/members?pg=2", None),
|
|
(
|
|
HTTPMethod.POST,
|
|
"/openapi/v1/apps/{app_id}/human-input-forms/tok-1:submit",
|
|
{"inputs": {"field1": "val"}},
|
|
),
|
|
],
|
|
ids=["invite.owner", "update_role.owner", "members_list.unknown_query", "human_input_form.missing_action"],
|
|
)
|
|
def test_invalid_request_is_422_on_the_wire(
|
|
admitted_bearer: AdmittedWorld, method: HTTPMethod, path: str, body: dict[str, object] | None
|
|
):
|
|
"""Owner is not assignable, query models are `extra='forbid'`, and the form
|
|
submit needs an action: each surfaces as 422 from `@accepts` inside the guard.
|
|
"""
|
|
resp = admitted_bearer.client.open(
|
|
path.format(
|
|
workspace_id=admitted_bearer.workspace_id,
|
|
member_id=admitted_bearer.member_id,
|
|
app_id=admitted_bearer.app_id,
|
|
),
|
|
method=method,
|
|
json=body,
|
|
headers=admitted_bearer.headers,
|
|
)
|
|
|
|
_assert_validation_422(resp)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Members list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_members_list_returns_normalized_rows(database_session: Session):
|
|
ws_id = str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
member_id = str(uuid.uuid4())
|
|
api = WorkspaceMembersApi()
|
|
|
|
_, members = _persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[(member_id, "mia@example.com", TenantAccountRole.ADMIN, False)],
|
|
)
|
|
members[0].name = "Mia"
|
|
_persist_caller(database_session, acct_id)
|
|
|
|
result = api.get.__handler__(
|
|
api,
|
|
_context(database_session, acct_id, ws_id),
|
|
workspace_id=ws_id,
|
|
query=MemberListQuery(),
|
|
)
|
|
|
|
assert result.page == 1
|
|
assert result.limit == 20
|
|
assert result.total == 1
|
|
assert result.has_more is False
|
|
assert result.data[0].email == "mia@example.com"
|
|
assert result.data[0].role == "admin"
|
|
assert result.data[0].status == "active"
|
|
|
|
|
|
def test_members_list_paginates_with_query_params(database_session: Session):
|
|
"""`page=2&limit=2` slices service output and reports total/has_more."""
|
|
ws_id = str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMembersApi()
|
|
|
|
member_ids = [str(uuid.uuid4()) for _ in range(5)]
|
|
memberships = [(member_ids[i], f"u{i}@example.com", TenantAccountRole.NORMAL, False) for i in range(5)]
|
|
_persist_workspace(database_session, ws_id, memberships)
|
|
_persist_caller(database_session, acct_id)
|
|
|
|
result = api.get.__handler__(
|
|
api,
|
|
_context(database_session, acct_id, ws_id),
|
|
workspace_id=ws_id,
|
|
query=MemberListQuery(page=2, limit=2),
|
|
)
|
|
|
|
assert result.page == 2
|
|
assert result.limit == 2
|
|
assert result.total == 5
|
|
assert result.has_more is True
|
|
assert [d.id for d in result.data] == member_ids[2:4]
|
|
|
|
|
|
def test_members_list_next_page_hint_reaches_the_wire(admitted_bearer: AdmittedWorld, database_session: Session):
|
|
for i in range(4):
|
|
member_id = str(uuid.uuid4())
|
|
database_session.add_all(
|
|
[
|
|
_account(account_id=member_id, email=f"member{i}@example.com"),
|
|
TenantAccountJoin(
|
|
tenant_id=admitted_bearer.workspace_id,
|
|
account_id=member_id,
|
|
current=False,
|
|
role=TenantAccountRole.NORMAL,
|
|
),
|
|
]
|
|
)
|
|
database_session.commit()
|
|
|
|
res = admitted_bearer.client.get(
|
|
f"/openapi/v1/workspaces/{admitted_bearer.workspace_id}/members?page=1&limit=2",
|
|
headers=admitted_bearer.headers,
|
|
)
|
|
|
|
assert res.status_code == 200
|
|
body = res.get_json()
|
|
assert body["has_more"] is True
|
|
assert body["hints"] == [
|
|
{
|
|
"summary": "Next page",
|
|
"op": "get.workspace.member",
|
|
"input": {"workspace_id": admitted_bearer.workspace_id, "page": 2, "limit": 2},
|
|
"form": None,
|
|
}
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invite endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _invite_body(email: str = "new@example.com") -> MemberInvitePayload:
|
|
return MemberInvitePayload(email=email, role="normal")
|
|
|
|
|
|
def test_invite_happy_path_returns_invite_url_and_member_id(
|
|
database_session: Session, account_domain: AccountDomain
|
|
) -> None:
|
|
ws_id = str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMembersApi()
|
|
|
|
_persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True)],
|
|
)
|
|
|
|
result = api.post.__handler__(
|
|
api,
|
|
_context(database_session, acct_id, ws_id),
|
|
workspace_id=ws_id,
|
|
body=_invite_body("NEW@example.com"),
|
|
)
|
|
|
|
assert result.result == "success"
|
|
assert result.email == "new@example.com"
|
|
assert result.role == "normal"
|
|
invited = account_domain.repository.get(result.member_id)
|
|
assert invited is not None
|
|
assert invited.email == result.email
|
|
assert "token=invitation-token" in result.invite_url
|
|
assert "email=new%40example.com" in result.invite_url
|
|
assert result.tenant_id == ws_id
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "code"),
|
|
[
|
|
(WorkspaceInvitationQuotaError(), OpenApiErrorCode.MEMBER_LIMIT_EXCEEDED),
|
|
(WorkspaceMemberLicenseQuotaError(), OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED),
|
|
],
|
|
)
|
|
def test_invitation_quota_admission_preserves_error_contract(
|
|
admitted_bearer: AdmittedWorld,
|
|
account_domain: AccountDomain,
|
|
error: WorkspaceInvitationQuotaError,
|
|
code: OpenApiErrorCode,
|
|
) -> None:
|
|
account_domain.delivery.check_invitation_quota.side_effect = error
|
|
|
|
response = admitted_bearer.client.post(
|
|
f"/openapi/v1/workspaces/{admitted_bearer.workspace_id}/members",
|
|
headers=admitted_bearer.headers,
|
|
json={"email": "new@example.com", "role": "normal"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert ErrorBody.model_validate(response.get_json()).code == code
|
|
account_domain.delivery.check_invitation_quota.assert_called_once_with(admitted_bearer.workspace_id)
|
|
assert account_domain.repository.find_by_email("new@example.com") is None
|
|
account_domain.delivery.send.assert_not_called()
|
|
|
|
|
|
def test_invitation_permission_rejection_precedes_quota_lookup(
|
|
admitted_bearer: AdmittedWorld, account_domain: AccountDomain, sqlite_session: Session
|
|
) -> None:
|
|
membership = sqlite_session.scalar(
|
|
select(TenantAccountJoin).where(TenantAccountJoin.tenant_id == admitted_bearer.workspace_id)
|
|
)
|
|
assert membership is not None
|
|
membership.role = TenantAccountRole.NORMAL
|
|
sqlite_session.commit()
|
|
|
|
response = admitted_bearer.client.post(
|
|
f"/openapi/v1/workspaces/{admitted_bearer.workspace_id}/members",
|
|
headers=admitted_bearer.headers,
|
|
json={"email": "new@example.com", "role": "normal"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert ErrorBody.model_validate(response.get_json()).code == OpenApiErrorCode.FORBIDDEN
|
|
account_domain.delivery.check_invitation_quota.assert_not_called()
|
|
account_domain.delivery.send.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
AccountAlreadyInTenantError("already in tenant"),
|
|
AccountRegisterError("Workspace is not allowed to create."),
|
|
InvalidWorkspaceMemberRoleError("Dataset operators are not enabled."),
|
|
],
|
|
ids=["already_in_tenant", "register_error", "invalid_role"],
|
|
)
|
|
def test_invite_400_on_registration_refusal(monkeypatch: pytest.MonkeyPatch, database_session: Session, exc: Exception):
|
|
ws_id = str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMembersApi()
|
|
|
|
_persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True)],
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
workspaces_module.application_services().workspaces.invitations, "invite", Mock(side_effect=exc)
|
|
)
|
|
|
|
with pytest.raises(BadRequest):
|
|
api.post.__handler__(
|
|
api,
|
|
_context(database_session, acct_id, ws_id),
|
|
workspace_id=ws_id,
|
|
body=_invite_body(),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Delete member
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("member_id_format", ["canonical", "uppercase", "hex"])
|
|
def test_delete_member_happy_path(
|
|
database_session: Session, account_domain: AccountDomain, member_id_format: str
|
|
) -> None:
|
|
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMemberApi()
|
|
|
|
_persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[
|
|
(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True),
|
|
(member_id, "member@example.com", TenantAccountRole.NORMAL, False),
|
|
],
|
|
)
|
|
|
|
path_member_id = _member_id_variant(member_id, member_id_format)
|
|
result = api.delete.__handler__(
|
|
api, _context(database_session, acct_id, ws_id), workspace_id=ws_id, member_id=path_member_id
|
|
)
|
|
|
|
assert result.result == "success"
|
|
assert account_domain.members.get_role(ws_id, member_id) is None
|
|
assert account_domain.repository.get(member_id) is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("exc", "expected"),
|
|
[
|
|
(CannotOperateSelfError("cannot operate self"), BadRequest),
|
|
(NoPermissionError("no permission"), BadRequest),
|
|
(MemberNotInTenantError("not in tenant"), NotFound),
|
|
(AccountNotFoundError(), NotFound),
|
|
],
|
|
)
|
|
def test_delete_member_exception_mapping(monkeypatch, exc, expected, database_session: Session):
|
|
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMemberApi()
|
|
|
|
_persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[
|
|
(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True),
|
|
(member_id, "member@example.com", TenantAccountRole.NORMAL, False),
|
|
],
|
|
)
|
|
|
|
monkeypatch.setattr(workspaces_module.application_services().workspaces.members, "remove", Mock(side_effect=exc))
|
|
|
|
with pytest.raises(expected):
|
|
api.delete.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id, member_id=member_id)
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["delete", "patch"])
|
|
def test_member_mutation_404_when_account_missing(database_session: Session, method: str) -> None:
|
|
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMemberApi()
|
|
|
|
_persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True)],
|
|
)
|
|
|
|
ctx = _context(database_session, acct_id, ws_id)
|
|
if method == "delete":
|
|
with pytest.raises(NotFound, match="member not found"):
|
|
api.delete.__handler__(api, ctx, workspace_id=ws_id, member_id=member_id)
|
|
else:
|
|
with pytest.raises(NotFound, match="member not found"):
|
|
api.patch.__handler__(
|
|
api,
|
|
ctx,
|
|
workspace_id=ws_id,
|
|
member_id=member_id,
|
|
body=MemberRoleUpdatePayload(role="admin"),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Update role
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("member_id_format", ["canonical", "uppercase", "hex"])
|
|
def test_update_role_happy_path(
|
|
database_session: Session, account_domain: AccountDomain, member_id_format: str
|
|
) -> None:
|
|
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMemberApi()
|
|
|
|
_persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[
|
|
(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True),
|
|
(member_id, "member@example.com", TenantAccountRole.NORMAL, False),
|
|
],
|
|
)
|
|
|
|
result = api.patch.__handler__(
|
|
api,
|
|
_context(database_session, acct_id, ws_id),
|
|
workspace_id=ws_id,
|
|
member_id=_member_id_variant(member_id, member_id_format),
|
|
body=MemberRoleUpdatePayload(role="admin"),
|
|
)
|
|
|
|
assert result.result == "success"
|
|
assert account_domain.members.get_role(ws_id, member_id) == TenantAccountRole.ADMIN
|
|
|
|
|
|
def _member_id_variant(member_id: str, variant: str) -> str:
|
|
if variant == "uppercase":
|
|
return member_id.upper()
|
|
if variant == "hex":
|
|
return uuid.UUID(member_id).hex
|
|
if variant == "uppercase_hex":
|
|
return uuid.UUID(member_id).hex.upper()
|
|
return member_id
|
|
|
|
|
|
@pytest.mark.parametrize("member_id_format", ["canonical", "uppercase", "hex", "uppercase_hex"])
|
|
@pytest.mark.parametrize(
|
|
("method", "caller_role"), [("DELETE", TenantAccountRole.OWNER), ("PATCH", TenantAccountRole.ADMIN)]
|
|
)
|
|
def test_member_id_aliases_cannot_bypass_self_operation_guard(
|
|
admitted_bearer: AdmittedWorld,
|
|
database_session: Session,
|
|
account_domain: AccountDomain,
|
|
member_id_format: str,
|
|
method: str,
|
|
caller_role: TenantAccountRole,
|
|
) -> None:
|
|
membership = database_session.scalar(
|
|
select(TenantAccountJoin).where(TenantAccountJoin.tenant_id == admitted_bearer.workspace_id)
|
|
)
|
|
assert membership is not None
|
|
account_id = membership.account_id
|
|
membership.role = caller_role
|
|
owner_id = account_id
|
|
if caller_role == TenantAccountRole.ADMIN:
|
|
owner_id = str(uuid.uuid4())
|
|
database_session.add_all(
|
|
[
|
|
_account(owner_id, "owner@example.com"),
|
|
TenantAccountJoin(
|
|
tenant_id=admitted_bearer.workspace_id, account_id=owner_id, role=TenantAccountRole.OWNER
|
|
),
|
|
]
|
|
)
|
|
database_session.commit()
|
|
path_member_id = _member_id_variant(account_id, member_id_format)
|
|
|
|
response = admitted_bearer.client.open(
|
|
f"/openapi/v1/workspaces/{admitted_bearer.workspace_id}/members/{path_member_id}",
|
|
method=method,
|
|
headers=admitted_bearer.headers,
|
|
json={"role": "normal"} if method == "PATCH" else None,
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
error = ErrorBody.model_validate(response.get_json())
|
|
assert error.code == OpenApiErrorCode.BAD_REQUEST
|
|
assert error.message == "Cannot operate self."
|
|
assert account_domain.members.get_role(admitted_bearer.workspace_id, account_id) == caller_role
|
|
assert account_domain.members.get_role(admitted_bearer.workspace_id, owner_id) == TenantAccountRole.OWNER
|
|
account_domain.access.member_removed.assert_not_called()
|
|
account_domain.access.change_role.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["DELETE", "PATCH"])
|
|
def test_malformed_member_id_returns_404(admitted_bearer: AdmittedWorld, method: str) -> None:
|
|
response = admitted_bearer.client.open(
|
|
f"/openapi/v1/workspaces/{admitted_bearer.workspace_id}/members/not-a-uuid",
|
|
method=method,
|
|
headers=admitted_bearer.headers,
|
|
json={"role": "admin"} if method == "PATCH" else None,
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert ErrorBody.model_validate(response.get_json()).message == "member not found"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("exc", "expected"),
|
|
[
|
|
(CannotOperateSelfError("cannot operate self"), BadRequest),
|
|
(NoPermissionError("no permission"), BadRequest),
|
|
(RoleAlreadyAssignedError("already"), BadRequest),
|
|
(InvalidWorkspaceMemberRoleError("Dataset operators are not enabled."), BadRequest),
|
|
(MemberNotInTenantError("not in tenant"), NotFound),
|
|
(AccountNotFoundError(), NotFound),
|
|
],
|
|
)
|
|
def test_update_role_exception_mapping(monkeypatch, exc, expected, database_session: Session):
|
|
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
|
|
acct_id = uuid.uuid4()
|
|
api = WorkspaceMemberApi()
|
|
|
|
_persist_workspace(
|
|
database_session,
|
|
ws_id,
|
|
[
|
|
(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True),
|
|
(member_id, "member@example.com", TenantAccountRole.NORMAL, False),
|
|
],
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
workspaces_module.application_services().workspaces.members, "update_role", Mock(side_effect=exc)
|
|
)
|
|
|
|
with pytest.raises(expected):
|
|
api.patch.__handler__(
|
|
api,
|
|
_context(database_session, acct_id, ws_id),
|
|
workspace_id=ws_id,
|
|
member_id=member_id,
|
|
body=MemberRoleUpdatePayload(role="admin"),
|
|
)
|