diff --git a/api/asset_manager/src/config.py b/api/asset_manager/src/config.py index 40b8e6a8..845266a6 100644 --- a/api/asset_manager/src/config.py +++ b/api/asset_manager/src/config.py @@ -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") diff --git a/api/asset_manager/src/database.py b/api/asset_manager/src/database.py index 5213f465..16934f48 100644 --- a/api/asset_manager/src/database.py +++ b/api/asset_manager/src/database.py @@ -8,6 +8,7 @@ modules: dict[str, Any] = { "modules.auth.models", "modules.users.models", "modules.organizations.models", + "modules.invitations.models", ] } diff --git a/api/asset_manager/src/main.py b/api/asset_manager/src/main.py index 4000414b..205bcea4 100644 --- a/api/asset_manager/src/main.py +++ b/api/asset_manager/src/main.py @@ -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) diff --git a/api/asset_manager/src/migrations/models/0_20250625121149_init.py b/api/asset_manager/src/migrations/models/3_20250710123814_None.py similarity index 79% rename from api/asset_manager/src/migrations/models/0_20250625121149_init.py rename to api/asset_manager/src/migrations/models/3_20250710123814_None.py index a1585f4a..6078219b 100644 --- a/api/asset_manager/src/migrations/models/0_20250625121149_init.py +++ b/api/asset_manager/src/migrations/models/3_20250710123814_None.py @@ -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, diff --git a/api/asset_manager/src/mixins/CMDMixin.py b/api/asset_manager/src/mixins/CMDMixin.py deleted file mode 100644 index 1d3b1b11..00000000 --- a/api/asset_manager/src/mixins/CMDMixin.py +++ /dev/null @@ -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) - diff --git a/api/asset_manager/src/modules/auth/models.py b/api/asset_manager/src/modules/auth/models.py index a1c3b0cb..fe5fa3f5 100644 --- a/api/asset_manager/src/modules/auth/models.py +++ b/api/asset_manager/src/modules/auth/models.py @@ -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() - diff --git a/api/asset_manager/src/modules/auth/router.py b/api/asset_manager/src/modules/auth/router.py index f1d80b56..c42b8931 100644 --- a/api/asset_manager/src/modules/auth/router.py +++ b/api/asset_manager/src/modules/auth/router.py @@ -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( diff --git a/api/asset_manager/src/modules/invitations/models.py b/api/asset_manager/src/modules/invitations/models.py new file mode 100644 index 00000000..3e0c5bce --- /dev/null +++ b/api/asset_manager/src/modules/invitations/models.py @@ -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() + diff --git a/api/asset_manager/src/modules/invitations/router.py b/api/asset_manager/src/modules/invitations/router.py new file mode 100644 index 00000000..16c9b8f3 --- /dev/null +++ b/api/asset_manager/src/modules/invitations/router.py @@ -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, + ) diff --git a/api/asset_manager/src/modules/invitations/schemas.py b/api/asset_manager/src/modules/invitations/schemas.py new file mode 100644 index 00000000..432493ea --- /dev/null +++ b/api/asset_manager/src/modules/invitations/schemas.py @@ -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 diff --git a/api/asset_manager/src/modules/organizations/models.py b/api/asset_manager/src/modules/organizations/models.py index 970ea68b..306d6194 100644 --- a/api/asset_manager/src/modules/organizations/models.py +++ b/api/asset_manager/src/modules/organizations/models.py @@ -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() - - diff --git a/api/asset_manager/src/modules/organizations/router.py b/api/asset_manager/src/modules/organizations/router.py index 07922520..ba4be69a 100644 --- a/api/asset_manager/src/modules/organizations/router.py +++ b/api/asset_manager/src/modules/organizations/router.py @@ -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 diff --git a/api/asset_manager/src/modules/users/models.py b/api/asset_manager/src/modules/users/models.py index 6afb9dd5..be2c70dc 100644 --- a/api/asset_manager/src/modules/users/models.py +++ b/api/asset_manager/src/modules/users/models.py @@ -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: diff --git a/api/asset_manager/src/requirements/requirements.txt b/api/asset_manager/src/requirements/requirements.txt index dd831c37..917f1342 100644 --- a/api/asset_manager/src/requirements/requirements.txt +++ b/api/asset_manager/src/requirements/requirements.txt @@ -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 diff --git a/api/asset_manager/src/tests/test_authentication/test_authentication.py b/api/asset_manager/src/tests/test_authentication/test_authentication.py index c8fe4756..96d0b510 100644 --- a/api/asset_manager/src/tests/test_authentication/test_authentication.py +++ b/api/asset_manager/src/tests/test_authentication/test_authentication.py @@ -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 diff --git a/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py b/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py new file mode 100644 index 00000000..f7be41ab --- /dev/null +++ b/api/asset_manager/src/tests/test_invitation_routes/test_invitation_routes.py @@ -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." + } diff --git a/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py b/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py index 13a8335c..64cde71f 100644 --- a/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py +++ b/api/asset_manager/src/tests/test_organizations_routes/test_organization_routes.py @@ -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}",