feat(backend): added auth, reviews, users modules

also provided tests
This commit is contained in:
ITQ
2026-02-12 20:48:29 +03:00
parent cb9692089f
commit 613c99dce2
60 changed files with 5101 additions and 127 deletions
View File
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api.v1.auth"
label = "api_v1_auth"
+100
View File
@@ -0,0 +1,100 @@
from http import HTTPStatus
from django.contrib.auth import authenticate
from django.http import HttpRequest
from ninja import Router
from ninja.errors import AuthenticationError
from api.v1.auth.schemas import (
LoginIn,
MeOut,
TokenPairOut,
TokenRefreshIn,
TokenRefreshOut,
)
from apps.users.auth.bearer import jwt_bearer
from apps.users.auth.jwt import (
TokenError,
create_access_token,
create_token_pair,
decode_refresh_token,
)
from apps.users.models import User
router = Router(tags=["auth"])
@router.post(
"/login",
response={HTTPStatus.OK: TokenPairOut},
summary="Obtain JWT token pair",
description=(
"Authenticate with username and password. "
"Returns an access token and a refresh token."
),
)
def login(
request: HttpRequest,
payload: LoginIn,
) -> tuple[int, TokenPairOut]:
user = authenticate(
request,
username=payload.username,
password=payload.password,
)
if user is None or not isinstance(user, User):
raise AuthenticationError
if not user.is_active:
raise AuthenticationError
tokens = create_token_pair(user.pk, user.role)
return HTTPStatus.OK, TokenPairOut(
access=tokens["access"],
refresh=tokens["refresh"],
)
@router.post(
"/refresh",
response={HTTPStatus.OK: TokenRefreshOut},
summary="Refresh access token",
description="Exchange a valid refresh token for a new access token.",
)
def refresh(
request: HttpRequest,
payload: TokenRefreshIn,
) -> tuple[int, TokenRefreshOut]:
try:
claims = decode_refresh_token(payload.refresh)
except TokenError:
raise AuthenticationError from TokenError
user_id: str | None = claims.get("sub")
if not user_id:
raise AuthenticationError
try:
user = User.objects.get(pk=user_id, is_active=True)
except User.DoesNotExist:
raise AuthenticationError from User.DoesNotExist
access = create_access_token(user.pk, user.role)
return HTTPStatus.OK, TokenRefreshOut(access=access)
@router.get(
"/me",
response={HTTPStatus.OK: MeOut},
auth=jwt_bearer,
summary="Current user profile",
description="Return the profile of the currently authenticated user.",
)
def me(request: HttpRequest) -> tuple[int, MeOut]:
user = getattr(request, "auth", None)
if not isinstance(user, User):
raise AuthenticationError
return HTTPStatus.OK, MeOut.model_validate(user)
+64
View File
@@ -0,0 +1,64 @@
from typing import ClassVar
from ninja import ModelSchema, Schema
from pydantic import Field
from apps.users.models import User
class LoginIn(Schema):
username: str = Field(
...,
min_length=1,
max_length=150,
description="Username of the account",
)
password: str = Field(
...,
min_length=1,
description="Account password",
)
class TokenPairOut(Schema):
access: str = Field(
...,
description=(
"Short-lived JWT access token (used in Authorization header)"
),
)
refresh: str = Field(
...,
description=(
"Long-lived JWT refresh token (used to obtain a new access token)"
),
)
class TokenRefreshIn(Schema):
refresh: str = Field(
...,
min_length=1,
description="A valid, non-expired refresh token",
)
class TokenRefreshOut(Schema):
access: str = Field(
...,
description="Newly issued JWT access token",
)
class MeOut(ModelSchema):
class Meta:
model = User
fields: ClassVar[tuple[str, ...]] = (
User.id.field.name,
User.username.field.name,
User.email.field.name,
User.role.field.name,
User.first_name.field.name,
User.last_name.field.name,
User.is_active.field.name,
)
+104
View File
@@ -0,0 +1,104 @@
import json
from django.test import Client, TestCase
from django.urls import reverse
from apps.users.auth.jwt import (
create_token_pair,
)
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user
class AuthAPITest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.user: User = _make_user(
username="api_auth",
email="api_auth@x.com",
password="testpass123",
role=UserRole.ADMIN,
)
def test_login_success(self) -> None:
resp = self.client.post(
reverse("api-1:login"),
data=json.dumps(
{"username": "api_auth", "password": "testpass123"}
),
content_type="application/json",
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("access", data)
self.assertIn("refresh", data)
def test_login_wrong_password(self) -> None:
resp = self.client.post(
reverse("api-1:login"),
data=json.dumps({"username": "api_auth", "password": "wrong"}),
content_type="application/json",
)
self.assertEqual(resp.status_code, 401)
def test_login_nonexistent_user(self) -> None:
resp = self.client.post(
reverse("api-1:login"),
data=json.dumps({"username": "ghost", "password": "nope"}),
content_type="application/json",
)
self.assertEqual(resp.status_code, 401)
def test_login_inactive_user(self) -> None:
self.user.is_active = False
self.user.save()
resp = self.client.post(
reverse("api-1:login"),
data=json.dumps(
{"username": "api_auth", "password": "testpass123"}
),
content_type="application/json",
)
self.assertEqual(resp.status_code, 401)
def test_refresh_success(self) -> None:
pair: dict[str, str] = create_token_pair(self.user.pk, self.user.role)
resp = self.client.post(
reverse("api-1:refresh"),
data=json.dumps({"refresh": pair["refresh"]}),
content_type="application/json",
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("access", data)
def test_refresh_invalid_token(self) -> None:
resp = self.client.post(
reverse("api-1:refresh"),
data=json.dumps({"refresh": "invalid.token.here"}),
content_type="application/json",
)
self.assertEqual(resp.status_code, 401)
def test_refresh_with_access_token_fails(self) -> None:
pair: dict[str, str] = create_token_pair(self.user.pk, self.user.role)
resp = self.client.post(
reverse("api-1:refresh"),
data=json.dumps({"refresh": pair["access"]}),
content_type="application/json",
)
self.assertEqual(resp.status_code, 401)
def test_me_authenticated(self) -> None:
resp = self.client.get(
reverse("api-1:me"),
HTTP_AUTHORIZATION=_auth_header(self.user),
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["username"], "api_auth")
self.assertEqual(data["role"], "admin")
def test_me_unauthenticated(self) -> None:
resp = self.client.get(reverse("api-1:me"))
self.assertEqual(resp.status_code, 401)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReviewsConfig(AppConfig):
name = "api.v1.reviews"
label = "api_v1_reviews"
+344
View File
@@ -0,0 +1,344 @@
from http import HTTPStatus
from django.core.exceptions import ValidationError
from django.http import Http404, HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Router
from api.v1.reviews.schemas import (
ApproverGroupAddApproverIn,
ApproverGroupCreateIn,
ApproverGroupListOut,
ApproverGroupOut,
ApproverGroupRemoveApproverIn,
ApproverGroupUpdateIn,
ApproverOut,
EffectiveReviewPolicyOut,
ReviewSettingsOut,
ReviewSettingsUpdateIn,
)
from apps.reviews.models import ApproverGroup
from apps.reviews.selectors import (
approver_group_get_by_experimenter_id,
approver_group_list,
get_effective_approvers_for_experimenter,
review_settings_load,
)
from apps.reviews.services import (
approver_group_add_approver,
approver_group_create,
approver_group_delete,
approver_group_remove_approver,
approver_group_update,
review_settings_update,
)
from apps.users.auth.bearer import jwt_bearer, require_admin
from apps.users.models import User, UserRole
router = Router(tags=["reviews"], auth=jwt_bearer)
@router.get(
"/approver-groups",
response={HTTPStatus.OK: ApproverGroupListOut},
summary="List approver groups",
description=(
"Return a paginated list of all approver groups. Admin only."
),
)
@require_admin
def list_approver_groups(
request: HttpRequest,
limit: int = 50,
offset: int = 0,
) -> tuple[int, ApproverGroupListOut]:
qs = approver_group_list()
total = qs.count()
items = [
ApproverGroupOut.model_validate(item)
for item in qs[offset : offset + limit]
]
return HTTPStatus.OK, ApproverGroupListOut(count=total, items=items)
@router.post(
"/approver-groups",
response={HTTPStatus.CREATED: ApproverGroupOut},
summary="Create approver group",
description=(
"Create a new approver group for an experimenter. "
"Each experimenter can have at most one group. Admin only."
),
)
@require_admin
def create_approver_group(
request: HttpRequest,
payload: ApproverGroupCreateIn,
) -> tuple[int, ApproverGroupOut]:
experimenter = get_object_or_404(User, pk=payload.experimenter_id)
group = approver_group_create(
experimenter=experimenter,
approver_ids=payload.approver_ids or None,
min_approvals=payload.min_approvals,
)
response_group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group.pk,
)
return HTTPStatus.CREATED, ApproverGroupOut.model_validate(response_group)
@router.get(
"/approver-groups/{group_id}",
response={HTTPStatus.OK: ApproverGroupOut},
summary="Get approver group",
description="Retrieve a single approver group by its UUID. Admin only.",
)
@require_admin
def get_approver_group(
request: HttpRequest,
group_id: str,
) -> tuple[int, ApproverGroupOut]:
group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group_id,
)
return HTTPStatus.OK, ApproverGroupOut.model_validate(group)
@router.patch(
"/approver-groups/{group_id}",
response={HTTPStatus.OK: ApproverGroupOut},
summary="Update approver group",
description=(
"Partially update an existing approver group. "
"Only non-null fields in the payload are applied. Admin only."
),
)
@require_admin
def update_approver_group(
request: HttpRequest,
group_id: str,
payload: ApproverGroupUpdateIn,
) -> tuple[int, ApproverGroupOut]:
group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group_id,
)
updated = approver_group_update(
group=group,
approver_ids=payload.approver_ids,
min_approvals=payload.min_approvals,
)
response_group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=updated.pk,
)
return HTTPStatus.OK, ApproverGroupOut.model_validate(response_group)
@router.delete(
"/approver-groups/{group_id}",
response={HTTPStatus.NO_CONTENT: None},
summary="Delete approver group",
description=(
"Delete an approver group. After deletion the experimenter "
"falls back to the global review-settings policy. Admin only."
),
)
@require_admin
def delete_approver_group(
request: HttpRequest,
group_id: str,
) -> tuple[int, None]:
group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group_id,
)
approver_group_delete(group=group)
return HTTPStatus.NO_CONTENT, None
@router.post(
"/approver-groups/{group_id}/approvers/add",
response={HTTPStatus.OK: ApproverGroupOut},
summary="Add approver to group",
description=(
"Add a single approver user to an existing approver group. "
"The user must have the 'approver' role. Admin only."
),
)
@require_admin
def add_approver_to_group(
request: HttpRequest,
group_id: str,
payload: ApproverGroupAddApproverIn,
) -> tuple[int, ApproverGroupOut]:
group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group_id,
)
approver = get_object_or_404(User, pk=payload.approver_id)
approver_group_add_approver(group=group, approver=approver)
response_group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group.pk,
)
return HTTPStatus.OK, ApproverGroupOut.model_validate(response_group)
@router.post(
"/approver-groups/{group_id}/approvers/remove",
response={HTTPStatus.OK: ApproverGroupOut},
summary="Remove approver from group",
description=(
"Remove a single approver user from an existing approver group. "
"Fails if removal would leave fewer approvers than the "
"minimum approval threshold. Admin only."
),
)
@require_admin
def remove_approver_from_group(
request: HttpRequest,
group_id: str,
payload: ApproverGroupRemoveApproverIn,
) -> tuple[int, ApproverGroupOut]:
group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group_id,
)
approver = get_object_or_404(User, pk=payload.approver_id)
approver_group_remove_approver(group=group, approver=approver)
response_group = get_object_or_404(
ApproverGroup.objects.select_related("experimenter").prefetch_related(
"approvers"
),
pk=group.pk,
)
return HTTPStatus.OK, ApproverGroupOut.model_validate(response_group)
@router.get(
"/approver-groups/by-experimenter/{experimenter_id}",
response={HTTPStatus.OK: ApproverGroupOut},
summary="Get approver group by experimenter",
description=(
"Retrieve the approver group assigned to a specific experimenter. "
"Returns 404 if no explicit group exists. "
"Available to any authenticated user."
),
)
@require_admin
def get_approver_group_by_experimenter(
request: HttpRequest,
experimenter_id: str,
) -> tuple[int, ApproverGroupOut]:
group = approver_group_get_by_experimenter_id(experimenter_id)
if group is None:
raise Http404
return HTTPStatus.OK, ApproverGroupOut.model_validate(group)
@router.get(
"/settings",
response={HTTPStatus.OK: ReviewSettingsOut},
summary="Get review settings",
description=(
"Retrieve the global fallback review settings. "
"Available to any authenticated user."
),
)
def get_review_settings(
request: HttpRequest,
) -> tuple[int, ReviewSettingsOut]:
settings = review_settings_load()
return HTTPStatus.OK, ReviewSettingsOut.model_validate(settings)
@router.put(
"/settings",
response={HTTPStatus.OK: ReviewSettingsOut},
summary="Update review settings",
description=(
"Update the global fallback review settings. "
"Only non-null fields in the payload are applied. Admin only."
),
)
@require_admin
def update_review_settings(
request: HttpRequest,
payload: ReviewSettingsUpdateIn,
) -> tuple[int, ReviewSettingsOut]:
settings = review_settings_update(
default_min_approvals=payload.default_min_approvals,
allow_any_approver=payload.allow_any_approver,
)
return HTTPStatus.OK, ReviewSettingsOut.model_validate(settings)
@router.get(
"/effective-policy/{experimenter_id}",
response={HTTPStatus.OK: EffectiveReviewPolicyOut},
summary="Get effective review policy for an experimenter",
description=(
"Resolve and return the effective review policy for a given "
"experimenter, taking into account both an explicit approver "
"group (if any) and the global fallback settings. "
"Available to any authenticated user."
),
)
def get_effective_policy(
request: HttpRequest,
experimenter_id: str,
) -> tuple[int, EffectiveReviewPolicyOut]:
experimenter = get_object_or_404(User, pk=experimenter_id)
if experimenter.role != UserRole.EXPERIMENTER:
raise ValidationError(
{
"experimenter_id": (
f"User '{experimenter.username}' does not have the "
f"'{UserRole.EXPERIMENTER}' role."
)
}
)
approvers_qs, min_approvals = get_effective_approvers_for_experimenter(
experimenter,
)
has_group = (
approver_group_get_by_experimenter_id(experimenter_id) is not None
)
return HTTPStatus.OK, EffectiveReviewPolicyOut(
experimenter_id=str(experimenter.pk),
min_approvals=min_approvals,
approvers=[
ApproverOut.model_validate(approver) for approver in approvers_qs
],
source="approver_group" if has_group else "global_fallback",
has_explicit_group=has_group,
)
+166
View File
@@ -0,0 +1,166 @@
from datetime import datetime
from typing import ClassVar
from ninja import ModelSchema, Schema
from pydantic import Field
from apps.reviews.models import ApproverGroup, ReviewSettings
from apps.users.models import User
class ApproverOut(ModelSchema):
first_name: str = Field("", alias="firstName")
last_name: str = Field("", alias="lastName")
class Meta:
model = User
fields: ClassVar[tuple[str, ...]] = (
User.id.field.name,
User.username.field.name,
User.email.field.name,
User.first_name.field.name,
User.last_name.field.name,
)
class ExperimenterOut(ModelSchema):
class Meta:
model = User
fields: ClassVar[tuple[str, ...]] = (
User.id.field.name,
User.username.field.name,
User.email.field.name,
)
class ApproverGroupOut(ModelSchema):
experimenter: ExperimenterOut
approvers: list[ApproverOut]
min_approvals: int
created_at: datetime
updated_at: datetime
class Meta:
model = ApproverGroup
fields: ClassVar[tuple[str, ...]] = (
ApproverGroup.id.field.name,
ApproverGroup.experimenter.field.name,
ApproverGroup.approvers.field.name,
ApproverGroup.min_approvals.field.name,
ApproverGroup.created_at.field.name,
ApproverGroup.updated_at.field.name,
)
class ApproverGroupCreateIn(Schema):
experimenter_id: str = Field(
...,
alias="experimenterId",
description="UUID of the experimenter user this group belongs to.",
)
approver_ids: list[str] = Field(
default_factory=list,
alias="approverIds",
description=(
"List of user UUIDs to add as approvers. "
"Each user must have the 'approver' role."
),
)
min_approvals: int = Field(
1,
alias="minApprovals",
ge=1,
description=(
"Number of distinct approvals required. "
"Must be >= 1 and <= number of approvers (if any are provided)."
),
)
class ApproverGroupUpdateIn(Schema):
approver_ids: list[str] | None = Field(
None,
alias="approverIds",
description=(
"If provided, replaces the current set of approvers. "
"Each user must have the 'approver' role."
),
)
min_approvals: int | None = Field(
None,
alias="minApprovals",
ge=1,
description="New minimum approval threshold.",
)
class ApproverGroupListOut(Schema):
count: int
items: list[ApproverGroupOut]
class ApproverGroupAddApproverIn(Schema):
approver_id: str = Field(
...,
alias="approverId",
description="UUID of the user to add as an approver.",
)
class ApproverGroupRemoveApproverIn(Schema):
approver_id: str = Field(
...,
alias="approverId",
description="UUID of the approver to remove.",
)
class ReviewSettingsOut(ModelSchema):
default_min_approvals: int
allow_any_approver: bool
updated_at: datetime
class Meta:
model = ReviewSettings
fields: ClassVar[tuple[str, ...]] = (
ReviewSettings.id.field.name,
ReviewSettings.default_min_approvals.field.name,
ReviewSettings.allow_any_approver.field.name,
ReviewSettings.updated_at.field.name,
)
class ReviewSettingsUpdateIn(Schema):
default_min_approvals: int | None = Field(
None,
alias="defaultMinApprovals",
ge=1,
description="New default minimum approval threshold.",
)
allow_any_approver: bool | None = Field(
None,
alias="allowAnyApprover",
description="New fallback policy for approver eligibility.",
)
class EffectiveReviewPolicyOut(Schema):
experimenter_id: str = Field(..., description="UUID of the experimenter.")
min_approvals: int = Field(
..., description="Effective number of approvals required."
)
approvers: list[ApproverOut] = Field(
...,
description="List of users who are eligible to approve.",
)
source: str = Field(
...,
description=(
"Where the policy comes from: "
"'approver_group' or 'global_fallback'."
),
)
has_explicit_group: bool = Field(
...,
description="Whether the experimenter has an explicit approver group.",
)
@@ -0,0 +1,787 @@
import json
import uuid
from django.test import Client, TestCase
from django.urls import reverse
from apps.reviews.models import ApproverGroup
from apps.reviews.services import approver_group_create, review_settings_update
from apps.reviews.tests._helpers import (
_get,
_make_admin,
_make_approver,
_make_experimenter,
_make_viewer,
)
from apps.users.models import User
from apps.users.tests._helpers import _auth_header
class ApproverGroupAPITest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_admin("_api")
self.viewer: User = _make_viewer("_api")
self.experimenter: User = _make_experimenter("_api")
self.approver1: User = _make_approver("_api1")
self.approver2: User = _make_approver("_api2")
self.approver3: User = _make_approver("_api3")
self.admin_auth: str = _auth_header(self.admin)
self.viewer_auth: str = _auth_header(self.viewer)
self.exp_auth: str = _auth_header(self.experimenter)
def test_list_groups_admin(self) -> None:
approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
)
resp = self.client.get(
reverse("api-1:list_approver_groups"),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("count", data)
self.assertIn("items", data)
self.assertEqual(data["count"], 1)
def test_list_groups_viewer_denied(self) -> None:
resp = self.client.get(
reverse("api-1:list_approver_groups"),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_list_groups_unauthenticated(self) -> None:
resp = self.client.get(reverse("api-1:list_approver_groups"))
self.assertEqual(resp.status_code, 401)
def test_list_groups_pagination(self) -> None:
exp2: User = _make_experimenter("_api2")
approver_group_create(experimenter=self.experimenter)
approver_group_create(experimenter=exp2)
resp = self.client.get(
reverse("api-1:list_approver_groups"),
data={"limit": 1, "offset": 0},
HTTP_AUTHORIZATION=self.admin_auth,
)
data = resp.json()
self.assertEqual(data["count"], 2)
self.assertEqual(len(data["items"]), 1)
def test_create_group_admin(self) -> None:
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.experimenter.pk),
"approverIds": [
str(self.approver1.pk),
str(self.approver2.pk),
],
"minApprovals": 2,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(_get(data, "minApprovals", "min_approvals"), 2)
self.assertEqual(len(data["approvers"]), 2)
self.assertEqual(data["experimenter"]["id"], str(self.experimenter.pk))
def test_create_group_without_approvers(self) -> None:
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.experimenter.pk),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(len(data["approvers"]), 0)
def test_create_group_viewer_denied(self) -> None:
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.experimenter.pk),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_create_group_experimenter_denied(self) -> None:
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.experimenter.pk),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.exp_auth,
)
self.assertEqual(resp.status_code, 403)
def test_create_group_nonexistent_experimenter(self) -> None:
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(uuid.uuid4()),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_create_group_experimenter_wrong_role(self) -> None:
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.viewer.pk),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_create_group_duplicate_raises(self) -> None:
self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.experimenter.pk),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.experimenter.pk),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400, 409])
def test_create_group_approver_wrong_role(self) -> None:
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(self.experimenter.pk),
"approverIds": [str(self.viewer.pk)],
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_get_group_admin(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
)
resp = self.client.get(
reverse(
"api-1:get_approver_group",
kwargs={"group_id": str(group.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["id"], str(group.pk))
self.assertEqual(len(data["approvers"]), 1)
def test_get_group_not_found(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_approver_group",
kwargs={"group_id": str(uuid.uuid4())},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_get_group_viewer_denied(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
resp = self.client.get(
reverse(
"api-1:get_approver_group",
kwargs={"group_id": str(group.pk)},
),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_group_admin(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
min_approvals=1,
)
resp = self.client.patch(
reverse(
"api-1:update_approver_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps(
{
"approverIds": [
str(self.approver1.pk),
str(self.approver2.pk),
],
"minApprovals": 2,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(_get(data, "minApprovals", "min_approvals"), 2)
self.assertEqual(len(data["approvers"]), 2)
def test_update_group_partial_min_approvals(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[
str(self.approver1.pk),
str(self.approver2.pk),
],
min_approvals=1,
)
resp = self.client.patch(
reverse(
"api-1:update_approver_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"minApprovals": 2}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(_get(data, "minApprovals", "min_approvals"), 2)
def test_update_group_not_found(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_approver_group",
kwargs={"group_id": str(uuid.uuid4())},
),
data=json.dumps({"minApprovals": 1}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_update_group_viewer_denied(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
resp = self.client.patch(
reverse(
"api-1:update_approver_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"minApprovals": 1}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_delete_group_admin(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
pk = group.pk
resp = self.client.delete(
reverse(
"api-1:delete_approver_group",
kwargs={"group_id": str(pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 204)
self.assertFalse(ApproverGroup.objects.filter(pk=pk).exists())
def test_delete_group_not_found(self) -> None:
resp = self.client.delete(
reverse(
"api-1:delete_approver_group",
kwargs={"group_id": str(uuid.uuid4())},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_delete_group_viewer_denied(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
resp = self.client.delete(
reverse(
"api-1:delete_approver_group",
kwargs={"group_id": str(group.pk)},
),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_add_approver_to_group_admin(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
)
resp = self.client.post(
reverse(
"api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.approver2.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()["approvers"]), 2)
def test_add_approver_not_found_group(self) -> None:
resp = self.client.post(
reverse(
"api-1:add_approver_to_group",
kwargs={"group_id": str(uuid.uuid4())},
),
data=json.dumps({"approverId": str(self.approver1.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_add_approver_not_found_user(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
resp = self.client.post(
reverse(
"api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(uuid.uuid4())}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_add_approver_wrong_role(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
resp = self.client.post(
reverse(
"api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.viewer.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_add_approver_duplicate(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
)
resp = self.client.post(
reverse(
"api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.approver1.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_add_approver_viewer_denied(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter
)
resp = self.client.post(
reverse(
"api-1:add_approver_to_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.approver1.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_remove_approver_from_group_admin(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[
str(self.approver1.pk),
str(self.approver2.pk),
],
min_approvals=1,
)
resp = self.client.post(
reverse(
"api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.approver2.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()["approvers"]), 1)
def test_remove_approver_below_min_raises(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
min_approvals=1,
)
resp = self.client.post(
reverse(
"api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.approver1.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_remove_approver_not_in_group(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver1.pk)],
)
resp = self.client.post(
reverse(
"api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.approver2.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_remove_approver_viewer_denied(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[
str(self.approver1.pk),
str(self.approver2.pk),
],
)
resp = self.client.post(
reverse(
"api-1:remove_approver_from_group",
kwargs={"group_id": str(group.pk)},
),
data=json.dumps({"approverId": str(self.approver1.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
class ApproverGroupByExperimenterAPITest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_admin("_byexp")
self.experimenter: User = _make_experimenter("_byexp")
self.approver: User = _make_approver("_byexp")
self.admin_auth: str = _auth_header(self.admin)
def test_get_by_experimenter_found(self) -> None:
group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)],
)
resp = self.client.get(
reverse(
"api-1:get_approver_group_by_experimenter",
kwargs={"experimenter_id": str(self.experimenter.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["id"], str(group.pk))
def test_get_by_experimenter_not_found(self) -> None:
exp2: User = _make_experimenter("_byexp2")
resp = self.client.get(
reverse(
"api-1:get_approver_group_by_experimenter",
kwargs={"experimenter_id": str(exp2.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_get_by_experimenter_invalid_id(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_approver_group_by_experimenter",
kwargs={"experimenter_id": str(uuid.uuid4())},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_get_by_experimenter_unauthenticated(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_approver_group_by_experimenter",
kwargs={"experimenter_id": str(self.experimenter.pk)},
)
)
self.assertEqual(resp.status_code, 401)
class ReviewSettingsAPITest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_admin("_rs")
self.viewer: User = _make_viewer("_rs")
self.experimenter: User = _make_experimenter("_rs")
self.approver: User = _make_approver("_rs")
self.admin_auth: str = _auth_header(self.admin)
self.viewer_auth: str = _auth_header(self.viewer)
self.exp_auth: str = _auth_header(self.experimenter)
self.appr_auth: str = _auth_header(self.approver)
def test_get_settings_admin(self) -> None:
resp = self.client.get(
reverse("api-1:get_review_settings"),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
min_val = _get(data, "defaultMinApprovals", "default_min_approvals")
any_val = _get(data, "allowAnyApprover", "allow_any_approver")
self.assertEqual(min_val, 1)
self.assertFalse(any_val)
def test_get_settings_any_authenticated_role(self) -> None:
for auth in [self.viewer_auth, self.exp_auth, self.appr_auth]:
resp = self.client.get(
reverse("api-1:get_review_settings"),
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 200)
def test_get_settings_unauthenticated(self) -> None:
resp = self.client.get(reverse("api-1:get_review_settings"))
self.assertEqual(resp.status_code, 401)
def test_update_settings_admin(self) -> None:
resp = self.client.put(
reverse("api-1:update_review_settings"),
data=json.dumps(
{"defaultMinApprovals": 3, "allowAnyApprover": False}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
min_val = _get(data, "defaultMinApprovals", "default_min_approvals")
any_val = _get(data, "allowAnyApprover", "allow_any_approver")
self.assertEqual(min_val, 3)
self.assertFalse(any_val)
def test_update_settings_partial(self) -> None:
resp = self.client.put(
reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 5}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
min_val = _get(data, "defaultMinApprovals", "default_min_approvals")
self.assertEqual(min_val, 5)
def test_update_settings_viewer_denied(self) -> None:
resp = self.client.put(
reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 2}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_settings_experimenter_denied(self) -> None:
resp = self.client.put(
reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 2}),
content_type="application/json",
HTTP_AUTHORIZATION=self.exp_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_settings_approver_denied(self) -> None:
resp = self.client.put(
reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 2}),
content_type="application/json",
HTTP_AUTHORIZATION=self.appr_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_settings_invalid_min_approvals(self) -> None:
resp = self.client.put(
reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 0}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
class EffectivePolicyAPITest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_admin("_pol")
self.experimenter: User = _make_experimenter("_pol")
self.exp_no_group: User = _make_experimenter("_pol2")
self.approver: User = _make_approver("_pol")
self.admin_auth: str = _auth_header(self.admin)
self.viewer: User = _make_viewer("_pol")
self.viewer_auth: str = _auth_header(self.viewer)
self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)],
min_approvals=1,
)
def test_effective_policy_with_group(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(self.experimenter.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
exp_id = _get(data, "experimenterId", "experimenter_id")
min_val = _get(data, "minApprovals", "min_approvals")
has_group = _get(data, "hasExplicitGroup", "has_explicit_group")
self.assertEqual(exp_id, str(self.experimenter.pk))
self.assertEqual(min_val, 1)
self.assertEqual(data["source"], "approver_group")
self.assertTrue(has_group)
self.assertEqual(len(data["approvers"]), 1)
self.assertEqual(data["approvers"][0]["id"], str(self.approver.pk))
def test_effective_policy_fallback(self) -> None:
review_settings_update(
default_min_approvals=2, allow_any_approver=True
)
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(self.exp_no_group.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
min_val = _get(data, "minApprovals", "min_approvals")
has_group = _get(data, "hasExplicitGroup", "has_explicit_group")
self.assertEqual(data["source"], "global_fallback")
self.assertFalse(has_group)
self.assertEqual(min_val, 2)
def test_effective_policy_fallback_deny(self) -> None:
review_settings_update(allow_any_approver=False)
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(self.exp_no_group.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["source"], "global_fallback")
self.assertEqual(len(data["approvers"]), 0)
def test_effective_policy_non_experimenter_user(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(self.viewer.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_effective_policy_nonexistent_user(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(uuid.uuid4())},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_effective_policy_unauthenticated(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(self.experimenter.pk)},
)
)
self.assertEqual(resp.status_code, 401)
def test_effective_policy_any_authenticated_can_read(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(self.experimenter.pk)},
),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 200)
@@ -0,0 +1,317 @@
import json
from django.core.exceptions import ValidationError
from django.test import Client, TestCase
from django.urls import reverse
from apps.reviews.models import ApproverGroup
from apps.reviews.selectors import (
get_effective_approvers_for_experimenter,
get_min_approvals_for_experimenter,
)
from apps.reviews.services import (
approver_group_create,
approver_group_delete,
approver_group_update,
review_settings_update,
)
from apps.reviews.tests._helpers import (
_make_admin,
_make_approver,
_make_experimenter,
_make_viewer,
)
from apps.users.models import User
from apps.users.tests._helpers import _auth_header
class ReviewRBACTest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_admin("_rbac")
self.experimenter: User = _make_experimenter("_rbac")
self.approver: User = _make_approver("_rbac")
self.viewer: User = _make_viewer("_rbac")
self.non_admin_users: dict[str, str] = {
"experimenter": _auth_header(self.experimenter),
"approver": _auth_header(self.approver),
"viewer": _auth_header(self.viewer),
}
self.group: ApproverGroup = approver_group_create(
experimenter=self.experimenter,
approver_ids=[str(self.approver.pk)],
)
def test_list_groups_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items():
resp = self.client.get(
reverse("api-1:list_approver_groups"),
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
def test_create_group_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items():
exp2: User = _make_experimenter(f"_rbac_cr_{role_name}")
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(exp2.pk),
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
def test_get_group_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items():
resp = self.client.get(
reverse(
"api-1:get_approver_group",
kwargs={"group_id": str(self.group.pk)},
),
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
def test_update_group_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items():
resp = self.client.patch(
reverse(
"api-1:update_approver_group",
kwargs={"group_id": str(self.group.pk)},
),
data=json.dumps({"minApprovals": 1}),
content_type="application/json",
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
def test_delete_group_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items():
resp = self.client.delete(
reverse(
"api-1:delete_approver_group",
kwargs={"group_id": str(self.group.pk)},
),
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
def test_add_approver_denied_for_non_admins(self) -> None:
appr2: User = _make_approver("_rbac_add")
for role_name, auth in self.non_admin_users.items():
resp = self.client.post(
reverse(
"api-1:add_approver_to_group",
kwargs={"group_id": str(self.group.pk)},
),
data=json.dumps({"approverId": str(appr2.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
def test_remove_approver_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items():
resp = self.client.post(
reverse(
"api-1:remove_approver_from_group",
kwargs={"group_id": str(self.group.pk)},
),
data=json.dumps({"approverId": str(self.approver.pk)}),
content_type="application/json",
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
def test_update_settings_denied_for_non_admins(self) -> None:
for role_name, auth in self.non_admin_users.items():
resp = self.client.put(
reverse("api-1:update_review_settings"),
data=json.dumps({"defaultMinApprovals": 99}),
content_type="application/json",
HTTP_AUTHORIZATION=auth,
)
self.assertEqual(resp.status_code, 403, role_name)
class ReviewEdgeCasesTest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_admin("_edge")
self.admin_auth: str = _auth_header(self.admin)
def test_delete_group_then_fallback_applies(self) -> None:
exp: User = _make_experimenter("_edge1")
appr: User = _make_approver("_edge1")
group: ApproverGroup = approver_group_create(
experimenter=exp,
approver_ids=[str(appr.pk)],
min_approvals=1,
)
approver_group_delete(group=group)
review_settings_update(
default_min_approvals=3, allow_any_approver=True
)
min_app: int = get_min_approvals_for_experimenter(exp)
self.assertEqual(min_app, 3)
def test_inactive_approver_excluded_from_effective_policy(self) -> None:
exp: User = _make_experimenter("_edge2")
appr: User = _make_approver("_edge2")
approver_group_create(
experimenter=exp,
approver_ids=[str(appr.pk)],
)
appr.is_active = False
appr.save()
approvers, _ = get_effective_approvers_for_experimenter(exp)
self.assertEqual(approvers.count(), 0)
def test_create_group_with_all_three_approvers(self) -> None:
exp: User = _make_experimenter("_edge3")
apprs: list[User] = [_make_approver(f"_edge3_{i}") for i in range(3)]
group: ApproverGroup = approver_group_create(
experimenter=exp,
approver_ids=[str(a.pk) for a in apprs],
min_approvals=2,
)
self.assertEqual(group.approvers.count(), 3)
self.assertEqual(group.min_approvals, 2)
def test_update_group_to_empty_approvers(self) -> None:
exp: User = _make_experimenter("_edge4")
appr: User = _make_approver("_edge4")
group: ApproverGroup = approver_group_create(
experimenter=exp,
approver_ids=[str(appr.pk)],
min_approvals=1,
)
with self.assertRaises(ValidationError):
approver_group_update(
group=group, approver_ids=[], min_approvals=1
)
def test_api_output_format_approver_group(self) -> None:
exp: User = _make_experimenter("_edge5")
appr: User = _make_approver("_edge5")
resp = self.client.post(
reverse("api-1:create_approver_group"),
data=json.dumps(
{
"experimenterId": str(exp.pk),
"approverIds": [str(appr.pk)],
"minApprovals": 1,
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertIn("id", data)
self.assertIn("experimenter", data)
self.assertIn("approvers", data)
self.assertTrue("minApprovals" in data or "min_approvals" in data)
self.assertTrue("createdAt" in data or "created_at" in data)
self.assertTrue("updatedAt" in data or "updated_at" in data)
self.assertIn("id", data["experimenter"])
self.assertIn("username", data["experimenter"])
self.assertIn("email", data["experimenter"])
self.assertEqual(len(data["approvers"]), 1)
appr_data = data["approvers"][0]
self.assertIn("id", appr_data)
self.assertIn("username", appr_data)
self.assertIn("email", appr_data)
def test_api_output_format_review_settings(self) -> None:
resp = self.client.get(
reverse("api-1:get_review_settings"),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("id", data)
self.assertTrue(
"defaultMinApprovals" in data or "default_min_approvals" in data
)
self.assertTrue(
"allowAnyApprover" in data or "allow_any_approver" in data
)
self.assertTrue("updatedAt" in data or "updated_at" in data)
def test_api_output_format_effective_policy(self) -> None:
exp: User = _make_experimenter("_edge6")
appr: User = _make_approver("_edge6")
approver_group_create(
experimenter=exp,
approver_ids=[str(appr.pk)],
)
resp = self.client.get(
reverse(
"api-1:get_effective_policy",
kwargs={"experimenter_id": str(exp.pk)},
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertTrue("experimenterId" in data or "experimenter_id" in data)
self.assertTrue("minApprovals" in data or "min_approvals" in data)
self.assertIn("approvers", data)
self.assertIn("source", data)
self.assertTrue(
"hasExplicitGroup" in data or "has_explicit_group" in data
)
def test_multiple_experimenters_independent_groups(self) -> None:
exp1: User = _make_experimenter("_edge_m1")
exp2: User = _make_experimenter("_edge_m2")
appr1: User = _make_approver("_edge_m1")
appr2: User = _make_approver("_edge_m2")
g1: ApproverGroup = approver_group_create(
experimenter=exp1,
approver_ids=[str(appr1.pk)],
min_approvals=1,
)
g2: ApproverGroup = approver_group_create(
experimenter=exp2,
approver_ids=[str(appr2.pk)],
min_approvals=1,
)
self.assertNotEqual(g1.pk, g2.pk)
self.assertTrue(g1.can_approve(appr1))
self.assertFalse(g1.can_approve(appr2))
self.assertTrue(g2.can_approve(appr2))
self.assertFalse(g2.can_approve(appr1))
def test_concurrent_fallback_and_explicit_group(self) -> None:
exp_with: User = _make_experimenter("_edge_c1")
exp_without: User = _make_experimenter("_edge_c2")
appr: User = _make_approver("_edge_c")
approver_group_create(
experimenter=exp_with,
approver_ids=[str(appr.pk)],
min_approvals=1,
)
review_settings_update(
default_min_approvals=5, allow_any_approver=True
)
approvers1, min1 = get_effective_approvers_for_experimenter(exp_with)
self.assertEqual(min1, 1)
self.assertEqual(approvers1.count(), 1)
approvers2, min2 = get_effective_approvers_for_experimenter(
exp_without
)
self.assertEqual(min2, 5)
self.assertGreaterEqual(approvers2.count(), 1)
+17 -4
View File
@@ -7,6 +7,9 @@ from ninja import NinjaAPI, Schema
from ninja.renderers import BaseRenderer
from api.v1 import handlers
from api.v1.auth.endpoints import router as auth_router
from api.v1.reviews.endpoints import router as reviews_router
from api.v1.users.endpoints import router as users_router
class ORJSONRenderer(BaseRenderer):
@@ -33,10 +36,20 @@ router = NinjaAPI(
)
# router.add_router(
# "health",
# health_router,
# )
router.add_router(
"auth",
auth_router,
)
router.add_router(
"users",
users_router,
)
router.add_router(
"reviews",
reviews_router,
)
for exception, handler in handlers.exception_handlers:
+1 -5
View File
@@ -2,12 +2,10 @@ from datetime import datetime
from typing import Any
from ninja import Schema
from pydantic import ConfigDict, Field
from pydantic import Field
class FieldError(Schema):
model_config = ConfigDict(populate_by_name=True)
field: str = Field(
...,
description="Field name with error (can be nested)",
@@ -19,8 +17,6 @@ class FieldError(Schema):
class ApiError(Schema):
model_config = ConfigDict(populate_by_name=True)
code: str
message: str
trace_id: str = Field(..., alias="traceId")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = "api.v1.users"
label = "api_v1_users"
+192
View File
@@ -0,0 +1,192 @@
from http import HTTPStatus
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Router
from api.v1.users.schemas import (
UserCreateIn,
UserListOut,
UserOut,
UserRoleAssignIn,
UserUpdateIn,
)
from apps.users.auth.bearer import jwt_bearer, require_admin
from apps.users.models import User, UserRole
from apps.users.selectors import user_list
from apps.users.services import (
user_assign_role,
user_create,
user_delete,
user_update,
)
router = Router(tags=["users"])
@router.get(
"",
response={HTTPStatus.OK: UserListOut},
auth=jwt_bearer,
summary="List users",
description=(
"Return a filtered, paginated list of platform users. Admin only."
),
)
@require_admin
def list_users(
request: HttpRequest,
role: str | None = None,
is_active: bool | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[int, UserListOut]:
qs = user_list(role=role, is_active=is_active, search=search)
total = qs.count()
items = [
UserOut.model_validate(item) for item in qs[offset : offset + limit]
]
return HTTPStatus.OK, UserListOut(count=total, items=items)
@router.post(
"",
response={201: UserOut},
auth=jwt_bearer,
summary="Create user",
description=(
"Create a new platform user with the specified role. Admin only."
),
)
@require_admin
def create_user(
request: HttpRequest,
payload: UserCreateIn,
) -> tuple[int, UserOut]:
valid_roles = {choice[0] for choice in UserRole.choices}
if payload.role not in valid_roles:
raise ValidationError(
{
"role": (
f"Invalid role '{payload.role}'. "
f"Must be one of: {', '.join(sorted(valid_roles))}"
)
}
)
user = user_create(
username=payload.username,
email=payload.email,
password=payload.password,
role=payload.role,
first_name=payload.first_name,
last_name=payload.last_name,
)
return HTTPStatus.CREATED, UserOut.model_validate(user)
@router.get(
"/{user_id}",
response={HTTPStatus.OK: UserOut},
auth=jwt_bearer,
summary="Get user",
description="Retrieve a single user by their UUID. Admin only.",
)
@require_admin
def get_user(
request: HttpRequest,
user_id: str,
) -> tuple[int, UserOut]:
user = get_object_or_404(User, pk=user_id)
return HTTPStatus.OK, UserOut.model_validate(user)
@router.patch(
"/{user_id}",
response={HTTPStatus.OK: UserOut},
auth=jwt_bearer,
summary="Update user",
description=(
"Partially update an existing user. "
"Only non-null fields in the payload are applied. Admin only."
),
)
@require_admin
def update_user(
request: HttpRequest,
user_id: str,
payload: UserUpdateIn,
) -> tuple[int, UserOut]:
user = get_object_or_404(User, pk=user_id)
if payload.role is not None:
valid_roles = {choice[0] for choice in UserRole.choices}
if payload.role not in valid_roles:
raise ValidationError(
{
"role": (
f"Invalid role '{payload.role}'. "
f"Must be one of: {', '.join(sorted(valid_roles))}"
)
}
)
updated_user = user_update(
user=user,
username=payload.username,
email=payload.email,
password=payload.password,
role=payload.role,
is_active=payload.is_active,
first_name=payload.first_name,
last_name=payload.last_name,
)
return HTTPStatus.OK, UserOut.model_validate(updated_user)
@router.delete(
"/{user_id}",
response={204: None},
auth=jwt_bearer,
summary="Delete user",
description="Permanently delete a user. Admin only.",
)
@require_admin
def delete_user(
request: HttpRequest,
user_id: str,
) -> tuple[int, None]:
user = get_object_or_404(User, pk=user_id)
current_user = getattr(request, "auth", None)
if not isinstance(current_user, User):
raise ValidationError({"user": "Authentication required."})
if str(user.pk) == str(current_user.pk):
raise ValidationError({"user": "You cannot delete your own account."})
user_delete(user=user)
return HTTPStatus.NO_CONTENT, None
@router.post(
"/{user_id}/role",
response={HTTPStatus.OK: UserOut},
auth=jwt_bearer,
summary="Assign role",
description="Change the platform role of an existing user. Admin only.",
)
@require_admin
def assign_role(
request: HttpRequest,
user_id: str,
payload: UserRoleAssignIn,
) -> tuple[int, UserOut]:
user = get_object_or_404(User, pk=user_id)
updated_user = user_assign_role(user=user, role=payload.role)
return HTTPStatus.OK, UserOut.model_validate(updated_user)
+123
View File
@@ -0,0 +1,123 @@
from typing import ClassVar
from ninja import ModelSchema, Schema
from pydantic import Field
from apps.users.models import User
class UserOut(ModelSchema):
first_name: str = Field("", alias="firstName")
last_name: str = Field("", alias="lastName")
is_active: bool
class Meta:
model = User
fields: ClassVar[tuple[str, ...]] = (
User.id.field.name,
User.username.field.name,
User.email.field.name,
User.role.field.name,
User.first_name.field.name,
User.last_name.field.name,
User._meta.get_field("is_active").name,
)
class UserCreateIn(Schema):
username: str = Field(
...,
min_length=1,
max_length=150,
description="Unique username for the new account.",
)
email: str = Field(
...,
min_length=1,
max_length=254,
description="Email address.",
)
password: str = Field(
...,
min_length=8,
max_length=128,
description="Account password (min 8 characters).",
)
role: str = Field(
"viewer",
description=(
"Platform role to assign. "
"One of: admin, experimenter, approver, viewer."
),
)
first_name: str = Field(
"",
alias="firstName",
max_length=150,
description="First name.",
)
last_name: str = Field(
"",
alias="lastName",
max_length=150,
description="Last name.",
)
class UserUpdateIn(Schema):
username: str | None = Field(
None,
min_length=1,
max_length=150,
description="New username.",
)
email: str | None = Field(
None,
min_length=1,
max_length=254,
description="New email address.",
)
password: str | None = Field(
None,
min_length=8,
max_length=128,
description="New password (min 8 characters).",
)
role: str | None = Field(
None,
description=(
"New platform role. One of: admin, experimenter, approver, viewer."
),
)
first_name: str | None = Field(
None,
alias="firstName",
max_length=150,
description="New first name.",
)
last_name: str | None = Field(
None,
alias="lastName",
max_length=150,
description="New last name.",
)
is_active: bool | None = Field(
None,
alias="isActive",
description="Set active/inactive status.",
)
class UserRoleAssignIn(Schema):
role: str = Field(
...,
description=(
"Platform role to assign. "
"One of: admin, experimenter, approver, viewer."
),
)
class UserListOut(Schema):
count: int
items: list[UserOut]
@@ -0,0 +1,23 @@
from django.test import Client, TestCase
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user
class BaseUsersAPITest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.admin: User = _make_user(
username="mgmt_admin",
email="mgmt_admin@x.com",
password="adminpass1",
role=UserRole.ADMIN,
)
self.viewer: User = _make_user(
username="mgmt_viewer",
email="mgmt_viewer@x.com",
password="viewerpass",
role=UserRole.VIEWER,
)
self.admin_auth: str = _auth_header(self.admin)
self.viewer_auth: str = _auth_header(self.viewer)
@@ -0,0 +1,96 @@
import json
import uuid
from django.urls import reverse
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _make_user
from ._crud_base import BaseUsersAPITest
class UsersAPIDeleteAssignRoleTest(BaseUsersAPITest):
def test_delete_user_admin(self) -> None:
target: User = _make_user(
username="to_delete",
email="del@lotty.local",
role=UserRole.VIEWER,
)
resp = self.client.delete(
reverse("api-1:delete_user", kwargs={"user_id": str(target.pk)}),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 204)
self.assertFalse(User.objects.filter(pk=target.pk).exists())
def test_delete_self_denied(self) -> None:
resp = self.client.delete(
reverse(
"api-1:delete_user", kwargs={"user_id": str(self.admin.pk)}
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_delete_user_viewer_denied(self) -> None:
resp = self.client.delete(
reverse(
"api-1:delete_user", kwargs={"user_id": str(self.admin.pk)}
),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_delete_user_not_found(self) -> None:
resp = self.client.delete(
reverse(
"api-1:delete_user", kwargs={"user_id": str(uuid.uuid4())}
),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_assign_role_admin(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"role": "experimenter"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["role"], "experimenter")
def test_assign_role_invalid(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"role": "megaboss"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_assign_role_viewer_denied(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(self.admin.pk)}
),
data=json.dumps({"role": "viewer"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_assign_role_not_found(self) -> None:
resp = self.client.post(
reverse(
"api-1:assign_role", kwargs={"user_id": str(uuid.uuid4())}
),
data=json.dumps({"role": "admin"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
@@ -0,0 +1,125 @@
import json
from django.urls import reverse
from ._crud_base import BaseUsersAPITest
class UsersAPIListCreateTest(BaseUsersAPITest):
def test_list_users_admin(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("count", data)
self.assertIn("items", data)
self.assertEqual(data["count"], 2)
def test_list_users_viewer_denied(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_list_users_unauthenticated(self) -> None:
resp = self.client.get(reverse("api-1:list_users"))
self.assertEqual(resp.status_code, 401)
def test_list_users_filter_by_role(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
data={"role": "admin"},
HTTP_AUTHORIZATION=self.admin_auth,
)
data = resp.json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["items"][0]["role"], "admin")
def test_list_users_search(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
data={"search": "mgmt_viewer"},
HTTP_AUTHORIZATION=self.admin_auth,
)
data = resp.json()
self.assertEqual(data["count"], 1)
def test_list_users_pagination(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
data={"limit": 1, "offset": 0},
HTTP_AUTHORIZATION=self.admin_auth,
)
data = resp.json()
self.assertEqual(len(data["items"]), 1)
self.assertEqual(data["count"], 2)
def test_create_user_admin(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "newuser",
"email": "new@lotty.local",
"password": "newpass123",
"role": "experimenter",
"firstName": "New",
"lastName": "User",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(data["username"], "newuser")
self.assertEqual(data["role"], "experimenter")
def test_create_user_invalid_role(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "bad_role",
"email": "bad@lotty.local",
"password": "password1",
"role": "superadmin",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_create_user_viewer_denied(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "denied",
"email": "denied@lotty.local",
"password": "password1",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_create_user_duplicate_username(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "mgmt_admin",
"email": "other@lotty.local",
"password": "password1",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [409, 422, 400, 500])
@@ -0,0 +1,93 @@
import json
import uuid
from django.urls import reverse
from ._crud_base import BaseUsersAPITest
class UsersAPIReadUpdateTest(BaseUsersAPITest):
def test_get_user_admin(self) -> None:
resp = self.client.get(
reverse("api-1:get_user", kwargs={"user_id": str(self.viewer.pk)}),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["username"], "mgmt_viewer")
def test_get_user_not_found(self) -> None:
resp = self.client.get(
reverse("api-1:get_user", kwargs={"user_id": str(uuid.uuid4())}),
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
def test_get_user_viewer_denied(self) -> None:
resp = self.client.get(
reverse("api-1:get_user", kwargs={"user_id": str(self.admin.pk)}),
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_user_admin(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps(
{"username": "renamed_viewer", "role": "approver"}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["username"], "renamed_viewer")
self.assertEqual(data["role"], "approver")
def test_update_user_partial(self) -> None:
original_role = self.viewer.role
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"firstName": "Updated"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["role"], original_role)
def test_update_user_invalid_role(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.viewer.pk)}
),
data=json.dumps({"role": "superadmin"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertIn(resp.status_code, [422, 400])
def test_update_user_viewer_denied(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(self.admin.pk)}
),
data=json.dumps({"firstName": "Hacked"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.viewer_auth,
)
self.assertEqual(resp.status_code, 403)
def test_update_user_not_found(self) -> None:
resp = self.client.patch(
reverse(
"api-1:update_user", kwargs={"user_id": str(uuid.uuid4())}
),
data=json.dumps({"firstName": "Ghost"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.admin_auth,
)
self.assertEqual(resp.status_code, 404)
@@ -0,0 +1,100 @@
import json
from django.test import Client, TestCase
from django.urls import reverse
from apps.users.models import User, UserRole
from apps.users.tests._helpers import _auth_header, _make_user
class RoleBasedAccessControlTest(TestCase):
def setUp(self) -> None:
self.client = Client()
self.roles = {}
for role_val in [
UserRole.ADMIN,
UserRole.EXPERIMENTER,
UserRole.APPROVER,
UserRole.VIEWER,
]:
user: User = _make_user(
username=f"rbac_{role_val}",
email=f"rbac_{role_val}@x.com",
password="password1",
role=role_val,
)
self.roles[role_val] = {
"user": user,
"auth": _auth_header(user),
}
def test_admin_can_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.ADMIN]["auth"],
)
self.assertEqual(resp.status_code, 200)
def test_experimenter_cannot_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_approver_cannot_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.APPROVER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_viewer_cannot_list(self) -> None:
resp = self.client.get(
reverse("api-1:list_users"),
HTTP_AUTHORIZATION=self.roles[UserRole.VIEWER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_experimenter_cannot_create(self) -> None:
resp = self.client.post(
reverse("api-1:create_user"),
data=json.dumps(
{
"username": "blocked",
"email": "blocked@x.com",
"password": "password1",
}
),
content_type="application/json",
HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_approver_cannot_delete(self) -> None:
target = self.roles[UserRole.VIEWER]["user"]
resp = self.client.delete(
reverse("api-1:delete_user", kwargs={"user_id": str(target.pk)}),
HTTP_AUTHORIZATION=self.roles[UserRole.APPROVER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_viewer_cannot_assign_role(self) -> None:
target = self.roles[UserRole.EXPERIMENTER]["user"]
resp = self.client.post(
reverse("api-1:assign_role", kwargs={"user_id": str(target.pk)}),
data=json.dumps({"role": "admin"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.roles[UserRole.VIEWER]["auth"],
)
self.assertEqual(resp.status_code, 403)
def test_experimenter_cannot_update(self) -> None:
target = self.roles[UserRole.VIEWER]["user"]
resp = self.client.patch(
reverse("api-1:update_user", kwargs={"user_id": str(target.pk)}),
data=json.dumps({"firstName": "Nope"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.roles[UserRole.EXPERIMENTER]["auth"],
)
self.assertEqual(resp.status_code, 403)