diff --git a/backend/app/api/docs/onboarding/onboarding.md b/backend/app/api/docs/onboarding/onboarding.md new file mode 100644 index 00000000..75cae3b9 --- /dev/null +++ b/backend/app/api/docs/onboarding/onboarding.md @@ -0,0 +1,29 @@ +# Onboarding API Behavior + +## 🏢 Organization Handling +- If `organization_name` does **not exist**, a new organization will be created. +- If `organization_name` already exists, the request will proceed to create the project under that organization. + +--- + +## 📂 Project Handling +- If `project_name` does **not exist** in the organization, it will be created. +- If the project already exists in the same organization, the API will return **409 Conflict**. + +--- + +## 👤 User Handling +- If `email` does **not exist**, a new user is created and linked to the project. +- If the user already exists, they are simply attached to the project. + +--- + +## 🔑 OpenAI API Key (Optional) +- If provided, the API key will be **encrypted** and stored as project credentials. +- If omitted, the project will be created **without OpenAI credentials**. + +--- + +## 🔄 Transactional Guarantee +The onboarding process is **all-or-nothing**: +- If any step fails (e.g., invalid password), **no organization, project, or user will be persisted**. diff --git a/backend/app/api/routes/onboarding.py b/backend/app/api/routes/onboarding.py index e2f22dc1..48b32e0e 100644 --- a/backend/app/api/routes/onboarding.py +++ b/backend/app/api/routes/onboarding.py @@ -1,145 +1,26 @@ -import re -import secrets +from fastapi import APIRouter, Depends -from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel, EmailStr, model_validator, field_validator -from sqlmodel import Session - -from app.crud import ( - create_organization, - get_organization_by_name, - create_project, - create_user, - create_api_key, - get_api_key_by_project_user, -) -from app.models import ( - OrganizationCreate, - ProjectCreate, - UserCreate, - Project, - User, - APIKey, -) from app.api.deps import ( SessionDep, get_current_active_superuser, ) +from app.crud import onboard_project +from app.models import OnboardingRequest, OnboardingResponse, User +from app.utils import APIResponse, load_description router = APIRouter(tags=["onboarding"]) -# Pydantic models for input validation -class OnboardingRequest(BaseModel): - organization_name: str - project_name: str - email: EmailStr | None = None - password: str | None = None - user_name: str | None = None - - @staticmethod - def _clean_username(raw: str, max_len: int = 200) -> str: - """ - Normalize a string into a safe username that can also be used - as the local part of an email address. - """ - username = re.sub(r"[^A-Za-z0-9._]", "_", raw.strip().lower()) - username = re.sub(r"[._]{2,}", "_", username) # collapse repeats - username = username.strip("._") # remove leading/trailing - return username[:max_len] - - @model_validator(mode="after") - def set_defaults(self): - if self.user_name is None: - self.user_name = self.project_name + " User" - - if self.email is None: - local_part = self._clean_username(self.user_name, max_len=200) - suffix = secrets.token_hex(3) - self.email = f"{local_part}.{suffix}@kaapi.org" - - if self.password is None: - self.password = secrets.token_urlsafe(12) - return self - - -class OnboardingResponse(BaseModel): - organization_id: int - project_id: int - user_id: int - api_key: str - - @router.post( "/onboard", - dependencies=[Depends(get_current_active_superuser)], - response_model=OnboardingResponse, + response_model=APIResponse[OnboardingResponse], + status_code=201, + description=load_description("onboarding/onboarding.md"), ) -def onboard_user(request: OnboardingRequest, session: SessionDep): - """ - Handles quick onboarding of a new user: Accepts Organization name, project name, email, password, and user name, then gives back an API key which - will be further used for authentication. - """ - # Validate organization - existing_organization = get_organization_by_name( - session=session, name=request.organization_name - ) - if existing_organization: - organization = existing_organization - else: - org_create = OrganizationCreate(name=request.organization_name) - organization = create_organization(session=session, org_create=org_create) - - # Validate project - existing_project = ( - session.query(Project).filter(Project.name == request.project_name).first() - ) - if existing_project: - project = existing_project # Use the existing project - else: - project_create = ProjectCreate( - name=request.project_name, organization_id=organization.id - ) - project = create_project(session=session, project_create=project_create) - - # Validate user - existing_user = session.query(User).filter(User.email == request.email).first() - if existing_user: - user = existing_user - else: - user_create = UserCreate( - full_name=request.user_name, - email=request.email, - password=request.password, - ) - user = create_user(session=session, user_create=user_create) - - # Check if API key exists for the user and project - existing_key = get_api_key_by_project_user( - session=session, user_id=user.id, project_id=project.id - ) - if existing_key: - raise HTTPException( - status_code=400, - detail="API key already exists for this user and project.", - ) - - # Create API key - api_key_public = create_api_key( - session=session, - organization_id=organization.id, - user_id=user.id, - project_id=project.id, - ) - - # Set user as non-superuser and save to session - user.is_superuser = False - session.add(user) - session.commit() - - return OnboardingResponse( - organization_id=organization.id, - project_id=project.id, - user_id=user.id, - api_key=api_key_public.key, - ) +def onboard_project_route( + onboard_in: OnboardingRequest, + session: SessionDep, + current_user: User = Depends(get_current_active_superuser), +): + response = onboard_project(session=session, onboard_in=onboard_in) + return APIResponse.success_response(data=response) diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index e4b973a0..35602c94 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -19,12 +19,14 @@ from .project import ( create_project, get_project_by_id, + get_project_by_name, get_projects_by_organization, validate_project, ) from .api_key import ( create_api_key, + generate_api_key, get_api_key, get_api_key_by_value, get_api_keys_by_project, @@ -64,3 +66,5 @@ create_conversation, delete_conversation, ) + +from .onboarding import onboard_project diff --git a/backend/app/crud/onboarding.py b/backend/app/crud/onboarding.py new file mode 100644 index 00000000..8788b083 --- /dev/null +++ b/backend/app/crud/onboarding.py @@ -0,0 +1,134 @@ +import logging +from fastapi import HTTPException +from sqlmodel import Session + +from app.core.security import encrypt_api_key, encrypt_credentials, get_password_hash +from app.crud import ( + generate_api_key, + get_organization_by_name, + get_project_by_name, + get_user_by_email, +) +from app.models import ( + APIKey, + Credential, + OnboardingRequest, + OnboardingResponse, + Organization, + OrganizationCreate, + Project, + ProjectCreate, + User, + UserCreate, +) + +logger = logging.getLogger(__name__) + + +def onboard_project( + session: Session, onboard_in: OnboardingRequest +) -> OnboardingResponse: + """ + Create or link resources for onboarding. + + - Organization: + - Create new if `organization_name` does not exist. + - Otherwise, attach project to existing organization. + + - Project: + - Create if `project_name` does not exist in org. + - If already exists, return 409 Conflict. + + - User: + - Create and link if `email` does not exist. + - If exists, attach to project. + + - OpenAI API Key (optional): + - If provided, encrypted and stored as project credentials. + - If omitted, project is created without OpenAI credentials. + """ + existing_organization = get_organization_by_name( + session=session, name=onboard_in.organization_name + ) + if existing_organization: + organization = existing_organization + else: + org_create = OrganizationCreate(name=onboard_in.organization_name) + organization = Organization.model_validate(org_create) + session.add(organization) + session.flush() + + project = get_project_by_name( + session=session, + project_name=onboard_in.project_name, + organization_id=organization.id, + ) + if project: + raise HTTPException( + status_code=409, + detail=f"Project already exists for organization '{organization.name}'", + ) + + project_create = ProjectCreate( + name=onboard_in.project_name, organization_id=organization.id + ) + project = Project.model_validate(project_create) + session.add(project) + session.flush() + + user = get_user_by_email(session=session, email=onboard_in.email) + if not user: + user_create = UserCreate( + email=onboard_in.email, + full_name=onboard_in.user_name, + password=onboard_in.password, + ) + user = User.model_validate( + user_create, + update={"hashed_password": get_password_hash(user_create.password)}, + ) + session.add(user) + session.flush() + + raw_key, _ = generate_api_key() + encrypted_key = encrypt_api_key(raw_key) + + api_key = APIKey( + key=encrypted_key, # Store the encrypted raw key + organization_id=organization.id, + user_id=user.id, + project_id=project.id, + ) + session.add(api_key) + + credential = None + if onboard_in.openai_api_key: + creds = {"api_key": onboard_in.openai_api_key} + encrypted_credentials = encrypt_credentials(creds) + credential = Credential( + organization_id=organization.id, + project_id=project.id, + is_active=True, + provider="openai", + credential=encrypted_credentials, + ) + session.add(credential) + + session.commit() + + openai_creds_id = credential.id if credential else None + + logger.info( + "[onboard_project] Onboarding completed successfully. " + f"org_id={organization.id}, project_id={project.id}, user_id={user.id}, " + f"openai_creds_id={openai_creds_id}" + ) + return OnboardingResponse( + organization_id=organization.id, + organization_name=organization.name, + project_id=project.id, + project_name=project.name, + user_id=user.id, + user_email=user.email, + api_key=raw_key, + ) diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py index 31bdb845..9c7e7153 100644 --- a/backend/app/crud/project.py +++ b/backend/app/crud/project.py @@ -28,6 +28,15 @@ def get_project_by_id(*, session: Session, project_id: int) -> Optional[Project] return session.exec(statement).first() +def get_project_by_name( + *, session: Session, project_name: str, organization_id: int +) -> Optional[Project]: + statement = select(Project).where( + Project.name == project_name, Project.organization_id == organization_id + ) + return session.exec(statement).first() + + def get_projects_by_organization(*, session: Session, org_id: int) -> List[Project]: statement = select(Project).where(Project.organization_id == org_id) return session.exec(statement).all() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a1c2009c..2f5c8bc6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -62,3 +62,5 @@ OpenAIConversationBase, OpenAIConversationCreate, ) + +from .onboarding import OnboardingRequest, OnboardingResponse diff --git a/backend/app/models/onboarding.py b/backend/app/models/onboarding.py new file mode 100644 index 00000000..3b35896a --- /dev/null +++ b/backend/app/models/onboarding.py @@ -0,0 +1,103 @@ +import re +import secrets +from sqlmodel import SQLModel, Field +from pydantic import EmailStr, model_validator + + +class OnboardingRequest(SQLModel): + """ + Request model for onboarding an organization, project, and user. + + Behavior: + - **organization_name**: Required. If it does not exist, a new organization will be created. + - **project_name**: Required. Must be unique within the organization. + - **user_name**: Optional. If not provided, defaults to ` User`. + - **email**: Optional. If not provided, an email will be auto-generated using + a normalized username + random suffix (e.g., `project_user.ab12cd@kaapi.org`). + - **password**: Optional. If not provided, a secure random password is generated. + - **openai_api_key**: Optional. If provided, it will be encrypted and stored with the project. + + Notes: + - Some users may not need a full user module and only want to interact using an API key. + For those cases, user-related fields are optional and safe defaults are generated automatically. + """ + + organization_name: str = Field( + description="Name of the organization to be created or linked", + min_length=3, + max_length=100, + ) + project_name: str = Field( + description="Name of the project under the organization", + min_length=3, + max_length=100, + ) + email: EmailStr | None = Field( + default=None, + description="Email address of the primary user", + ) + password: str | None = Field( + default=None, + description="Password for the primary user (must be at least 8 characters)", + min_length=8, + max_length=128, + ) + user_name: str | None = Field( + default=None, + description="Full name of the primary user", + min_length=3, + max_length=50, + ) + openai_api_key: str | None = Field( + default=None, + description="Optional OpenAI API key to link with this project", + min_length=20, + max_length=256, + ) + + @staticmethod + def _clean_username(raw: str, max_len: int = 200) -> str: + """ + Normalize a string into a safe username that can also be used + as the local part of an email address. + """ + username = re.sub(r"[^A-Za-z0-9._]", "_", raw.strip().lower()) + username = re.sub(r"[._]{2,}", "_", username) # collapse repeats + username = username.strip("._") # remove leading/trailing + return username[:max_len] + + @model_validator(mode="after") + def set_defaults(self): + if self.user_name is None: + self.user_name = self.project_name + " User" + + if self.email is None: + local_part = self._clean_username(self.user_name, max_len=200) + suffix = secrets.token_hex(3) + self.email = f"{local_part}.{suffix}@kaapi.org" + + if self.password is None: + self.password = secrets.token_urlsafe(12) + return self + + +class OnboardingResponse(SQLModel): + """ + Response model for the Onboarding API. + + Contains the identifiers and credentials created or linked during onboarding. + """ + + organization_id: int = Field(description="Unique ID of the organization") + organization_name: str = Field(description="Name of the organization") + project_id: int = Field( + description="Unique ID of the project within the organization" + ) + project_name: str = Field(description="Name of the project") + user_id: int = Field( + description="Unique ID of the user.", + ) + user_email: EmailStr = Field( + description="Email of the user.", + ) + api_key: str = Field(description="Generated internal API key for the project.") diff --git a/backend/app/tests/api/routes/test_onboarding.py b/backend/app/tests/api/routes/test_onboarding.py index d0f4d85e..e275fd3a 100644 --- a/backend/app/tests/api/routes/test_onboarding.py +++ b/backend/app/tests/api/routes/test_onboarding.py @@ -1,132 +1,160 @@ -import pytest from fastapi.testclient import TestClient -from app.main import app # Assuming your FastAPI app is in app/main.py -from app.models import Organization, Project, User, APIKey -from app.crud import create_organization, create_project, create_user, create_api_key -from app.api.deps import SessionDep -from sqlalchemy import create_engine -from sqlmodel import Session, SQLModel +from sqlmodel import Session + +from app.utils import mask_string from app.core.config import settings from app.tests.utils.utils import random_email, random_lower_string -from app.core.security import decrypt_api_key - -client = TestClient(app) - - -def test_onboard_user(client, db: Session, superuser_token_headers: dict[str, str]): - data = { - "organization_name": "TestOrg", - "project_name": "TestProject", - "email": random_email(), - "password": "testpassword123", - "user_name": "test_user", +from app.tests.utils.test_data import create_test_organization + + +def test_onboard_project_new_organization_project_user( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """Test onboarding with new organization, project, and user.""" + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + email = random_email() + password = random_lower_string() + user_name = "Test User Onboard" + openai_key = f"sk-{random_lower_string()}" + + onboard_data = { + "organization_name": org_name, + "project_name": project_name, + "email": email, + "password": password, + "user_name": user_name, + "openai_api_key": openai_key, } response = client.post( - f"{settings.API_V1_STR}/onboard", json=data, headers=superuser_token_headers + f"{settings.API_V1_STR}/onboard", + json=onboard_data, + headers=superuser_token_headers, ) - assert response.status_code == 200 - + assert response.status_code == 201 response_data = response.json() - assert "organization_id" in response_data - assert "project_id" in response_data - assert "user_id" in response_data - assert "api_key" in response_data - - organization = ( - db.query(Organization) - .filter(Organization.name == data["organization_name"]) - .first() - ) - project = db.query(Project).filter(Project.name == data["project_name"]).first() - user = db.query(User).filter(User.email == data["email"]).first() - api_key = db.query(APIKey).filter(APIKey.user_id == user.id).first() - assert organization is not None - assert project is not None - assert user is not None - assert api_key is not None - - plain_token = response_data["api_key"] - encrypted_stored = api_key.key - - assert decrypt_api_key(encrypted_stored) == plain_token # main check - assert encrypted_stored != plain_token + # Check the response structure + assert "data" in response_data + assert "success" in response_data + assert response_data["success"] is True + + data = response_data["data"] + assert data["organization_name"] == org_name + assert data["project_name"] == project_name + assert data["user_email"] == email + assert "api_key" in data + assert len(data["api_key"]) > 0 + assert "organization_id" in data + assert "project_id" in data + assert "user_id" in data + + +def test_onboard_project_existing_organization( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """Test onboarding with existing organization but new project and user.""" + # Create existing organization + existing_org = create_test_organization(db) + + project_name = "TestProjectOnboard" + email = random_email() + password = random_lower_string() + user_name = "Test User Onboard" + + onboard_data = { + "organization_name": existing_org.name, + "project_name": project_name, + "email": email, + "password": password, + "user_name": user_name, + } - assert user.is_superuser is False + response = client.post( + f"{settings.API_V1_STR}/onboard", + json=onboard_data, + headers=superuser_token_headers, + ) + assert response.status_code == 201 + response_data = response.json() -def test_create_user_existing_email( - client, db: Session, superuser_token_headers: dict[str, str] -): - data = { - "organization_name": "TestOrg", - "project_name": "TestProject", - "email": random_email(), - "password": "testpassword123", - "user_name": "test_user", + data = response_data["data"] + assert data["organization_id"] == existing_org.id + assert data["organization_name"] == existing_org.name + assert data["project_name"] == project_name + assert data["user_email"] == email + + +def test_onboard_project_duplicate_project_in_organization( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """Test onboarding fails when project already exists in the organization.""" + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + email = random_email() + password = random_lower_string() + + onboard_data = { + "organization_name": org_name, + "project_name": project_name, + "email": email, + "password": password, } - client.post( - f"{settings.API_V1_STR}/onboard", json=data, headers=superuser_token_headers - ) - + # First request should succeed response = client.post( - f"{settings.API_V1_STR}/onboard", json=data, headers=superuser_token_headers - ) - assert response.status_code == 400 - assert ( - response.json()["error"] == "API key already exists for this user and project." + f"{settings.API_V1_STR}/onboard", + json=onboard_data, + headers=superuser_token_headers, ) + assert response.status_code == 201 - -def test_is_superuser_flag( - client, db: Session, superuser_token_headers: dict[str, str] -): - data = { - "organization_name": "TestOrg", - "project_name": "TestProjects", - "email": random_email(), - "password": "testpassword123", - "user_name": "test_user", - } + # Second request with same org and project should fail + email2 = random_email() + onboard_data["email"] = email2 response = client.post( - f"{settings.API_V1_STR}/onboard", json=data, headers=superuser_token_headers + f"{settings.API_V1_STR}/onboard", + json=onboard_data, + headers=superuser_token_headers, ) - assert response.status_code == 200 + assert response.status_code == 409 + error_response = response.json() + assert "error" in error_response + assert "Project already exists" in error_response["error"] - response_data = response.json() - user = db.query(User).filter(User.id == response_data["user_id"]).first() - assert user is not None - assert user.is_superuser is False - - -def test_organization_and_project_creation( - client, db: Session, superuser_token_headers: dict[str, str] -): - data = { - "organization_name": "NewOrg", - "project_name": "NewProject", - "email": random_email(), - "password": "newpassword123", - "user_name": "new_user", + +def test_onboard_project_with_auto_generated_defaults( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + """Test onboarding with minimal input using auto-generated defaults.""" + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + + # Only provide required fields + onboard_data = { + "organization_name": org_name, + "project_name": project_name, + # email, password, user_name will be auto-generated } response = client.post( - f"{settings.API_V1_STR}/onboard", json=data, headers=superuser_token_headers + f"{settings.API_V1_STR}/onboard", + json=onboard_data, + headers=superuser_token_headers, ) - assert response.status_code == 200 - - organization = ( - db.query(Organization) - .filter(Organization.name == data["organization_name"]) - .first() - ) - project = db.query(Project).filter(Project.name == data["project_name"]).first() + assert response.status_code == 201 + response_data = response.json() - assert organization is not None - assert project is not None + data = response_data["data"] + assert data["organization_name"] == org_name + assert data["project_name"] == project_name + assert data["user_email"] is not None + assert "@kaapi.org" in data["user_email"] + assert "api_key" in data + assert len(data["api_key"]) > 0 diff --git a/backend/app/tests/crud/test_onboarding.py b/backend/app/tests/crud/test_onboarding.py new file mode 100644 index 00000000..3ff64bbf --- /dev/null +++ b/backend/app/tests/crud/test_onboarding.py @@ -0,0 +1,264 @@ +import pytest +from fastapi import HTTPException +from sqlmodel import Session, select + +from app.crud.onboarding import onboard_project +from app.crud import ( + get_organization_by_name, + get_project_by_name, + get_user_by_email, + get_organization_by_id, +) +from app.models import ( + OnboardingRequest, + OnboardingResponse, + Organization, + Project, + User, + APIKey, + Credential, +) +from app.tests.utils.utils import random_lower_string, random_email +from app.tests.utils.test_data import create_test_organization, create_test_project +from app.tests.utils.user import create_random_user + + +def test_onboard_project_new_organization_project_user(db: Session) -> None: + """Test onboarding with new organization, project, and user.""" + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + email = random_email() + password = random_lower_string() + user_name = "Test User Onboard" + openai_key = f"sk-{random_lower_string()}" + + onboard_request = OnboardingRequest( + organization_name=org_name, + project_name=project_name, + email=email, + password=password, + user_name=user_name, + openai_api_key=openai_key, + ) + + response = onboard_project(session=db, onboard_in=onboard_request) + + assert isinstance(response, OnboardingResponse) + assert response.organization_name == org_name + assert response.project_name == project_name + assert response.user_email == email + assert response.api_key is not None + assert len(response.api_key) > 0 + + org = get_organization_by_name(session=db, name=org_name) + assert org is not None + assert org.id == response.organization_id + assert org.name == org_name + + project = get_project_by_name( + session=db, project_name=project_name, organization_id=org.id + ) + assert project is not None + assert project.id == response.project_id + assert project.name == project_name + assert project.organization_id == org.id + + user = get_user_by_email(session=db, email=email) + assert user is not None + assert user.id == response.user_id + assert user.email == email + assert user.full_name == user_name + + api_key = db.exec( + select(APIKey).where( + APIKey.user_id == user.id, + APIKey.project_id == project.id, + APIKey.organization_id == org.id, + ) + ).first() + + assert api_key is not None + + credential = db.exec( + select(Credential).where( + Credential.organization_id == org.id, + Credential.project_id == project.id, + Credential.provider == "openai", + Credential.is_active.is_(True), + ) + ).first() + assert credential is not None + + +def test_onboard_project_existing_organization(db: Session) -> None: + """Test onboarding with existing organization but new project and user.""" + # Create existing organization + existing_org = create_test_organization(db) + + project_name = "TestProjectOnboard" + email = random_email() + password = random_lower_string() + user_name = "Test User Onboard" + + onboard_request = OnboardingRequest( + organization_name=existing_org.name, + project_name=project_name, + email=email, + password=password, + user_name=user_name, + ) + + response = onboard_project(session=db, onboard_in=onboard_request) + + assert response.organization_id == existing_org.id + assert response.organization_name == existing_org.name + + project = get_project_by_name( + session=db, project_name=project_name, organization_id=existing_org.id + ) + assert project is not None + assert project.organization_id == existing_org.id + + +def test_onboard_project_existing_user(db: Session) -> None: + """Test onboarding with existing user but new organization and project.""" + + existing_user = create_random_user(db) + + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + + onboard_request = OnboardingRequest( + organization_name=org_name, + project_name=project_name, + email=existing_user.email, + password=random_lower_string(), # This should be ignored for existing user + user_name="New Name", # This should be ignored for existing user + ) + + response = onboard_project(session=db, onboard_in=onboard_request) + + # Assert user was reused + assert response.user_id == existing_user.id + assert response.user_email == existing_user.email + + # Verify new organization and project were created + org = get_organization_by_name(session=db, name=org_name) + assert org is not None + project = get_project_by_name( + session=db, project_name=project_name, organization_id=org.id + ) + assert project is not None + + +def test_onboard_project_duplicate_project_name(db: Session) -> None: + """Test that onboarding fails when project name already exists in organization.""" + # Create existing project + existing_project = create_test_project(db) + + org = get_organization_by_id(session=db, org_id=existing_project.organization_id) + email = random_email() + password = random_lower_string() + + onboard_request = OnboardingRequest( + organization_name=org.name, + project_name=existing_project.name, + email=email, + password=password, + ) + + with pytest.raises(HTTPException) as exc_info: + onboard_project(session=db, onboard_in=onboard_request) + + assert exc_info.value.status_code == 409 + assert "Project already exists" in str(exc_info.value.detail) + + +def test_onboard_project_with_auto_generated_defaults(db: Session) -> None: + """Test onboarding with minimal input using auto-generated defaults.""" + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + + # Only provide required fields + onboard_request = OnboardingRequest( + organization_name=org_name, + project_name=project_name, + # email, password, user_name will be auto-generated + ) + + response = onboard_project(session=db, onboard_in=onboard_request) + + assert response.user_email is not None + assert "@kaapi.org" in response.user_email + + user = get_user_by_email(session=db, email=response.user_email) + assert user is not None + assert user.full_name == f"{project_name} User" + + +def test_onboard_project_api_key_generation(db: Session) -> None: + """Test that API key is properly generated and encrypted.""" + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + email = random_email() + + onboard_request = OnboardingRequest( + organization_name=org_name, + project_name=project_name, + email=email, + ) + + response = onboard_project(session=db, onboard_in=onboard_request) + + assert response.api_key is not None + assert len(response.api_key) > 10 # Should be a reasonable length + + # Verify API key record exists in database + user = get_user_by_email(session=db, email=email) + org = get_organization_by_name(session=db, name=org_name) + project = get_project_by_name( + session=db, project_name=project_name, organization_id=org.id + ) + + api_key_record = db.exec( + select(APIKey).where( + APIKey.user_id == user.id, + APIKey.project_id == project.id, + APIKey.organization_id == org.id, + APIKey.is_deleted.is_(False), + ) + ).first() + assert api_key_record is not None + assert api_key_record.key != response.api_key + + +def test_onboard_project_response_data_integrity(db: Session) -> None: + """Test that all response data matches what was created in the database.""" + org_name = "TestOrgOnboard" + project_name = "TestProjectOnboard" + email = random_email() + password = random_lower_string() + user_name = "Test User Onboard" + openai_key = f"sk-{random_lower_string()}" + + onboard_request = OnboardingRequest( + organization_name=org_name, + project_name=project_name, + email=email, + password=password, + user_name=user_name, + openai_api_key=openai_key, + ) + + response = onboard_project(session=db, onboard_in=onboard_request) + + # Fetch actual records from database + org = db.get(Organization, response.organization_id) + project = db.get(Project, response.project_id) + user = db.get(User, response.user_id) + + # Verify all response data matches database records + assert org.name == response.organization_name + assert project.name == response.project_name + assert project.organization_id == response.organization_id + assert user.email == response.user_email diff --git a/backend/app/tests/crud/test_project.py b/backend/app/tests/crud/test_project.py index 3f15a898..06aa38ac 100644 --- a/backend/app/tests/crud/test_project.py +++ b/backend/app/tests/crud/test_project.py @@ -6,6 +6,7 @@ from app.crud.project import ( create_project, get_project_by_id, + get_project_by_name, get_projects_by_organization, validate_project, ) @@ -43,6 +44,31 @@ def test_get_project_by_id(db: Session) -> None: assert fetched_project.name == project.name +def test_get_project_by_name(db: Session) -> None: + """Test retrieving a project by name and organization ID.""" + project = create_test_project(db) + + fetched_project = get_project_by_name( + session=db, project_name=project.name, organization_id=project.organization_id + ) + assert fetched_project is not None + assert fetched_project.id == project.id + assert fetched_project.name == project.name + assert fetched_project.organization_id == project.organization_id + + +def test_get_project_by_name_not_found(db: Session) -> None: + """Test retrieving a project by name when it doesn't exist.""" + organization = create_test_organization(db) + + # Try to get a project that doesn't exist + non_existent_name = f"non-existent-{random_lower_string()}" + fetched_project = get_project_by_name( + session=db, project_name=non_existent_name, organization_id=organization.id + ) + assert fetched_project is None + + def test_get_projects_by_organization(db: Session) -> None: """Test retrieving all projects for an organization.""" organization = create_test_organization(db)