feat(backend): added auth, reviews, users modules
also provided tests
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReviewsConfig(AppConfig):
|
||||
name = "api.v1.reviews"
|
||||
label = "api_v1_reviews"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user