Invitations #11

Merged
BlackChaosNL merged 4 commits from fea/invitations into master 2025-07-12 17:24:03 +03:00
17 changed files with 888 additions and 70 deletions
+3 -5
View File
@@ -1,8 +1,6 @@
from fastapi.security import OAuth2PasswordBearer
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
from passlib.context import CryptContext # type: ignore
import pytz
from pydantic_settings import BaseSettings, SettingsConfigDict
from pwdlib import PasswordHash
class Settings(BaseSettings):
PROJECT_NAME: str = "StoneEdge Asset Management System"
PROJECT_VERSION: str = "0.0.1"
@@ -19,7 +17,7 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MIN: int = 10
REFRESH_TOKEN_EXPIRE_MIN: int = 20
BACKEND_CORS_ORIGINS: list = ["*"]
CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto")
CRYPT: PasswordHash = PasswordHash.recommended()
OAUTH2_SCHEME: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="token")
model_config = SettingsConfigDict(env_file=".env")
+1
View File
@@ -8,6 +8,7 @@ modules: dict[str, Any] = {
"modules.auth.models",
"modules.users.models",
"modules.organizations.models",
"modules.invitations.models",
]
}
+2
View File
@@ -10,6 +10,7 @@ from modules.assets.router import router as asset_router
from modules.auth.router import router as auth_router
from modules.users.router import router as users_router
from modules.organizations.router import router as organizations_router
from modules.invitations.router import router as invitations_router
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
@@ -53,3 +54,4 @@ app.include_router(auth_router)
app.include_router(users_router)
app.include_router(organizations_router)
app.include_router(asset_router)
app.include_router(invitations_router)
@@ -12,9 +12,6 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
"ADMIN" INT NOT NULL DEFAULT 0
) /* ACL */;
CREATE TABLE IF NOT EXISTS "organization" (
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"id" CHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(128) NOT NULL,
"type" VARCHAR(128) NOT NULL,
@@ -23,41 +20,57 @@ CREATE TABLE IF NOT EXISTS "organization" (
"state" VARCHAR(128),
"city" VARCHAR(128),
"country" VARCHAR(128),
"disabled" INT NOT NULL DEFAULT 0
) /* Organization */;
CREATE TABLE IF NOT EXISTS "user" (
"disabled" INT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"disabled_at" TIMESTAMP
) /* Organization */;
CREATE TABLE IF NOT EXISTS "user" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"email" VARCHAR(128) NOT NULL,
"username" TEXT NOT NULL,
"name" TEXT NOT NULL,
"surname" TEXT NOT NULL,
"password" VARCHAR(128),
"disabled" INT NOT NULL DEFAULT 0
) /* User */;
CREATE TABLE IF NOT EXISTS "token" (
"disabled" INT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"disabled_at" TIMESTAMP
) /* User */;
CREATE TABLE IF NOT EXISTS "token" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer',
"access_token" TEXT,
"refresh_token" TEXT,
"disabled" INT NOT NULL DEFAULT 0,
"user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
) /* Token */;
CREATE TABLE IF NOT EXISTS "membership" (
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
) /* Token */;
CREATE TABLE IF NOT EXISTS "membership" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"disabled" INT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE,
"organization_id" CHAR(36) NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE,
"user_id" CHAR(36) NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
"organization_id" CHAR(36) REFERENCES "organization" ("id") ON DELETE SET NULL,
"user_id" CHAR(36) REFERENCES "user" ("id") ON DELETE SET NULL
) /* Membership */;
CREATE TABLE IF NOT EXISTS "invite" (
"id" CHAR(36) NOT NULL PRIMARY KEY,
"receiver" VARCHAR(128) NOT NULL,
"sender" CHAR(36) NOT NULL,
"org_id" CHAR(36) NOT NULL,
"message" TEXT,
"accepted" INT NOT NULL DEFAULT 0,
"disabled" INT NOT NULL DEFAULT 0,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"acl_id" CHAR(36) NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "aerich" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"version" VARCHAR(255) NOT NULL,
-11
View File
@@ -1,11 +0,0 @@
from tortoise import fields
class CMDMixin():
"""
Created, modified and delete mixin, these are required for every class.
"""
created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=True)
+4 -5
View File
@@ -4,11 +4,8 @@ from tortoise import fields
import uuid
from datetime import datetime
from mixins.CMDMixin import CMDMixin
from config import settings
class Token(Model, CMDMixin):
class Token(Model):
"""
Token
@@ -21,9 +18,11 @@ class Token(Model, CMDMixin):
access_token: str = fields.TextField(null=True)
refresh_token: str = fields.TextField(null=True)
disabled: bool = fields.BooleanField(default=False)
created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=True)
async def delete(self) -> None:
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
+17 -8
View File
@@ -1,13 +1,12 @@
from datetime import datetime
from typing import Annotated
import uuid
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.routing import APIRouter
import pytz
from modules.users.utils import get_current_active_user
from modules.users.models import User
from modules.auth.utils import create_jwt_tokens, get_tokens_from_logged_in_user
from modules.auth.models import Token
from modules.users.models import User
from fastapi import Depends, HTTPException, status
from tortoise.expressions import Q
from config import settings
@@ -31,23 +30,31 @@ async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
Logs the user into our API, creates tokens and passes them back to User.
"""
user: User | None = await User.filter(Q(email=form.username) | Q(username=form.username)).first()
user: User | None = await User.filter(
Q(email=form.username) | Q(username=form.username)
).first()
if user is None:
raise HTTPException(status_code=401, detail=account_error)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error
)
if user.check_against_password(form.password) is False:
raise HTTPException(status_code=401, detail=account_error)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error
)
if user.disabled is True:
raise HTTPException(status_code=401, detail=account_error)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=account_error
)
tokens = await create_jwt_tokens(user)
return {"jwt": tokens}
@router.get("/logout", status_code=204)
@router.get("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(user: Annotated[User, Depends(get_current_active_user)]):
"""
Logout
@@ -110,7 +117,9 @@ async def refresh_login(
return {"jwt": tokens}
@router.post("/register", status_code=201, response_model=user_model)
@router.post(
"/register", status_code=status.HTTP_201_CREATED, response_model=user_model
)
async def register(user: register_model):
# Prevent existing users from reapplying for our system.
existing_user: User | None = await User.filter(
@@ -0,0 +1,30 @@
from datetime import datetime
import uuid
import pytz
from tortoise import Model, fields
from modules.users.models import ACL
class Invite(Model):
id: uuid.UUID = fields.UUIDField(primary_key=True)
receiver: str = fields.CharField(max_length=128)
sender: uuid.UUID = fields.UUIDField()
org_id: uuid.UUID = fields.UUIDField()
message: str | None = fields.TextField(null=True)
acl: ACL = fields.ForeignKeyField("models.ACL")
accepted: bool = fields.BooleanField(default=False)
disabled: bool = fields.BooleanField(default=False)
created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=True)
async def delete(self, force: bool = False) -> None:
if force:
await Model.delete(self)
else:
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
@@ -0,0 +1,214 @@
from typing import Annotated, List
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from modules.organizations.models import Organization
from modules.invitations.models import Invite
from modules.invitations.schemas import invitation_model, send_invitation_for_org
from modules.users.models import ACL, Membership, User
from modules.users.utils import get_current_active_user
from tortoise.expressions import Q
router = APIRouter(prefix="/api/v1/invitations", tags=["invites"])
@router.get("/", response_model=List[invitation_model])
async def get_all_invitations(
user: Annotated[User, Depends(get_current_active_user)],
) -> List[Invite]:
"""Returns all invitations for user requesting, except disabled invites.
Args:
user (Annotated[User, Depends(get_current_active_user)]): Returns user token.
Returns:
List[Invite]: A list of invitations.
"""
return await Invite.filter(
(Q(sender=user.id) | (Q(receiver=user.username) | Q(receiver=user.email)))
& Q(disabled=False)
)
@router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_invitation(
user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID
) -> None:
"""Removes an invitation you have sent
Args:
user (Annotated[User, Depends(get_current_active_user)]): Returns user token.
invitation_id (uuid.UUID): UUID for the invitation to be removed
Raises:
HTTPException: When invitation doesn't exist return 403.
Returns:
Invite: The Invitation model.
"""
invite: Invite | None = await Invite.get_or_none(
Q(id=invitation_id) & Q(sender=user.id) & Q(disabled=False)
)
if not invite:
raise HTTPException(
status_code=403,
detail="The invitation doesn't exist or you don't have access to it.",
)
await invite.delete()
@router.get("/accept/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def accept_invitation(
user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID
) -> None:
"""Accepts the invitation sent by a different organization.
Args:
user (Annotated[User, Depends(get_current_active_user)]): Returns user token.
invitation_id (uuid.UUID): UUID for the organization that the users wants to add the person to.
Raises:
HTTPException: Raises exception when invite is not available or disabled.
"""
invite: Invite | None = await Invite.get_or_none(
Q(id=invitation_id)
& (Q(receiver=user.username) | Q(receiver=user.email))
& Q(disabled=False)
).prefetch_related("acl")
if not invite:
raise HTTPException(
status_code=403,
detail="The invitation doesn't exist or you don't have access to it.",
)
if invite.disabled:
raise HTTPException(
status_code=403,
detail="You have already declined the invitation or the invitation was removed, you can't accept it.",
)
invite.accepted = True
await invite.save()
# Disable invite after accepting, prevent changing it.
await invite.delete()
await Membership.create(
user=user, organization=await Organization.get(id=invite.org_id), acl=invite.acl
)
@router.get("/decline/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def reject_invitation(
user: Annotated[User, Depends(get_current_active_user)], invitation_id: uuid.UUID
) -> None:
"""Declines an invitation to join an organization
Args:
user (Annotated[User, Depends(get_current_active_user)]): Returns user token.
invitation_id (uuid.UUID): The UUID of the invitation
Raises:
HTTPException: Checks if the invite exists.
HTTPException: Checks if the invite has already accepted.
Returns:
Invite: The Invitation model.
"""
invite: Invite | None = await Invite.get_or_none(
Q(id=invitation_id)
& (Q(receiver=user.username) | Q(receiver=user.email))
& Q(disabled=False)
).prefetch_related("acl")
if not invite:
raise HTTPException(
status_code=403,
detail="The invitation doesn't exist or you don't have access to it.",
)
if invite.accepted:
raise HTTPException(
status_code=403,
detail="The invitation was already accepted, you can't remove it.",
)
await invite.delete()
@router.post("/send", response_model=invitation_model)
async def send_invitation(
user: Annotated[User, Depends(get_current_active_user)],
invite_details: send_invitation_for_org,
) -> Invite:
"""Sends an invitation to e-mail or username.
Args:
user (Annotated[User, Depends(get_current_active_user)]): Returns user token.
invite_details (send_invitation_for_org): The details for the invitation.
Raises:
HTTPException: Checks access to the organization posted.
HTTPException: Checks for Manager or Admin permissions and declines if you are not.
Returns:
Invite: The Invitation model.
"""
# Should send an E-Mail as notification.
membership = await Membership.get_or_none(
Q(user=user.id) & Q(organization=invite_details.org_id)
).prefetch_related("acl")
if not membership:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this organization.",
)
if not membership.acl.MANAGE or not membership.acl.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not allowed to send invitations for this organization.",
)
# Check if user is already part of organization
invited_user: User | None = await User.get_or_none(
Q(username=invite_details.receiver) | Q(email=invite_details.receiver)
)
if invited_user:
user_is_part_of_org = await Membership.get_or_none(
Q(user=invited_user) & Q(organization=invite_details.org_id)
)
if user_is_part_of_org:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The person you've invited is already part of the organization.",
)
acl = None
if invite_details.acl:
acl = await ACL.create(
READ=invite_details.acl.READ,
WRITE=invite_details.acl.WRITE,
REPORT=invite_details.acl.REPORT,
MANAGE=invite_details.acl.MANAGE,
ADMIN=invite_details.acl.ADMIN,
)
else:
acl = await ACL.create(
READ=True,
)
return await Invite.create(
receiver=invite_details.receiver,
sender=user.id,
org_id=invite_details.org_id,
message=invite_details.message,
acl=acl,
)
@@ -0,0 +1,20 @@
import uuid
from pydantic import BaseModel
from tortoise.contrib.pydantic import pydantic_model_creator
from modules.invitations.models import Invite
invitation_model = pydantic_model_creator(Invite)
class acl_model(BaseModel):
READ: bool
WRITE: bool
REPORT: bool
MANAGE: bool
ADMIN: bool
class send_invitation_for_org(BaseModel):
org_id: uuid.UUID
receiver: str
acl: acl_model | None
message: str | None
@@ -7,7 +7,6 @@ from tortoise.exceptions import ConfigurationError
from tortoise.models import Model
from tortoise import fields
from mixins.CMDMixin import CMDMixin
class EnumField(fields.CharField):
"""
@@ -52,8 +51,7 @@ class OrganizationType(Enum):
EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 1000 - 5000+
class Organization(Model, CMDMixin):
class Organization(Model):
"""
Organization
@@ -79,6 +77,9 @@ class Organization(Model, CMDMixin):
on_delete=fields.NO_ACTION,
)
disabled: bool = fields.BooleanField(default=False)
created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=True)
def __str__(self) -> str:
return f"{self.id} - {self.name}"
@@ -90,5 +91,3 @@ class Organization(Model, CMDMixin):
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
@@ -20,11 +20,10 @@ router = APIRouter(prefix="/api/v1/organizations", tags=["orgs"])
async def all_active_organizations(
user: Annotated[User, Depends(get_current_active_user)],
) -> List[Organization]:
memberships: List[Membership] = list(
await Membership.filter(
Q(user_id=user.id) & Q(disabled=False)
).prefetch_related("organization")
)
memberships: List[Membership] = await Membership.filter(
Q(user_id=user.id) & Q(disabled=False)
).prefetch_related("organization")
organizations: List[Organization] = []
if len(memberships) < 1:
@@ -59,7 +58,7 @@ async def delete_organization(
for member in all_memberships:
await member.acl.delete()
await member.delete()
await membership.acl.delete()
await membership.delete()
return
+10 -6
View File
@@ -6,13 +6,12 @@ from tortoise.models import Model
from tortoise import fields
from modules.organizations.models import Organization
from mixins.CMDMixin import CMDMixin
from config import settings
crypt = settings.CRYPT
class User(Model, CMDMixin):
class User(Model):
"""
User
@@ -35,7 +34,9 @@ class User(Model, CMDMixin):
on_delete=fields.NO_ACTION,
)
disabled: bool = fields.BooleanField(default=False)
# tokens = fields.ForeignKeyField("models.Token")
created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=True)
def __str__(self) -> str:
return f"{self.id} - {self.name} {self.surname}"
@@ -90,7 +91,7 @@ class ACL(Model):
"""
class Membership(Model, CMDMixin):
class Membership(Model):
"""
Membership
@@ -98,10 +99,13 @@ class Membership(Model, CMDMixin):
"""
id: uuid.UUID = fields.UUIDField(primary_key=True)
organization: Organization = fields.ForeignKeyField("models.Organization")
user: User = fields.ForeignKeyField("models.User")
organization: Organization = fields.ForeignKeyField("models.Organization", null=True, on_delete=fields.SET_NULL)
user: User = fields.ForeignKeyField("models.User", null=True, on_delete=fields.SET_NULL)
acl: ACL = fields.ForeignKeyField("models.ACL")
disabled: bool = fields.BooleanField(default=False)
created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=True)
async def delete(self, force: bool = False) -> None:
if force:
@@ -4,7 +4,7 @@ tortoise-orm[asyncpg]>=0.25.1
uvicorn>=0.34.3
black>=25.1.0
joserfc>=1.1.0
passlib>=1.7.4
pwdlib[argon2]>=0.2.1
pytz>=2025.2
ptpython>=3.0.30
msgspec>=0.19.0
@@ -1,6 +1,5 @@
from tests.base_test import Test
from modules.users.models import User
import pytest # type: ignore
from httpx import AsyncClient
from config import settings
from unittest.mock import ANY
@@ -0,0 +1,547 @@
from unittest.mock import ANY
from httpx import AsyncClient
from modules.users.models import ACL, Membership
from tests.base_test import Test
class TestInvitationalRoutes(Test):
async def test_send_invitations(self, client: AsyncClient, create_user_with_org):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin12@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
_, _, _, usertokens = await create_user_with_org(email="user1231@localhost.com")
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "user1231@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "user1231@localhost.com",
"sender": str(admin.id),
}
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == [
{
"id": ANY,
"receiver": "user1231@localhost.com",
"sender": str(admin.id),
"org_id": str(org.id),
"message": "Hi! We would like to invite you to our organization.",
"accepted": False,
"disabled": False,
"created_at": ANY,
"modified_at": ANY,
"disabled_at": None,
}
]
async def test_cannot_see_others_invitations(
self, client: AsyncClient, create_user_with_org
):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin99@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
_, _, _, usertokens = await create_user_with_org(email="user18@localhost.com")
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "user1231@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "user1231@localhost.com",
"sender": str(admin.id),
}
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == []
async def test_removing_invitations(
self, client: AsyncClient, create_user_with_org
):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin9999@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "user9487@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "user9487@localhost.com",
"sender": str(admin.id),
}
invite_id = invite.json()["id"]
removed_invite = await client.delete(
f"https://localhost/api/v1/invitations/{invite_id}",
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert removed_invite.status_code == 204
async def test_cannot_accept_own_invite(
self, client: AsyncClient, create_user_with_org
):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin18569@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "non-existing-user@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "non-existing-user@localhost.com",
"sender": str(admin.id),
}
invite_id = invite.json()["id"]
try_accept_invite = await client.get(
f"https://localhost/api/v1/invitations/accept/{invite_id}",
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert try_accept_invite.status_code == 403
assert try_accept_invite.json() == {
"detail": "The invitation doesn't exist or you don't have access to it."
}
async def test_accept_sent_invitations(
self, client: AsyncClient, create_user_with_org
):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin191@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
_, _, _, usertokens = await create_user_with_org(email="user8@localhost.com")
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "user8@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "user8@localhost.com",
"sender": str(admin.id),
}
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == [
{
"id": ANY,
"receiver": "user8@localhost.com",
"sender": str(admin.id),
"org_id": str(org.id),
"message": "Hi! We would like to invite you to our organization.",
"accepted": False,
"disabled": False,
"created_at": ANY,
"modified_at": ANY,
"disabled_at": None,
}
]
invite_id = user_invites.json()[0]["id"]
accept_invite = await client.get(
f"https://localhost/api/v1/invitations/accept/{invite_id}",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert accept_invite.status_code == 204
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
# When an invite has been accepted, it should be removed.
assert user_invites.json() == []
async def test_decline_sent_invitations(
self, client: AsyncClient, create_user_with_org
):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin11@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
_, _, _, usertokens = await create_user_with_org(email="user98@localhost.com")
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "user98@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "user98@localhost.com",
"sender": str(admin.id),
}
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == [
{
"id": ANY,
"receiver": "user98@localhost.com",
"sender": str(admin.id),
"org_id": str(org.id),
"message": "Hi! We would like to invite you to our organization.",
"accepted": False,
"disabled": False,
"created_at": ANY,
"modified_at": ANY,
"disabled_at": None,
}
]
invite_id = user_invites.json()[0]["id"]
accept_invite = await client.get(
f"https://localhost/api/v1/invitations/decline/{invite_id}",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert accept_invite.status_code == 204
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == []
async def test_prevent_accepting_when_declined_sent_invitations(
self, client: AsyncClient, create_user_with_org
):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin9612@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
_, _, _, usertokens = await create_user_with_org(email="user11918@localhost.com")
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "user11918@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "user11918@localhost.com",
"sender": str(admin.id),
}
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == [
{
"id": ANY,
"receiver": "user11918@localhost.com",
"sender": str(admin.id),
"org_id": str(org.id),
"message": "Hi! We would like to invite you to our organization.",
"accepted": False,
"disabled": False,
"created_at": ANY,
"modified_at": ANY,
"disabled_at": None,
}
]
invite_id = user_invites.json()[0]["id"]
accept_invite = await client.get(
f"https://localhost/api/v1/invitations/decline/{invite_id}",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert accept_invite.status_code == 204
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == []
accept_invite = await client.get(
f"https://localhost/api/v1/invitations/accept/{invite_id}",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert accept_invite.status_code == 403
assert accept_invite.json() == {
"detail": "The invitation doesn't exist or you don't have access to it."
}
async def test_prevent_declining_when_accepted_sent_invitations(
self, client: AsyncClient, create_user_with_org
):
admin, org, _, admintokens = await create_user_with_org(
email="superadmin9712@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
_, _, _, usertokens = await create_user_with_org(email="user14918@localhost.com")
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "user14918@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 200
assert invite.json() == {
"accepted": False,
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"id": ANY,
"message": "Hi! We would like to invite you to our organization.",
"modified_at": ANY,
"org_id": str(org.id),
"receiver": "user14918@localhost.com",
"sender": str(admin.id),
}
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == [
{
"id": ANY,
"receiver": "user14918@localhost.com",
"sender": str(admin.id),
"org_id": str(org.id),
"message": "Hi! We would like to invite you to our organization.",
"accepted": False,
"disabled": False,
"created_at": ANY,
"modified_at": ANY,
"disabled_at": None,
}
]
invite_id = user_invites.json()[0]["id"]
accept_invite = await client.get(
f"https://localhost/api/v1/invitations/accept/{invite_id}",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert accept_invite.status_code == 204
user_invites = await client.get(
"https://localhost/api/v1/invitations/",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert user_invites.status_code == 200
assert user_invites.json() == []
decline_invite = await client.get(
f"https://localhost/api/v1/invitations/accept/{invite_id}",
headers={"Authorization": f"Bearer {usertokens.access_token}"},
)
assert decline_invite.status_code == 403
assert decline_invite.json() == {
"detail": "The invitation doesn't exist or you don't have access to it."
}
async def test_prevent_adding_user_whom_already_belongs_to_your_organization(
self, client: AsyncClient, create_user_with_org
):
_, org, _, admintokens = await create_user_with_org(
email="us3r123@localhost.com",
username="awesomeadmin",
password="awesomeadmin",
is_admin=True,
)
user, _, _, _ = await create_user_with_org(email="us3r1234@localhost.com")
await Membership.create(
user=user,
organization=org,
acl=await ACL.create(READ=True)
)
invite = await client.post(
"https://localhost/api/v1/invitations/send",
json={
"org_id": str(org.id),
"receiver": "us3r1234@localhost.com",
"acl": None,
"message": "Hi! We would like to invite you to our organization.",
},
headers={"Authorization": f"Bearer {admintokens.access_token}"},
)
assert invite.status_code == 403
assert invite.json() == {
"detail": "The person you've invited is already part of the organization."
}
@@ -1,12 +1,9 @@
from httpx import AsyncClient
from modules.users.models import ACL, Membership
from modules.organizations.models import Organization
from config import settings
from unittest.mock import ANY
from tests.base_test import Test
crypt = settings.CRYPT
class TestOrganizationRoute(Test):
async def test_get_organizations_from_api(
@@ -153,9 +150,7 @@ class TestOrganizationRoute(Test):
READ=True, WRITE=True, REPORT=True, MANAGE=False, ADMIN=False
)
await Membership.create(
user=user, organization=organization, acl=acl
)
await Membership.create(user=user, organization=organization, acl=acl)
deleted_org = await client.delete(
f"https://localhost/api/v1/organizations/{organization.id}",