From 74d5cfcb5b3409d4fe5657e78c49235eb33ed9bf Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:40:09 +0530 Subject: [PATCH 01/16] config routes updated --- backend/app/api/routes/config/config.py | 3 ++- backend/app/crud/config/config.py | 17 ++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/app/api/routes/config/config.py b/backend/app/api/routes/config/config.py index 6d2629442..422bc61df 100644 --- a/backend/app/api/routes/config/config.py +++ b/backend/app/api/routes/config/config.py @@ -53,6 +53,7 @@ def create_config( def list_configs( current_user: AuthContextDep, session: SessionDep, + query: str = Query(description='search query'), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), ): @@ -61,7 +62,7 @@ def list_configs( Ordered by updated_at in descending order. """ config_crud = ConfigCrud(session=session, project_id=current_user.project_.id) - configs = config_crud.read_all(skip=skip, limit=limit) + configs = config_crud.read_all(query=query, skip=skip, limit=limit) return APIResponse.success_response( data=configs, ) diff --git a/backend/app/crud/config/config.py b/backend/app/crud/config/config.py index 0a2ed2138..33fa7bcf1 100644 --- a/backend/app/crud/config/config.py +++ b/backend/app/crud/config/config.py @@ -84,16 +84,19 @@ def read_one(self, config_id: UUID) -> Config | None: ) ) return self.session.exec(statement).one_or_none() + + def read_all(self, query: str | None, skip: int = 0, limit: int = 100) -> list[Config]: + filters = [ + Config.project_id == self.project_id, + Config.deleted_at.is_(None), + ] + + if query: + filters.append(Config.name.ilike(f"{query}%")) - def read_all(self, skip: int = 0, limit: int = 100) -> list[Config]: statement = ( select(Config) - .where( - and_( - Config.project_id == self.project_id, - Config.deleted_at.is_(None), - ) - ) + .where(and_(*filters)) .order_by(Config.updated_at.desc()) .offset(skip) .limit(limit) From bd48eb81cfc4b7ab2a235a246fd5e2aeb8d8a40d Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:44:05 +0530 Subject: [PATCH 02/16] code update --- backend/app/api/docs/config/list.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/app/api/docs/config/list.md b/backend/app/api/docs/config/list.md index c4cc3769b..dc057aa8d 100644 --- a/backend/app/api/docs/config/list.md +++ b/backend/app/api/docs/config/list.md @@ -1,3 +1,7 @@ +query: Search string used for partial matching across configurations +skip: Number of records to skip for pagination (default: 0) +limit: Maximum number of records to return (default: 100, max: 100) + Retrieve all configurations for the current project. Returns a paginated list of configurations ordered by most recently From 10fd7c3e29ffd9a9d43f9353529d78e09178a387 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:45:02 +0530 Subject: [PATCH 03/16] made query optional --- backend/app/api/routes/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/routes/config/config.py b/backend/app/api/routes/config/config.py index 422bc61df..f954c1c1d 100644 --- a/backend/app/api/routes/config/config.py +++ b/backend/app/api/routes/config/config.py @@ -53,7 +53,7 @@ def create_config( def list_configs( current_user: AuthContextDep, session: SessionDep, - query: str = Query(description='search query'), + query: str = Query(None, description='search query'), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), ): From f73c22a350079b1714f990e57e79a6b678de9715 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:46:16 +0530 Subject: [PATCH 04/16] code updated --- backend/app/api/routes/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/routes/config/config.py b/backend/app/api/routes/config/config.py index f954c1c1d..9296c1dd6 100644 --- a/backend/app/api/routes/config/config.py +++ b/backend/app/api/routes/config/config.py @@ -53,7 +53,7 @@ def create_config( def list_configs( current_user: AuthContextDep, session: SessionDep, - query: str = Query(None, description='search query'), + query: str | None = Query(None, description='search query'), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), ): From 0c7175b8b6e7c67115613538135f5160ad8b45ba Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:52:17 +0530 Subject: [PATCH 05/16] added has_more functionality --- backend/app/api/routes/config/config.py | 3 ++- backend/app/api/routes/documents.py | 4 ++-- backend/app/crud/config/config.py | 12 +++++++++--- backend/app/crud/document/document.py | 13 ++++++++++--- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/backend/app/api/routes/config/config.py b/backend/app/api/routes/config/config.py index 9296c1dd6..ab78305d2 100644 --- a/backend/app/api/routes/config/config.py +++ b/backend/app/api/routes/config/config.py @@ -62,9 +62,10 @@ def list_configs( Ordered by updated_at in descending order. """ config_crud = ConfigCrud(session=session, project_id=current_user.project_.id) - configs = config_crud.read_all(query=query, skip=skip, limit=limit) + configs, has_more = config_crud.read_all(query=query, skip=skip, limit=limit) return APIResponse.success_response( data=configs, + metadata=dict(has_more=has_more) ) diff --git a/backend/app/api/routes/documents.py b/backend/app/api/routes/documents.py index 69c5b4895..28c6087ab 100644 --- a/backend/app/api/routes/documents.py +++ b/backend/app/api/routes/documents.py @@ -81,7 +81,7 @@ def list_docs( ), ): crud = DocumentCrud(session, current_user.project_.id) - documents = crud.read_many(skip, limit) + documents, has_more = crud.read_many(skip, limit) storage = ( get_cloud_storage(session=session, project_id=current_user.project_.id) @@ -94,7 +94,7 @@ def list_docs( include_url=include_url, storage=storage, ) - return APIResponse.success_response(results) + return APIResponse.success_response(results, metadata=dict(has_more=has_more)) @router.post( diff --git a/backend/app/crud/config/config.py b/backend/app/crud/config/config.py index 33fa7bcf1..fa0e2540b 100644 --- a/backend/app/crud/config/config.py +++ b/backend/app/crud/config/config.py @@ -85,7 +85,7 @@ def read_one(self, config_id: UUID) -> Config | None: ) return self.session.exec(statement).one_or_none() - def read_all(self, query: str | None, skip: int = 0, limit: int = 100) -> list[Config]: + def read_all(self, query: str | None, skip: int = 0, limit: int = 100) -> tuple[list[Config], bool]: filters = [ Config.project_id == self.project_id, Config.deleted_at.is_(None), @@ -99,9 +99,15 @@ def read_all(self, query: str | None, skip: int = 0, limit: int = 100) -> list[C .where(and_(*filters)) .order_by(Config.updated_at.desc()) .offset(skip) - .limit(limit) + .limit(limit + 1) ) - return self.session.exec(statement).all() + configs = self.session.exec(statement).all() + has_more = False + if limit is not None and len(configs) > limit: + has_more = True + configs = configs[:limit] + + return configs, has_more def update_or_raise(self, config_id: UUID, config_update: ConfigUpdate) -> Config: config = self.exists_or_raise(config_id) diff --git a/backend/app/crud/document/document.py b/backend/app/crud/document/document.py index 1443e385a..35e4d86fb 100644 --- a/backend/app/crud/document/document.py +++ b/backend/app/crud/document/document.py @@ -37,10 +37,11 @@ def read_many( self, skip: int | None = None, limit: int | None = None, - ) -> list[Document]: + ) -> tuple[list[Document], bool]: statement = select(Document).where( and_(Document.project_id == self.project_id, Document.is_deleted.is_(False)) ) + statement = statement.order_by(Document.inserted_at.desc()) if skip is not None: if skip < 0: @@ -64,10 +65,16 @@ def read_many( exc_info=True, ) raise - statement = statement.limit(limit) + statement = statement.limit(limit + 1) documents = self.session.exec(statement).all() - return documents + + has_more = False + if limit is not None and len(documents) > limit: + has_more = True + documents = documents[:limit] + + return documents, has_more def read_each(self, doc_ids: list[UUID]): statement = select(Document).where( From a363f456d7d1c873b619d3ebfb941baa174283b7 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:40:50 +0530 Subject: [PATCH 06/16] refactor: update config CRUD methods to include query parameter and has_more functionality --- backend/app/api/routes/config/config.py | 9 ++--- backend/app/crud/config/config.py | 8 ++-- .../tests/api/routes/configs/test_config.py | 26 +++++++++++++ backend/app/tests/crud/config/test_config.py | 37 ++++++++++++++++--- .../documents/test_crud_document_read_many.py | 19 ++++++---- .../documents/test_crud_document_update.py | 4 +- 6 files changed, 78 insertions(+), 25 deletions(-) diff --git a/backend/app/api/routes/config/config.py b/backend/app/api/routes/config/config.py index ab78305d2..c08cdba3c 100644 --- a/backend/app/api/routes/config/config.py +++ b/backend/app/api/routes/config/config.py @@ -53,20 +53,17 @@ def create_config( def list_configs( current_user: AuthContextDep, session: SessionDep, - query: str | None = Query(None, description='search query'), + query: str | None = Query(None, description="search query"), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(100, ge=1, le=100, description="Maximum records to return"), -): +) -> APIResponse[list[ConfigPublic]]: """ List all configurations for the current project. Ordered by updated_at in descending order. """ config_crud = ConfigCrud(session=session, project_id=current_user.project_.id) configs, has_more = config_crud.read_all(query=query, skip=skip, limit=limit) - return APIResponse.success_response( - data=configs, - metadata=dict(has_more=has_more) - ) + return APIResponse.success_response(data=configs, metadata=dict(has_more=has_more)) @router.get( diff --git a/backend/app/crud/config/config.py b/backend/app/crud/config/config.py index fa0e2540b..ea1e849d6 100644 --- a/backend/app/crud/config/config.py +++ b/backend/app/crud/config/config.py @@ -84,8 +84,10 @@ def read_one(self, config_id: UUID) -> Config | None: ) ) return self.session.exec(statement).one_or_none() - - def read_all(self, query: str | None, skip: int = 0, limit: int = 100) -> tuple[list[Config], bool]: + + def read_all( + self, query: str | None, skip: int = 0, limit: int = 100 + ) -> tuple[list[Config], bool]: filters = [ Config.project_id == self.project_id, Config.deleted_at.is_(None), @@ -101,7 +103,7 @@ def read_all(self, query: str | None, skip: int = 0, limit: int = 100) -> tuple[ .offset(skip) .limit(limit + 1) ) - configs = self.session.exec(statement).all() + configs = self.session.exec(statement).all() has_more = False if limit is not None and len(configs) > limit: has_more = True diff --git a/backend/app/tests/api/routes/configs/test_config.py b/backend/app/tests/api/routes/configs/test_config.py index 45173aaa2..c5f14f2b3 100644 --- a/backend/app/tests/api/routes/configs/test_config.py +++ b/backend/app/tests/api/routes/configs/test_config.py @@ -141,6 +141,7 @@ def test_list_configs( assert data["success"] is True assert isinstance(data["data"], list) assert len(data["data"]) >= 3 + assert "has_more" in data["metadata"] config_names = [c["name"] for c in data["data"]] for config in created_configs: @@ -453,3 +454,28 @@ def test_configs_isolated_by_project( # Verify we only get configs from user's project for config_data in data["data"]: assert config_data["project_id"] == user_api_key.project_id + + +def test_list_configs_with_query( + db: Session, + client: TestClient, + user_api_key: TestAuthContext, +) -> None: + """Test listing configs filtered by query param.""" + create_test_config(db=db, project_id=user_api_key.project_id, name="search-alpha") + create_test_config(db=db, project_id=user_api_key.project_id, name="search-beta") + create_test_config(db=db, project_id=user_api_key.project_id, name="other-config") + + response = client.get( + f"{settings.API_V1_STR}/configs/", + headers={"X-API-KEY": user_api_key.key}, + params={"query": "search"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + returned_names = [c["name"] for c in data["data"]] + assert "search-alpha" in returned_names + assert "search-beta" in returned_names + assert all("search" in name for name in returned_names) + assert "other-config" not in returned_names diff --git a/backend/app/tests/crud/config/test_config.py b/backend/app/tests/crud/config/test_config.py index 6fc9c7f19..3f83f8a42 100644 --- a/backend/app/tests/crud/config/test_config.py +++ b/backend/app/tests/crud/config/test_config.py @@ -177,7 +177,7 @@ def test_read_all_configs(db: Session) -> None: config3 = create_test_config(db, project_id=project.id, name="config-3") config_crud = ConfigCrud(session=db, project_id=project.id) - configs = config_crud.read_all() + configs, has_more = config_crud.read_all(query=None) config_ids = [c.id for c in configs] assert config1.id in config_ids @@ -196,10 +196,11 @@ def test_read_all_configs_pagination(db: Session) -> None: config_crud = ConfigCrud(session=db, project_id=project.id) # Test skip and limit - configs_page1 = config_crud.read_all(skip=0, limit=2) - configs_page2 = config_crud.read_all(skip=2, limit=2) + configs_page1, has_more = config_crud.read_all(query=None, skip=0, limit=2) + configs_page2, _ = config_crud.read_all(query=None, skip=2, limit=2) assert len(configs_page1) == 2 + assert has_more is True assert len(configs_page2) == 2 assert configs_page1[0].id != configs_page2[0].id @@ -219,7 +220,7 @@ def test_read_all_configs_ordered_by_updated_at(db: Session) -> None: config1.id, ConfigUpdate(description="Updated description") ) - configs = config_crud.read_all() + configs, _ = config_crud.read_all(query=None) # config1 should be first because it was most recently updated assert configs[0].id == config1.id @@ -237,7 +238,7 @@ def test_read_all_configs_excludes_deleted(db: Session) -> None: # Delete config1 config_crud.delete_or_raise(config1.id) - configs = config_crud.read_all() + configs, _ = config_crud.read_all(query=None) config_ids = [c.id for c in configs] assert config1.id not in config_ids @@ -253,13 +254,37 @@ def test_read_all_configs_different_projects(db: Session) -> None: config2 = create_test_config(db, project_id=project2.id, name="config-2") config_crud = ConfigCrud(session=db, project_id=project1.id) - configs = config_crud.read_all() + configs, _ = config_crud.read_all(query=None) config_ids = [c.id for c in configs] assert config1.id in config_ids assert config2.id not in config_ids +def test_read_all_configs_query_filter(db: Session) -> None: + """Test that query param filters configs by name prefix.""" + project = create_test_project(db) + create_test_config(db, project_id=project.id, name="alpha-config") + create_test_config(db, project_id=project.id, name="beta-config") + + config_crud = ConfigCrud(session=db, project_id=project.id) + configs, _ = config_crud.read_all(query="alpha") + + assert len(configs) == 1 + assert configs[0].name == "alpha-config" + + +def test_read_all_configs_has_more_false(db: Session) -> None: + """Test has_more is False when results fit within limit.""" + project = create_test_project(db) + create_test_config(db, project_id=project.id, name="only-config") + + config_crud = ConfigCrud(session=db, project_id=project.id) + _, has_more = config_crud.read_all(query=None, limit=10) + + assert has_more is False + + def test_update_config_name(db: Session) -> None: """Test updating a configuration's name.""" config = create_test_config(db) diff --git a/backend/app/tests/crud/documents/documents/test_crud_document_read_many.py b/backend/app/tests/crud/documents/documents/test_crud_document_read_many.py index 867cac754..a193f84bb 100644 --- a/backend/app/tests/crud/documents/documents/test_crud_document_read_many.py +++ b/backend/app/tests/crud/documents/documents/test_crud_document_read_many.py @@ -24,7 +24,7 @@ def test_number_read_is_expected( store: DocumentStore, ) -> None: crud = DocumentCrud(db, store.project.id) - docs = crud.read_many() + docs, _ = crud.read_many() assert len(docs) == self._ndocs def test_deleted_docs_are_excluded( @@ -33,7 +33,8 @@ def test_deleted_docs_are_excluded( store: DocumentStore, ) -> None: crud = DocumentCrud(db, store.project.id) - assert all(x.is_deleted is False for x in crud.read_many()) + docs, _ = crud.read_many() + assert all(x.is_deleted is False for x in docs) def test_skip_is_respected( self, @@ -42,7 +43,7 @@ def test_skip_is_respected( ) -> None: crud = DocumentCrud(db, store.project.id) skip = self._ndocs // 2 - docs = crud.read_many(skip=skip) + docs, _ = crud.read_many(skip=skip) assert len(docs) == self._ndocs - skip @@ -52,7 +53,7 @@ def test_zero_skip_includes_all( store: DocumentStore, ) -> None: crud = DocumentCrud(db, store.project.id) - docs = crud.read_many(skip=0) + docs, _ = crud.read_many(skip=0) assert len(docs) == self._ndocs def test_big_skip_is_empty( @@ -62,7 +63,8 @@ def test_big_skip_is_empty( ) -> None: crud = DocumentCrud(db, store.project.id) skip = self._ndocs + 1 - assert not crud.read_many(skip=skip) + docs, _ = crud.read_many(skip=skip) + assert not docs def test_negative_skip_raises_exception( self, @@ -80,7 +82,7 @@ def test_limit_is_respected( ) -> None: crud = DocumentCrud(db, store.project.id) limit = self._ndocs // 2 - docs = crud.read_many(limit=limit) + docs, _ = crud.read_many(limit=limit) assert len(docs) == limit @@ -90,7 +92,8 @@ def test_zero_limit_includes_nothing( store: DocumentStore, ) -> None: crud = DocumentCrud(db, store.project.id) - assert not crud.read_many(limit=0) + docs, _ = crud.read_many(limit=0) + assert not docs def test_negative_limit_raises_exception( self, @@ -109,6 +112,6 @@ def test_skip_greater_than_limit_is_difference( crud = DocumentCrud(db, store.project.id) limit = self._ndocs skip = limit // 2 - docs = crud.read_many(skip=skip, limit=limit) + docs, _ = crud.read_many(skip=skip, limit=limit) assert len(docs) == limit - skip diff --git a/backend/app/tests/crud/documents/documents/test_crud_document_update.py b/backend/app/tests/crud/documents/documents/test_crud_document_update.py index 7030543a0..1b63104d1 100644 --- a/backend/app/tests/crud/documents/documents/test_crud_document_update.py +++ b/backend/app/tests/crud/documents/documents/test_crud_document_update.py @@ -18,9 +18,9 @@ class TestDatabaseUpdate: def test_update_adds_one(self, db: Session, documents: DocumentMaker) -> None: crud = DocumentCrud(db, documents.project_id) - before = crud.read_many() + before, _ = crud.read_many() crud.update(next(documents)) - after = crud.read_many() + after, _ = crud.read_many() assert len(before) + 1 == len(after) From abd1fc8507ccdefd5bb53ba6ef3fcc41a2e00201 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:40:56 +0530 Subject: [PATCH 07/16] Add projects-by-org endpoint and pagination for organizations list - Add GET /projects/organization/{org_id} endpoint with org validation - Add has_more pagination to organizations list endpoint - Add Swagger docs for list_by_org --- backend/app/api/docs/organization/list.md | 2 +- backend/app/api/docs/projects/list_by_org.md | 3 +++ backend/app/api/routes/organization.py | 11 ++++++++--- backend/app/api/routes/project.py | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/docs/projects/list_by_org.md diff --git a/backend/app/api/docs/organization/list.md b/backend/app/api/docs/organization/list.md index 95943bab2..0d128ac8e 100644 --- a/backend/app/api/docs/organization/list.md +++ b/backend/app/api/docs/organization/list.md @@ -1,3 +1,3 @@ List all organizations. -Returns paginated list of all organizations in the system. +Returns paginated list of all organizations in the system. The response includes a `has_more` field in `metadata` indicating whether additional pages are available. diff --git a/backend/app/api/docs/projects/list_by_org.md b/backend/app/api/docs/projects/list_by_org.md new file mode 100644 index 000000000..b227312d0 --- /dev/null +++ b/backend/app/api/docs/projects/list_by_org.md @@ -0,0 +1,3 @@ +List all projects for a given organization. + +Returns all projects belonging to the specified organization ID. The organization must exist and be active. diff --git a/backend/app/api/routes/organization.py b/backend/app/api/routes/organization.py index eb853921b..ca9239ab6 100644 --- a/backend/app/api/routes/organization.py +++ b/backend/app/api/routes/organization.py @@ -1,7 +1,7 @@ import logging from typing import List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func from sqlmodel import select @@ -27,14 +27,19 @@ response_model=APIResponse[List[OrganizationPublic]], description=load_description("organization/list.md"), ) -def read_organizations(session: SessionDep, skip: int = 0, limit: int = 100): +def read_organizations( + session: SessionDep, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), +): count_statement = select(func.count()).select_from(Organization) count = session.exec(count_statement).one() statement = select(Organization).offset(skip).limit(limit) organizations = session.exec(statement).all() - return APIResponse.success_response(organizations) + has_more = (skip + limit) < count + return APIResponse.success_response(organizations, metadata={"has_more": has_more}) # Create a new organization diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index 8a114930d..1d2a8d24e 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -11,7 +11,9 @@ from app.crud.project import ( create_project, get_project_by_id, + get_projects_by_organization, ) +from app.crud.organization import validate_organization from app.utils import APIResponse, load_description logger = logging.getLogger(__name__) @@ -112,3 +114,16 @@ def delete_project(session: SessionDep, project_id: int): f"[delete_project] Project deleted successfully | project_id={project_id}" ) return APIResponse.success_response(None) + + +# Get projects by organization +@router.get( + "/organization/{org_id}", + dependencies=[Depends(require_permission(Permission.SUPERUSER))], + response_model=APIResponse[List[ProjectPublic]], + description=load_description("projects/list_by_org.md"), +) +def read_projects_by_organization(session: SessionDep, org_id: int): + validate_organization(session=session, org_id=org_id) + projects = get_projects_by_organization(session=session, org_id=org_id) + return APIResponse.success_response(projects) From 84ce2e8cc077977faf69cf38e23c47e40cfa1575 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:40:10 +0530 Subject: [PATCH 08/16] Enhance organization validation: return 503 status code for inactive organizations; update read_projects_by_organization response type --- backend/app/api/routes/project.py | 4 +++- backend/app/crud/organization.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index 1d2a8d24e..8490a0a42 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -123,7 +123,9 @@ def delete_project(session: SessionDep, project_id: int): response_model=APIResponse[List[ProjectPublic]], description=load_description("projects/list_by_org.md"), ) -def read_projects_by_organization(session: SessionDep, org_id: int): +def read_projects_by_organization( + session: SessionDep, org_id: int +) -> APIResponse[List[ProjectPublic]]: validate_organization(session=session, org_id=org_id) projects = get_projects_by_organization(session=session, org_id=org_id) return APIResponse.success_response(projects) diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py index a95c843b6..e42d3da9f 100644 --- a/backend/app/crud/organization.py +++ b/backend/app/crud/organization.py @@ -52,6 +52,6 @@ def validate_organization(session: Session, org_id: int) -> Organization: logger.error( f"[validate_organization] Organization is not active | 'org_id': {org_id}" ) - raise HTTPException("Organization is not active") + raise HTTPException(status_code=503, detail="Organization is not active") return organization From 6144d98429ebff3725968e9c46133fb818b94b20 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:21:03 +0530 Subject: [PATCH 09/16] feat(*): google integration flow --- backend/app/api/deps.py | 117 ++++++--- backend/app/api/docs/auth/google.md | 22 ++ backend/app/api/main.py | 2 + backend/app/api/routes/google_auth.py | 362 ++++++++++++++++++++++++++ backend/app/core/config.py | 5 + backend/app/core/security.py | 42 ++- backend/app/models/__init__.py | 11 +- backend/app/models/auth.py | 23 +- backend/app/models/user.py | 11 + backend/pyproject.toml | 1 + backend/uv.lock | 6 +- 11 files changed, 553 insertions(+), 49 deletions(-) create mode 100644 backend/app/api/docs/auth/google.md create mode 100644 backend/app/api/routes/google_auth.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 9f2c81a62..6370aa206 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -4,17 +4,20 @@ import jwt from fastapi import Depends, HTTPException, Request, status from fastapi.security import APIKeyHeader, OAuth2PasswordBearer -from jwt.exceptions import InvalidTokenError +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from pydantic import ValidationError -from sqlmodel import Session, select +from sqlmodel import Session from app.core import security from app.core.config import settings from app.core.db import engine from app.core.security import api_key_manager from app.crud.organization import validate_organization +from app.crud.project import validate_project from app.models import ( AuthContext, + Organization, + Project, TokenPayload, User, ) @@ -35,57 +38,89 @@ def get_db() -> Generator[Session, None, None]: TokenDep = Annotated[str, Depends(reusable_oauth2)] +def _authenticate_with_jwt(session: Session, token: str) -> AuthContext: + """Validate a JWT token and return the authenticated user context.""" + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + ) + except (InvalidTokenError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + + # Reject refresh tokens — they should only be used at /auth/refresh + if token_data.type == "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh tokens cannot be used for API access", + ) + + user = session.get(User, token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=403, detail="Inactive user") + + organization: Organization | None = None + project: Project | None = None + + if token_data.org_id: + organization = validate_organization(session=session, org_id=token_data.org_id) + if token_data.project_id: + project = validate_project(session=session, project_id=token_data.project_id) + + return AuthContext(user=user, organization=organization, project=project) + + def get_auth_context( + request: Request, session: SessionDep, token: TokenDep, api_key: Annotated[str, Depends(api_key_header)], ) -> AuthContext: """ - Verify valid authentication (API Key or JWT token) and return authenticated user context. + Verify valid authentication (API Key, JWT token, or cookie) and return authenticated user context. Returns AuthContext with user info, project_id, and organization_id. Authorization logic should be handled in routes. + + Authentication priority: + 1. X-API-KEY header + 2. Authorization: Bearer header + 3. access_token cookie """ + # 1. Try X-API-KEY header if api_key: auth_context = api_key_manager.verify(session, api_key) - if not auth_context: - raise HTTPException(status_code=401, detail="Invalid API Key") - - if not auth_context.user.is_active: - raise HTTPException(status_code=403, detail="Inactive user") - - if not auth_context.organization.is_active: - raise HTTPException(status_code=403, detail="Inactive Organization") - - if not auth_context.project.is_active: - raise HTTPException(status_code=403, detail="Inactive Project") - - return auth_context - - elif token: - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - token_data = TokenPayload(**payload) - except (InvalidTokenError, ValidationError): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Could not validate credentials", - ) - - user = session.get(User, token_data.sub) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if not user.is_active: - raise HTTPException(status_code=403, detail="Inactive user") - - auth_context = AuthContext( - user=user, - ) - return auth_context + if auth_context: + if not auth_context.user.is_active: + raise HTTPException(status_code=403, detail="Inactive user") + + if not auth_context.organization.is_active: + raise HTTPException(status_code=403, detail="Inactive Organization") + + if not auth_context.project.is_active: + raise HTTPException(status_code=403, detail="Inactive Project") + + return auth_context + + # 2. Try Authorization: Bearer header + if token: + return _authenticate_with_jwt(session, token) + + # 3. Try access_token cookie + cookie_token = request.cookies.get("access_token") + if cookie_token: + return _authenticate_with_jwt(session, cookie_token) - else: - raise HTTPException(status_code=401, detail="Invalid Authorization format") + raise HTTPException(status_code=401, detail="Invalid Authorization format") AuthContextDep = Annotated[AuthContext, Depends(get_auth_context)] diff --git a/backend/app/api/docs/auth/google.md b/backend/app/api/docs/auth/google.md new file mode 100644 index 000000000..2a465c562 --- /dev/null +++ b/backend/app/api/docs/auth/google.md @@ -0,0 +1,22 @@ +# Google OAuth Authentication + +Authenticate a user via Google Sign-In by verifying the Google ID token. + +## Request + +- **token** (required): The Google ID token obtained from the frontend Google Sign-In flow. + +## Behavior + +1. Verifies the Google ID token against Google's public keys and the configured `GOOGLE_CLIENT_ID`. +2. Extracts user information (email, name, picture) from the verified token. +3. Looks up the user by email in the database. +4. If the user exists and is active, generates a JWT access token. +5. Sets the access token as an **HTTP-only secure cookie** (`access_token`) in the response. +6. Returns the access token, user details, and Google profile information. + +## Error Responses + +- **400**: Invalid or expired Google token, or email not verified by Google. +- **401**: No account found for the Google email address. +- **403**: User account is inactive. diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 5ab1cbd9e..2421cf97a 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -7,6 +7,7 @@ config, doc_transformation_job, documents, + google_auth, login, languages, llm, @@ -39,6 +40,7 @@ api_router.include_router(cron.router) api_router.include_router(documents.router) api_router.include_router(doc_transformation_job.router) +api_router.include_router(google_auth.router) api_router.include_router(evaluations.router) api_router.include_router(languages.router) api_router.include_router(llm.router) diff --git a/backend/app/api/routes/google_auth.py b/backend/app/api/routes/google_auth.py new file mode 100644 index 000000000..0d85255c3 --- /dev/null +++ b/backend/app/api/routes/google_auth.py @@ -0,0 +1,362 @@ +import logging +from datetime import timedelta + +import jwt as pyjwt +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import JSONResponse +from google.auth.transport import requests as google_requests +from google.oauth2 import id_token +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError +from sqlmodel import Session, and_, select + +from app.api.deps import AuthContextDep, SessionDep +from app.core import security +from app.core.config import settings +from app.crud import get_user_by_email +from app.models import ( + APIKey, + GoogleAuthRequest, + GoogleAuthResponse, + Organization, + Project, + SelectProjectRequest, + Token, + TokenPayload, + User, + UserPublic, +) +from app.utils import load_description + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +def _get_user_projects(session: Session, user_id: int) -> list[dict]: + """Query distinct org/project pairs for a user from active API keys.""" + statement = ( + select(Organization.id, Organization.name, Project.id, Project.name) + .select_from(APIKey) + .join(Organization, Organization.id == APIKey.organization_id) + .join(Project, Project.id == APIKey.project_id) + .where( + and_( + APIKey.user_id == user_id, + APIKey.is_deleted.is_(False), + Organization.is_active.is_(True), + Project.is_active.is_(True), + ) + ) + .distinct() + ) + results = session.exec(statement).all() + return [ + { + "organization_id": org_id, + "organization_name": org_name, + "project_id": proj_id, + "project_name": proj_name, + } + for org_id, org_name, proj_id, proj_name in results + ] + + +def _set_auth_cookies( + response: JSONResponse, + access_token: str, + refresh_token: str, +) -> None: + """Set access_token and refresh_token as HTTP-only cookies on the response.""" + is_secure = settings.ENVIRONMENT in ("staging", "production") + + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=is_secure, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + path="/", + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=is_secure, + samesite="lax", + max_age=settings.REFRESH_TOKEN_EXPIRE_MINUTES * 60, + path=f"{settings.API_V1_STR}/auth", + ) + + +def _create_token_pair( + user_id: int, + organization_id: int | None = None, + project_id: int | None = None, +) -> tuple[str, str]: + """Create an access token and refresh token pair.""" + access_token = security.create_access_token( + user_id, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + organization_id=organization_id, + project_id=project_id, + ) + refresh_token = security.create_refresh_token( + user_id, + expires_delta=timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES), + organization_id=organization_id, + project_id=project_id, + ) + return access_token, refresh_token + + +def _create_token_and_response( + user, + google_profile: dict, + available_projects: list[dict], + organization_id: int | None = None, + project_id: int | None = None, + requires_project_selection: bool = False, +) -> JSONResponse: + """Create JWT token pair, build response, and set cookies.""" + access_token, refresh_token = _create_token_pair( + user.id, + organization_id=organization_id, + project_id=project_id, + ) + + response_data = GoogleAuthResponse( + access_token=access_token, + user=UserPublic.model_validate(user), + google_profile=google_profile, + requires_project_selection=requires_project_selection, + available_projects=available_projects, + ) + + response = JSONResponse(content=response_data.model_dump()) + _set_auth_cookies(response, access_token, refresh_token) + return response + + +@router.post( + "/google", + description=load_description("auth/google.md"), + response_model=GoogleAuthResponse, +) +def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse: + """Authenticate a user via Google OAuth ID token.""" + + if not settings.GOOGLE_CLIENT_ID: + logger.error("[google_auth] GOOGLE_CLIENT_ID is not configured") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Google authentication is not configured", + ) + + # Verify the Google ID token + try: + idinfo = id_token.verify_oauth2_token( + body.token, + google_requests.Request(), + settings.GOOGLE_CLIENT_ID, + ) + except ValueError as e: + logger.warning(f"[google_auth] Invalid Google token: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired Google token", + ) + + # Ensure the email is verified by Google + if not idinfo.get("email_verified", False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Google email is not verified", + ) + + email: str = idinfo["email"] + + # Look up user by email + user = get_user_by_email(session=session, email=email) + if not user: + logger.info(f"[google_auth] No account found for email: {email}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No account found for this Google email. Please sign up first.", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user account", + ) + + google_profile = { + "email": idinfo.get("email"), + "name": idinfo.get("name"), + "picture": idinfo.get("picture"), + "given_name": idinfo.get("given_name"), + "family_name": idinfo.get("family_name"), + } + + # Query user's org/project access + available_projects = _get_user_projects(session, user.id) + + if len(available_projects) == 1: + # Auto-select the only org/project + proj = available_projects[0] + logger.info( + f"[google_auth] User authenticated via Google (auto-selected project) | user_id: {user.id}" + ) + return _create_token_and_response( + user=user, + google_profile=google_profile, + available_projects=available_projects, + organization_id=proj["organization_id"], + project_id=proj["project_id"], + ) + elif len(available_projects) > 1: + # Multiple projects — return token without org/project, frontend must select + logger.info( + f"[google_auth] User authenticated via Google (requires project selection) | user_id: {user.id}" + ) + return _create_token_and_response( + user=user, + google_profile=google_profile, + available_projects=available_projects, + requires_project_selection=True, + ) + else: + # No projects — return token with user only + logger.info( + f"[google_auth] User authenticated via Google (no projects) | user_id: {user.id}" + ) + return _create_token_and_response( + user=user, + google_profile=google_profile, + available_projects=[], + ) + + +@router.post( + "/select-project", + response_model=Token, +) +def select_project( + session: SessionDep, + auth_context: AuthContextDep, + body: SelectProjectRequest, +) -> JSONResponse: + """Select a project and get a new JWT token with org/project embedded.""" + + user = auth_context.user + + # Verify the user has access to this project via an active API key + available_projects = _get_user_projects(session, user.id) + matching = [p for p in available_projects if p["project_id"] == body.project_id] + + if not matching: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this project", + ) + + proj = matching[0] + + access_token, refresh_token = _create_token_pair( + user.id, + organization_id=proj["organization_id"], + project_id=proj["project_id"], + ) + + response = JSONResponse(content=Token(access_token=access_token).model_dump()) + _set_auth_cookies(response, access_token, refresh_token) + + logger.info( + f"[select_project] Project selected | user_id: {user.id}, project_id: {body.project_id}" + ) + return response + + +@router.post( + "/refresh", + response_model=Token, +) +def refresh_access_token(request: Request, session: SessionDep) -> JSONResponse: + """Use a refresh token to get a new access token without re-authenticating.""" + + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token not found", + ) + + # Decode and validate the refresh token + try: + payload = pyjwt.decode( + refresh_token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token has expired. Please login again.", + ) + except InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) + + # Ensure this is a refresh token, not an access token + if token_data.type != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + ) + + # Validate the user still exists and is active + user = session.get(User, token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=403, detail="Inactive user") + + # Issue a new access token with the same org/project claims + access_token, new_refresh_token = _create_token_pair( + user.id, + organization_id=token_data.org_id, + project_id=token_data.project_id, + ) + + response = JSONResponse(content=Token(access_token=access_token).model_dump()) + _set_auth_cookies(response, access_token, new_refresh_token) + + logger.info(f"[refresh_access_token] Token refreshed | user_id: {user.id}") + return response + + +@router.post("/logout") +def logout() -> JSONResponse: + """Clear auth cookies to log the user out.""" + response = JSONResponse(content={"message": "Logged out successfully"}) + + is_secure = settings.ENVIRONMENT in ("staging", "production") + + response.delete_cookie( + key="access_token", + httponly=True, + secure=is_secure, + samesite="lax", + path="/", + ) + response.delete_cookie( + key="refresh_token", + httponly=True, + secure=is_secure, + samesite="lax", + path=f"{settings.API_V1_STR}/auth", + ) + + return response diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 44a7d7771..49cf7e5f6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -37,6 +37,8 @@ class Settings(BaseSettings): SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 1 days = 1 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 1 + # 60 minutes * 24 hours * 7 days = 7 days + REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 ENVIRONMENT: Literal[ "development", "testing", "staging", "production" ] = "development" @@ -52,6 +54,9 @@ class Settings(BaseSettings): KAAPI_GUARDRAILS_AUTH: str = "" KAAPI_GUARDRAILS_URL: str = "" + # Google OAuth + GOOGLE_CLIENT_ID: str = "" + @computed_field # type: ignore[prop-decorator] @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 8cee6e982..e5f6ac3f8 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -67,19 +67,57 @@ def get_fernet() -> Fernet: return _fernet -def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: +def create_access_token( + subject: str | Any, + expires_delta: timedelta, + organization_id: int | None = None, + project_id: int | None = None, +) -> str: """ Create a JWT access token. Args: subject: The subject of the token (typically user ID) expires_delta: Token expiration time delta + organization_id: Optional organization ID to embed in the token + project_id: Optional project ID to embed in the token Returns: str: Encoded JWT token """ expire = datetime.now(timezone.utc) + expires_delta - to_encode = {"exp": expire, "sub": str(subject)} + to_encode: dict[str, Any] = {"exp": expire, "sub": str(subject), "type": "access"} + if organization_id is not None: + to_encode["org_id"] = organization_id + if project_id is not None: + to_encode["project_id"] = project_id + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def create_refresh_token( + subject: str | Any, + expires_delta: timedelta, + organization_id: int | None = None, + project_id: int | None = None, +) -> str: + """ + Create a JWT refresh token. + + Args: + subject: The subject of the token (typically user ID) + expires_delta: Token expiration time delta + organization_id: Optional organization ID to embed in the token + project_id: Optional project ID to embed in the token + + Returns: + str: Encoded JWT refresh token + """ + expire = datetime.now(timezone.utc) + expires_delta + to_encode: dict[str, Any] = {"exp": expire, "sub": str(subject), "type": "refresh"} + if organization_id is not None: + to_encode["org_id"] = organization_id + if project_id is not None: + to_encode["project_id"] = project_id return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b5cb3f0c6..f91a8de74 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,13 @@ from sqlmodel import SQLModel -from .auth import AuthContext, Token, TokenPayload +from .auth import ( + AuthContext, + GoogleAuthRequest, + GoogleAuthResponse, + SelectProjectRequest, + Token, + TokenPayload, +) from .api_key import ( APIKey, @@ -166,6 +173,8 @@ NewPassword, User, UserCreate, + UserMeResponse, + UserProjectAccess, UserPublic, UserRegister, UserUpdate, diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 26b42ef8a..bfbda1f6a 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -1,5 +1,5 @@ from sqlmodel import Field, SQLModel -from app.models.user import User +from app.models.user import User, UserPublic from app.models.organization import Organization from app.models.project import Project from typing import TYPE_CHECKING @@ -14,6 +14,27 @@ class Token(SQLModel): # Contents of JWT token class TokenPayload(SQLModel): sub: str | None = None + org_id: int | None = None + project_id: int | None = None + type: str = "access" + + +# Google OAuth +class GoogleAuthRequest(SQLModel): + token: str + + +class GoogleAuthResponse(SQLModel): + access_token: str + token_type: str = "bearer" + user: UserPublic + google_profile: dict + requires_project_selection: bool = False + available_projects: list[dict] = [] + + +class SelectProjectRequest(SQLModel): + project_id: int class AuthContext(SQLModel): diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f38aafc2a..673c4c4da 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -80,6 +80,17 @@ class UserPublic(UserBase): id: int +class UserProjectAccess(SQLModel): + organization_id: int + organization_name: str + project_id: int + project_name: str + + +class UserMeResponse(UserPublic): + projects: list[UserProjectAccess] = [] + + class UsersPublic(SQLModel): data: list[UserPublic] count: int diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 504caac43..9c698a2c8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "indic-nlp-library>=0.92", "whisper-normalizer>=0.1.12", "elevenlabs>=2.38.1", + "google-auth>=2.49.1", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index 54111b1d8..0f4a4439a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -227,6 +227,7 @@ dependencies = [ { name = "emails" }, { name = "fastapi", extra = ["standard"] }, { name = "flower" }, + { name = "google-auth" }, { name = "google-genai" }, { name = "httpx" }, { name = "indic-nlp-library" }, @@ -280,6 +281,7 @@ requires-dist = [ { name = "emails", specifier = ">=0.6,<1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" }, { name = "flower", specifier = ">=2.0.1" }, + { name = "google-auth", specifier = ">=2.49.1" }, { name = "google-genai", specifier = ">=1.59.0" }, { name = "httpx", specifier = ">=0.25.1,<1.0.0" }, { name = "indic-nlp-library", specifier = ">=0.92" }, @@ -1248,7 +1250,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -1257,7 +1258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -1266,7 +1266,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -1275,7 +1274,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, From 4cf576b1b73b1e7ac1a19395042bcf5d8259abb1 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:33:55 +0530 Subject: [PATCH 10/16] fix(*): update the js comment --- backend/app/api/routes/google_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/routes/google_auth.py b/backend/app/api/routes/google_auth.py index 0d85255c3..ddbe65167 100644 --- a/backend/app/api/routes/google_auth.py +++ b/backend/app/api/routes/google_auth.py @@ -182,7 +182,7 @@ def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse: logger.info(f"[google_auth] No account found for email: {email}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="No account found for this Google email. Please sign up first.", + detail="No account found for this Google email. Please Contact Support to add your account.", ) if not user.is_active: From 0b3b30ec0a8db0dfa248ccc3716e9bc178ddc444 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:09:42 +0530 Subject: [PATCH 11/16] fix(*): update the uv.lock --- backend/uv.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/uv.lock b/backend/uv.lock index 5fec7dcb1..b0ca01d84 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -227,6 +227,7 @@ dependencies = [ { name = "emails" }, { name = "fastapi", extra = ["standard"] }, { name = "flower" }, + { name = "gevent" }, { name = "google-auth" }, { name = "google-genai" }, { name = "httpx" }, @@ -281,6 +282,7 @@ requires-dist = [ { name = "emails", specifier = ">=0.6,<1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" }, { name = "flower", specifier = ">=2.0.1" }, + { name = "gevent", specifier = ">=25.9.1" }, { name = "google-auth", specifier = ">=2.49.1" }, { name = "google-genai", specifier = ">=1.59.0" }, { name = "httpx", specifier = ">=0.25.1,<1.0.0" }, From 6bb875c0bd73dc27de63c873416ad65c11206036 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:12:07 +0530 Subject: [PATCH 12/16] fix(*): update the test cases --- backend/app/tests/api/test_deps.py | 22 +++++++++- backend/app/tests/api/test_permissions.py | 49 +++++++++++++++++++---- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/backend/app/tests/api/test_deps.py b/backend/app/tests/api/test_deps.py index 5824f898e..2dc58b9c2 100644 --- a/backend/app/tests/api/test_deps.py +++ b/backend/app/tests/api/test_deps.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from sqlmodel import Session from fastapi import HTTPException @@ -14,6 +16,13 @@ from app.tests.utils.test_data import create_test_api_key +def _mock_request(cookies: dict | None = None) -> MagicMock: + """Create a mock Request object with optional cookies.""" + request = MagicMock() + request.cookies = cookies or {} + return request + + class TestGetAuthContext: """Test suite for get_auth_context function""" @@ -22,6 +31,7 @@ def test_get_auth_context_with_valid_api_key( ) -> None: """Test successful authentication with valid API key""" auth_context = get_auth_context( + request=_mock_request(), session=db, token=None, api_key=user_api_key.key, @@ -33,18 +43,19 @@ def test_get_auth_context_with_valid_api_key( assert auth_context.organization == user_api_key.organization def test_get_auth_context_with_invalid_api_key(self, db: Session) -> None: - """Test authentication fails with invalid API key""" + """Test authentication fails with invalid API key when no other auth is provided""" invalid_api_key = "ApiKey InvalidKeyThatDoesNotExist123456789" with pytest.raises(HTTPException) as exc_info: get_auth_context( + request=_mock_request(), session=db, token=None, api_key=invalid_api_key, ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "Invalid API Key" + assert exc_info.value.detail == "Invalid Authorization format" def test_get_auth_context_with_valid_token( self, db: Session, normal_user_token_headers: dict[str, str] @@ -52,6 +63,7 @@ def test_get_auth_context_with_valid_token( """Test successful authentication with valid token""" token = normal_user_token_headers["Authorization"].replace("Bearer ", "") auth_context = get_auth_context( + request=_mock_request(), session=db, token=token, api_key=None, @@ -67,6 +79,7 @@ def test_get_auth_context_with_invalid_token(self, db: Session) -> None: with pytest.raises(HTTPException) as exc_info: get_auth_context( + request=_mock_request(), session=db, token=invalid_token, api_key=None, @@ -78,6 +91,7 @@ def test_get_auth_context_with_no_credentials(self, db: Session) -> None: """Test authentication fails when neither API key nor token is provided""" with pytest.raises(HTTPException) as exc_info: get_auth_context( + request=_mock_request(), session=db, token=None, api_key=None, @@ -98,6 +112,7 @@ def test_get_auth_context_with_inactive_user_via_api_key(self, db: Session) -> N with pytest.raises(HTTPException) as exc_info: get_auth_context( + request=_mock_request(), session=db, token=None, api_key=api_key.key, @@ -122,6 +137,7 @@ def test_get_auth_context_with_inactive_user_via_token( with pytest.raises(HTTPException) as exc_info: get_auth_context( + request=_mock_request(), session=db, token=token, api_key=None, @@ -142,6 +158,7 @@ def test_get_auth_context_with_inactive_organization( with pytest.raises(HTTPException) as exc_info: get_auth_context( + request=_mock_request(), session=db, token=None, api_key=user_api_key.key, @@ -162,6 +179,7 @@ def test_get_auth_context_with_inactive_project( with pytest.raises(HTTPException) as exc_info: get_auth_context( + request=_mock_request(), session=db, token=None, api_key=user_api_key.key, diff --git a/backend/app/tests/api/test_permissions.py b/backend/app/tests/api/test_permissions.py index e08e73617..b8fde3bc9 100644 --- a/backend/app/tests/api/test_permissions.py +++ b/backend/app/tests/api/test_permissions.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from fastapi import HTTPException from sqlmodel import Session @@ -8,6 +10,13 @@ from app.tests.utils.test_data import create_test_api_key +def _mock_request() -> MagicMock: + """Create a mock Request object with empty cookies.""" + request = MagicMock() + request.cookies = {} + return request + + class TestHasPermission: """Test suite for has_permission function""" @@ -21,7 +30,10 @@ def test_superuser_permission_with_superuser(self, db: Session) -> None: db.refresh(user) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) result = has_permission(auth_context, Permission.SUPERUSER, db) @@ -33,7 +45,10 @@ def test_superuser_permission_with_regular_user(self, db: Session) -> None: api_key_response = create_test_api_key(db) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) result = has_permission(auth_context, Permission.SUPERUSER, db) @@ -47,7 +62,10 @@ def test_require_organization_permission_with_organization( api_key_response = create_test_api_key(db) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) result = has_permission(auth_context, Permission.REQUIRE_ORGANIZATION, db) @@ -61,7 +79,10 @@ def test_require_organization_permission_without_organization( api_key_response = create_test_api_key(db) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) auth_context.organization = None @@ -75,7 +96,10 @@ def test_require_project_permission_with_project(self, db: Session) -> None: api_key_response = create_test_api_key(db) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) result = has_permission(auth_context, Permission.REQUIRE_PROJECT, db) @@ -87,7 +111,10 @@ def test_require_project_permission_without_project(self, db: Session) -> None: api_key_response = create_test_api_key(db) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) auth_context.project = None @@ -115,7 +142,10 @@ def test_permission_checker_passes_with_valid_permission(self, db: Session) -> N db.commit() db.refresh(user) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) permission_checker = require_permission(Permission.SUPERUSER) @@ -127,7 +157,10 @@ def test_permission_checker_raises_403_without_permission( """Test that permission checker raises HTTPException with 403 when user lacks permission""" api_key_response = create_test_api_key(db) auth_context = get_auth_context( - session=db, token=None, api_key=api_key_response.key + request=_mock_request(), + session=db, + token=None, + api_key=api_key_response.key, ) permission_checker = require_permission(Permission.SUPERUSER) From 3a1c9c8d8be948f949520ffc6cdf2f31384ec6c2 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:30:24 +0530 Subject: [PATCH 13/16] fix(*): update test coverage --- backend/app/tests/api/test_deps.py | 81 ++++++ backend/app/tests/api/test_google_auth.py | 293 ++++++++++++++++++++++ backend/app/tests/core/test_security.py | 76 ++++++ 3 files changed, 450 insertions(+) create mode 100644 backend/app/tests/api/test_google_auth.py diff --git a/backend/app/tests/api/test_deps.py b/backend/app/tests/api/test_deps.py index 2dc58b9c2..d3131d043 100644 --- a/backend/app/tests/api/test_deps.py +++ b/backend/app/tests/api/test_deps.py @@ -13,6 +13,7 @@ from app.tests.utils.auth import TestAuthContext from app.tests.utils.user import authentication_token_from_email, create_random_user from app.core.config import settings +from app.core.security import create_access_token, create_refresh_token from app.tests.utils.test_data import create_test_api_key @@ -187,3 +188,83 @@ def test_get_auth_context_with_inactive_project( assert exc_info.value.status_code == 403 assert exc_info.value.detail == "Inactive Project" + + def test_get_auth_context_with_cookie_token( + self, db: Session, normal_user_token_headers: dict[str, str] + ) -> None: + """Test successful authentication via access_token cookie""" + token = normal_user_token_headers["Authorization"].replace("Bearer ", "") + auth_context = get_auth_context( + request=_mock_request(cookies={"access_token": token}), + session=db, + token=None, + api_key=None, + ) + + assert isinstance(auth_context, AuthContext) + assert auth_context.user.email == settings.EMAIL_TEST_USER + + def test_get_auth_context_with_expired_token(self, db: Session) -> None: + """Test authentication fails with expired token""" + from datetime import timedelta + + expired_token = create_access_token( + subject="1", expires_delta=timedelta(minutes=-1) + ) + + with pytest.raises(HTTPException) as exc_info: + get_auth_context( + request=_mock_request(), + session=db, + token=expired_token, + api_key=None, + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Token has expired" + + def test_get_auth_context_rejects_refresh_token(self, db: Session) -> None: + """Test that refresh tokens are rejected for API access""" + from datetime import timedelta + + refresh_token = create_refresh_token( + subject="1", expires_delta=timedelta(minutes=60) + ) + + with pytest.raises(HTTPException) as exc_info: + get_auth_context( + request=_mock_request(), + session=db, + token=refresh_token, + api_key=None, + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Refresh tokens cannot be used for API access" + + def test_get_auth_context_jwt_with_org_and_project( + self, db: Session, user_api_key: TestAuthContext + ) -> None: + """Test JWT token with org_id and project_id populates AuthContext""" + from datetime import timedelta + + token = create_access_token( + subject=str(user_api_key.user.id), + expires_delta=timedelta(minutes=60), + organization_id=user_api_key.organization.id, + project_id=user_api_key.project.id, + ) + + auth_context = get_auth_context( + request=_mock_request(), + session=db, + token=token, + api_key=None, + ) + + assert isinstance(auth_context, AuthContext) + assert auth_context.user.id == user_api_key.user.id + assert auth_context.organization is not None + assert auth_context.organization.id == user_api_key.organization.id + assert auth_context.project is not None + assert auth_context.project.id == user_api_key.project.id diff --git a/backend/app/tests/api/test_google_auth.py b/backend/app/tests/api/test_google_auth.py new file mode 100644 index 000000000..5adac7f9a --- /dev/null +++ b/backend/app/tests/api/test_google_auth.py @@ -0,0 +1,293 @@ +from datetime import timedelta +from unittest.mock import patch + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.core.security import create_access_token, create_refresh_token +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.user import create_random_user + +GOOGLE_AUTH_URL = f"{settings.API_V1_STR}/auth/google" +SELECT_PROJECT_URL = f"{settings.API_V1_STR}/auth/select-project" +REFRESH_URL = f"{settings.API_V1_STR}/auth/refresh" +LOGOUT_URL = f"{settings.API_V1_STR}/auth/logout" + +MOCK_GOOGLE_PROFILE = { + "email": None, # set per test + "email_verified": True, + "name": "Test User", + "picture": "https://example.com/photo.jpg", + "given_name": "Test", + "family_name": "User", +} + + +def _mock_idinfo(email: str, email_verified: bool = True) -> dict: + return {**MOCK_GOOGLE_PROFILE, "email": email, "email_verified": email_verified} + + +class TestGoogleAuth: + """Test suite for POST /auth/google endpoint.""" + + @patch("app.api.routes.google_auth.settings") + def test_google_auth_not_configured(self, mock_settings, client: TestClient): + """Test returns 500 when GOOGLE_CLIENT_ID is not set.""" + mock_settings.GOOGLE_CLIENT_ID = "" + resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) + assert resp.status_code == 500 + assert "not configured" in resp.json()["detail"] + + @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") + @patch("app.api.routes.google_auth.settings") + def test_google_auth_invalid_token( + self, mock_settings, mock_verify, client: TestClient + ): + """Test returns 400 for invalid Google token.""" + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440 + mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080 + mock_settings.ENVIRONMENT = "testing" + mock_settings.API_V1_STR = settings.API_V1_STR + mock_verify.side_effect = ValueError("Invalid token") + + resp = client.post(GOOGLE_AUTH_URL, json={"token": "bad-token"}) + assert resp.status_code == 400 + assert "Invalid or expired" in resp.json()["detail"] + + @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") + @patch("app.api.routes.google_auth.settings") + def test_google_auth_unverified_email( + self, mock_settings, mock_verify, client: TestClient + ): + """Test returns 400 when Google email is not verified.""" + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + mock_verify.return_value = _mock_idinfo( + "test@example.com", email_verified=False + ) + + resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) + assert resp.status_code == 400 + assert "not verified" in resp.json()["detail"] + + @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") + @patch("app.api.routes.google_auth.settings") + def test_google_auth_user_not_found( + self, mock_settings, mock_verify, client: TestClient + ): + """Test returns 401 when no user exists for the email.""" + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + mock_verify.return_value = _mock_idinfo("nonexistent@example.com") + + resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) + assert resp.status_code == 401 + assert "No account found" in resp.json()["detail"] + + @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") + @patch("app.api.routes.google_auth.settings") + def test_google_auth_activates_inactive_user( + self, mock_settings, mock_verify, db: Session, client: TestClient + ): + """Test that inactive user is activated on first Google login.""" + user = create_random_user(db) + user.is_active = False + db.add(user) + db.commit() + db.refresh(user) + + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440 + mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080 + mock_settings.ENVIRONMENT = "testing" + mock_settings.API_V1_STR = settings.API_V1_STR + mock_settings.SECRET_KEY = settings.SECRET_KEY + mock_verify.return_value = _mock_idinfo(user.email) + + resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) + assert resp.status_code == 200 + + db.refresh(user) + assert user.is_active is True + + @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") + @patch("app.api.routes.google_auth.settings") + def test_google_auth_success_no_projects( + self, mock_settings, mock_verify, db: Session, client: TestClient + ): + """Test successful login for user with no projects.""" + user = create_random_user(db) + + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440 + mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080 + mock_settings.ENVIRONMENT = "testing" + mock_settings.API_V1_STR = settings.API_V1_STR + mock_settings.SECRET_KEY = settings.SECRET_KEY + mock_verify.return_value = _mock_idinfo(user.email) + + resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) + assert resp.status_code == 200 + + data = resp.json() + assert "access_token" in data + assert data["requires_project_selection"] is False + assert data["available_projects"] == [] + assert "access_token" in resp.cookies + + @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") + @patch("app.api.routes.google_auth.settings") + def test_google_auth_success_single_project_via_api_key( + self, + mock_settings, + mock_verify, + db: Session, + client: TestClient, + user_api_key: TestAuthContext, + ): + """Test successful login auto-selects single project from API key.""" + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440 + mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080 + mock_settings.ENVIRONMENT = "testing" + mock_settings.API_V1_STR = settings.API_V1_STR + mock_settings.SECRET_KEY = settings.SECRET_KEY + mock_verify.return_value = _mock_idinfo(user_api_key.user.email) + + resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) + assert resp.status_code == 200 + + data = resp.json() + assert data["requires_project_selection"] is False + assert len(data["available_projects"]) == 1 + + +class TestSelectProject: + """Test suite for POST /auth/select-project endpoint.""" + + def test_select_project_unauthenticated(self, client: TestClient): + """Test returns 401 when not authenticated.""" + resp = client.post(SELECT_PROJECT_URL, json={"project_id": 1}) + assert resp.status_code == 401 + + def test_select_project_no_access( + self, client: TestClient, normal_user_token_headers: dict[str, str] + ): + """Test returns 403 when user has no access to the project.""" + resp = client.post( + SELECT_PROJECT_URL, + json={"project_id": 99999}, + headers=normal_user_token_headers, + ) + assert resp.status_code == 403 + assert "do not have access" in resp.json()["detail"] + + def test_select_project_success( + self, + db: Session, + client: TestClient, + user_api_key: TestAuthContext, + normal_user_token_headers: dict[str, str], + ): + """Test successful project selection returns new token with cookies.""" + resp = client.post( + SELECT_PROJECT_URL, + json={"project_id": user_api_key.project.id}, + headers=normal_user_token_headers, + ) + assert resp.status_code == 200 + + data = resp.json() + assert "access_token" in data + assert "access_token" in resp.cookies + + +class TestRefreshToken: + """Test suite for POST /auth/refresh endpoint.""" + + def test_refresh_no_cookie(self, client: TestClient): + """Test returns 401 when no refresh token cookie is present.""" + resp = client.post(REFRESH_URL) + assert resp.status_code == 401 + assert "not found" in resp.json()["detail"] + + def test_refresh_with_access_token_instead(self, db: Session, client: TestClient): + """Test returns 401 when access token is used instead of refresh token.""" + user = create_random_user(db) + access_token = create_access_token( + subject=str(user.id), expires_delta=timedelta(minutes=30) + ) + client.cookies.set("refresh_token", access_token) + + resp = client.post(REFRESH_URL) + assert resp.status_code == 401 + assert "Invalid token type" in resp.json()["detail"] + + def test_refresh_with_expired_token(self, db: Session, client: TestClient): + """Test returns 401 when refresh token is expired.""" + user = create_random_user(db) + expired_refresh = create_refresh_token( + subject=str(user.id), expires_delta=timedelta(minutes=-1) + ) + client.cookies.set("refresh_token", expired_refresh) + + resp = client.post(REFRESH_URL) + assert resp.status_code == 401 + assert "expired" in resp.json()["detail"] + + def test_refresh_success(self, db: Session, client: TestClient): + """Test successful refresh returns new tokens.""" + user = create_random_user(db) + refresh_token = create_refresh_token( + subject=str(user.id), expires_delta=timedelta(days=7) + ) + client.cookies.set("refresh_token", refresh_token) + + resp = client.post(REFRESH_URL) + assert resp.status_code == 200 + + data = resp.json() + assert "access_token" in data + assert "access_token" in resp.cookies + + def test_refresh_with_org_project( + self, db: Session, client: TestClient, user_api_key: TestAuthContext + ): + """Test refresh preserves org/project claims.""" + refresh_token = create_refresh_token( + subject=str(user_api_key.user.id), + expires_delta=timedelta(days=7), + organization_id=user_api_key.organization.id, + project_id=user_api_key.project.id, + ) + client.cookies.set("refresh_token", refresh_token) + + resp = client.post(REFRESH_URL) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + def test_refresh_inactive_user(self, db: Session, client: TestClient): + """Test returns 403 when user is inactive.""" + user = create_random_user(db) + refresh_token = create_refresh_token( + subject=str(user.id), expires_delta=timedelta(days=7) + ) + + user.is_active = False + db.add(user) + db.commit() + + client.cookies.set("refresh_token", refresh_token) + + resp = client.post(REFRESH_URL) + assert resp.status_code == 403 + + +class TestLogout: + """Test suite for POST /auth/logout endpoint.""" + + def test_logout_clears_cookies(self, client: TestClient): + """Test logout clears auth cookies.""" + resp = client.post(LOGOUT_URL) + assert resp.status_code == 200 + assert resp.json()["message"] == "Logged out successfully" diff --git a/backend/app/tests/core/test_security.py b/backend/app/tests/core/test_security.py index efdebcd42..438bf8b05 100644 --- a/backend/app/tests/core/test_security.py +++ b/backend/app/tests/core/test_security.py @@ -1,6 +1,13 @@ +from datetime import timedelta + +import jwt from sqlmodel import Session +from app.core.config import settings from app.core.security import ( + ALGORITHM, + create_access_token, + create_refresh_token, get_encryption_key, APIKeyManager, ) @@ -190,3 +197,72 @@ def test_generate_creates_verifiable_key(self, db: Session): assert auth_context is not None assert auth_context.user.id == api_key_response.user_id + + +class TestCreateAccessToken: + """Test suite for create_access_token function.""" + + def test_creates_valid_jwt(self): + """Test that a valid JWT is created.""" + token = create_access_token(subject="42", expires_delta=timedelta(minutes=30)) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + assert payload["sub"] == "42" + assert payload["type"] == "access" + assert "exp" in payload + + def test_includes_org_and_project(self): + """Test that org_id and project_id are embedded in the token.""" + token = create_access_token( + subject="1", + expires_delta=timedelta(minutes=30), + organization_id=10, + project_id=20, + ) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + assert payload["org_id"] == 10 + assert payload["project_id"] == 20 + + def test_omits_org_and_project_when_none(self): + """Test that org_id and project_id are omitted when not provided.""" + token = create_access_token(subject="1", expires_delta=timedelta(minutes=30)) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + assert "org_id" not in payload + assert "project_id" not in payload + + +class TestCreateRefreshToken: + """Test suite for create_refresh_token function.""" + + def test_creates_valid_refresh_jwt(self): + """Test that a valid refresh JWT is created.""" + token = create_refresh_token(subject="42", expires_delta=timedelta(days=7)) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + assert payload["sub"] == "42" + assert payload["type"] == "refresh" + assert "exp" in payload + + def test_includes_org_and_project(self): + """Test that org_id and project_id are embedded in the refresh token.""" + token = create_refresh_token( + subject="1", + expires_delta=timedelta(days=7), + organization_id=10, + project_id=20, + ) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + assert payload["org_id"] == 10 + assert payload["project_id"] == 20 + assert payload["type"] == "refresh" + + def test_omits_org_and_project_when_none(self): + """Test that org_id and project_id are omitted when not provided.""" + token = create_refresh_token(subject="1", expires_delta=timedelta(days=7)) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + assert "org_id" not in payload + assert "project_id" not in payload From b22588f47a94d3b7e096e0b3541cc6163f675d39 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:41:27 +0530 Subject: [PATCH 14/16] fix(*): update the test cases --- backend/app/tests/api/test_google_auth.py | 31 +++++++++-------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/backend/app/tests/api/test_google_auth.py b/backend/app/tests/api/test_google_auth.py index 5adac7f9a..5869e5015 100644 --- a/backend/app/tests/api/test_google_auth.py +++ b/backend/app/tests/api/test_google_auth.py @@ -37,7 +37,7 @@ def test_google_auth_not_configured(self, mock_settings, client: TestClient): mock_settings.GOOGLE_CLIENT_ID = "" resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) assert resp.status_code == 500 - assert "not configured" in resp.json()["detail"] + assert "not configured" in resp.json()["error"] @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") @@ -54,7 +54,7 @@ def test_google_auth_invalid_token( resp = client.post(GOOGLE_AUTH_URL, json={"token": "bad-token"}) assert resp.status_code == 400 - assert "Invalid or expired" in resp.json()["detail"] + assert "Invalid or expired" in resp.json()["error"] @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") @@ -69,7 +69,7 @@ def test_google_auth_unverified_email( resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) assert resp.status_code == 400 - assert "not verified" in resp.json()["detail"] + assert "not verified" in resp.json()["error"] @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") @@ -82,14 +82,14 @@ def test_google_auth_user_not_found( resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) assert resp.status_code == 401 - assert "No account found" in resp.json()["detail"] + assert "No account found" in resp.json()["error"] @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") - def test_google_auth_activates_inactive_user( + def test_google_auth_inactive_user_rejected( self, mock_settings, mock_verify, db: Session, client: TestClient ): - """Test that inactive user is activated on first Google login.""" + """Test returns 403 when user account is inactive.""" user = create_random_user(db) user.is_active = False db.add(user) @@ -97,18 +97,11 @@ def test_google_auth_activates_inactive_user( db.refresh(user) mock_settings.GOOGLE_CLIENT_ID = "test-client-id" - mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440 - mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080 - mock_settings.ENVIRONMENT = "testing" - mock_settings.API_V1_STR = settings.API_V1_STR - mock_settings.SECRET_KEY = settings.SECRET_KEY mock_verify.return_value = _mock_idinfo(user.email) resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) - assert resp.status_code == 200 - - db.refresh(user) - assert user.is_active is True + assert resp.status_code == 403 + assert "Inactive" in resp.json()["error"] @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") @@ -180,7 +173,7 @@ def test_select_project_no_access( headers=normal_user_token_headers, ) assert resp.status_code == 403 - assert "do not have access" in resp.json()["detail"] + assert "do not have access" in resp.json()["error"] def test_select_project_success( self, @@ -209,7 +202,7 @@ def test_refresh_no_cookie(self, client: TestClient): """Test returns 401 when no refresh token cookie is present.""" resp = client.post(REFRESH_URL) assert resp.status_code == 401 - assert "not found" in resp.json()["detail"] + assert "not found" in resp.json()["error"] def test_refresh_with_access_token_instead(self, db: Session, client: TestClient): """Test returns 401 when access token is used instead of refresh token.""" @@ -221,7 +214,7 @@ def test_refresh_with_access_token_instead(self, db: Session, client: TestClient resp = client.post(REFRESH_URL) assert resp.status_code == 401 - assert "Invalid token type" in resp.json()["detail"] + assert "Invalid token type" in resp.json()["error"] def test_refresh_with_expired_token(self, db: Session, client: TestClient): """Test returns 401 when refresh token is expired.""" @@ -233,7 +226,7 @@ def test_refresh_with_expired_token(self, db: Session, client: TestClient): resp = client.post(REFRESH_URL) assert resp.status_code == 401 - assert "expired" in resp.json()["detail"] + assert "expired" in resp.json()["error"] def test_refresh_success(self, db: Session, client: TestClient): """Test successful refresh returns new tokens.""" From 5b8af376b63c20ebcf669230dd0a173cb2f34668 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:35:55 +0530 Subject: [PATCH 15/16] fix(*): for the response used the APIResponses utils function --- backend/app/api/docs/auth/google.md | 26 +++++++++++--- backend/app/api/routes/google_auth.py | 28 ++++++++++----- backend/app/tests/api/test_google_auth.py | 42 +++++++++++++++-------- 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/backend/app/api/docs/auth/google.md b/backend/app/api/docs/auth/google.md index 2a465c562..52de201b9 100644 --- a/backend/app/api/docs/auth/google.md +++ b/backend/app/api/docs/auth/google.md @@ -11,12 +11,30 @@ Authenticate a user via Google Sign-In by verifying the Google ID token. 1. Verifies the Google ID token against Google's public keys and the configured `GOOGLE_CLIENT_ID`. 2. Extracts user information (email, name, picture) from the verified token. 3. Looks up the user by email in the database. -4. If the user exists and is active, generates a JWT access token. -5. Sets the access token as an **HTTP-only secure cookie** (`access_token`) in the response. -6. Returns the access token, user details, and Google profile information. +4. If the user exists and was inactive (first login), activates the account. +5. Generates a JWT access token and refresh token, set as **HTTP-only secure cookies**. +6. If the user has exactly one project, it is auto-selected and embedded in the JWT. +7. If the user has multiple projects, `requires_project_selection: true` is returned with the list. + +## Response Format + +All responses follow the standard `APIResponse` format: +```json +{ + "success": true, + "data": { + "access_token": "...", + "token_type": "bearer", + "user": { ... }, + "google_profile": { ... }, + "requires_project_selection": false, + "available_projects": [ ... ] + } +} +``` ## Error Responses - **400**: Invalid or expired Google token, or email not verified by Google. - **401**: No account found for the Google email address. -- **403**: User account is inactive. +- **500**: `GOOGLE_CLIENT_ID` is not configured. diff --git a/backend/app/api/routes/google_auth.py b/backend/app/api/routes/google_auth.py index ddbe65167..254e53cdd 100644 --- a/backend/app/api/routes/google_auth.py +++ b/backend/app/api/routes/google_auth.py @@ -17,6 +17,7 @@ APIKey, GoogleAuthRequest, GoogleAuthResponse, + Message, Organization, Project, SelectProjectRequest, @@ -25,7 +26,7 @@ User, UserPublic, ) -from app.utils import load_description +from app.utils import APIResponse, load_description logger = logging.getLogger(__name__) @@ -133,7 +134,8 @@ def _create_token_and_response( available_projects=available_projects, ) - response = JSONResponse(content=response_data.model_dump()) + api_response = APIResponse.success_response(data=response_data) + response = JSONResponse(content=api_response.model_dump()) _set_auth_cookies(response, access_token, refresh_token) return response @@ -141,7 +143,7 @@ def _create_token_and_response( @router.post( "/google", description=load_description("auth/google.md"), - response_model=GoogleAuthResponse, + response_model=APIResponse[GoogleAuthResponse], ) def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse: """Authenticate a user via Google OAuth ID token.""" @@ -240,7 +242,7 @@ def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse: @router.post( "/select-project", - response_model=Token, + response_model=APIResponse[Token], ) def select_project( session: SessionDep, @@ -269,7 +271,8 @@ def select_project( project_id=proj["project_id"], ) - response = JSONResponse(content=Token(access_token=access_token).model_dump()) + api_response = APIResponse.success_response(data=Token(access_token=access_token)) + response = JSONResponse(content=api_response.model_dump()) _set_auth_cookies(response, access_token, refresh_token) logger.info( @@ -280,7 +283,7 @@ def select_project( @router.post( "/refresh", - response_model=Token, + response_model=APIResponse[Token], ) def refresh_access_token(request: Request, session: SessionDep) -> JSONResponse: """Use a refresh token to get a new access token without re-authenticating.""" @@ -330,17 +333,24 @@ def refresh_access_token(request: Request, session: SessionDep) -> JSONResponse: project_id=token_data.project_id, ) - response = JSONResponse(content=Token(access_token=access_token).model_dump()) + api_response = APIResponse.success_response(data=Token(access_token=access_token)) + response = JSONResponse(content=api_response.model_dump()) _set_auth_cookies(response, access_token, new_refresh_token) logger.info(f"[refresh_access_token] Token refreshed | user_id: {user.id}") return response -@router.post("/logout") +@router.post( + "/logout", + response_model=APIResponse[Message], +) def logout() -> JSONResponse: """Clear auth cookies to log the user out.""" - response = JSONResponse(content={"message": "Logged out successfully"}) + api_response = APIResponse.success_response( + data=Message(message="Logged out successfully") + ) + response = JSONResponse(content=api_response.model_dump()) is_secure = settings.ENVIRONMENT in ("staging", "production") diff --git a/backend/app/tests/api/test_google_auth.py b/backend/app/tests/api/test_google_auth.py index 5869e5015..dc7cf821d 100644 --- a/backend/app/tests/api/test_google_auth.py +++ b/backend/app/tests/api/test_google_auth.py @@ -86,10 +86,10 @@ def test_google_auth_user_not_found( @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") - def test_google_auth_inactive_user_rejected( + def test_google_auth_activates_inactive_user( self, mock_settings, mock_verify, db: Session, client: TestClient ): - """Test returns 403 when user account is inactive.""" + """Test that inactive user is activated on first Google login.""" user = create_random_user(db) user.is_active = False db.add(user) @@ -97,11 +97,18 @@ def test_google_auth_inactive_user_rejected( db.refresh(user) mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440 + mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080 + mock_settings.ENVIRONMENT = "testing" + mock_settings.API_V1_STR = settings.API_V1_STR + mock_settings.SECRET_KEY = settings.SECRET_KEY mock_verify.return_value = _mock_idinfo(user.email) resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) - assert resp.status_code == 403 - assert "Inactive" in resp.json()["error"] + assert resp.status_code == 200 + + db.refresh(user) + assert user.is_active is True @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") @@ -122,7 +129,9 @@ def test_google_auth_success_no_projects( resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) assert resp.status_code == 200 - data = resp.json() + body = resp.json() + assert body["success"] is True + data = body["data"] assert "access_token" in data assert data["requires_project_selection"] is False assert data["available_projects"] == [] @@ -150,7 +159,7 @@ def test_google_auth_success_single_project_via_api_key( resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) assert resp.status_code == 200 - data = resp.json() + data = resp.json()["data"] assert data["requires_project_selection"] is False assert len(data["available_projects"]) == 1 @@ -190,8 +199,9 @@ def test_select_project_success( ) assert resp.status_code == 200 - data = resp.json() - assert "access_token" in data + body = resp.json() + assert body["success"] is True + assert "access_token" in body["data"] assert "access_token" in resp.cookies @@ -239,8 +249,9 @@ def test_refresh_success(self, db: Session, client: TestClient): resp = client.post(REFRESH_URL) assert resp.status_code == 200 - data = resp.json() - assert "access_token" in data + body = resp.json() + assert body["success"] is True + assert "access_token" in body["data"] assert "access_token" in resp.cookies def test_refresh_with_org_project( @@ -257,7 +268,7 @@ def test_refresh_with_org_project( resp = client.post(REFRESH_URL) assert resp.status_code == 200 - assert "access_token" in resp.json() + assert "access_token" in resp.json()["data"] def test_refresh_inactive_user(self, db: Session, client: TestClient): """Test returns 403 when user is inactive.""" @@ -279,8 +290,11 @@ def test_refresh_inactive_user(self, db: Session, client: TestClient): class TestLogout: """Test suite for POST /auth/logout endpoint.""" - def test_logout_clears_cookies(self, client: TestClient): - """Test logout clears auth cookies.""" + def test_logout_success(self, client: TestClient): + """Test logout returns success response and clears cookies.""" resp = client.post(LOGOUT_URL) assert resp.status_code == 200 - assert resp.json()["message"] == "Logged out successfully" + + body = resp.json() + assert body["success"] is True + assert body["data"]["message"] == "Logged out successfully" From b3eb1fd1c48b57b7252b1e0f35c00fe32bd3f953 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:50:49 +0530 Subject: [PATCH 16/16] fix(*): update the test cases --- backend/app/tests/api/test_google_auth.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/backend/app/tests/api/test_google_auth.py b/backend/app/tests/api/test_google_auth.py index dc7cf821d..df1c5a94c 100644 --- a/backend/app/tests/api/test_google_auth.py +++ b/backend/app/tests/api/test_google_auth.py @@ -86,10 +86,10 @@ def test_google_auth_user_not_found( @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings") - def test_google_auth_activates_inactive_user( + def test_google_auth_inactive_user_rejected( self, mock_settings, mock_verify, db: Session, client: TestClient ): - """Test that inactive user is activated on first Google login.""" + """Test returns 403 when user account is inactive.""" user = create_random_user(db) user.is_active = False db.add(user) @@ -97,18 +97,11 @@ def test_google_auth_activates_inactive_user( db.refresh(user) mock_settings.GOOGLE_CLIENT_ID = "test-client-id" - mock_settings.ACCESS_TOKEN_EXPIRE_MINUTES = 1440 - mock_settings.REFRESH_TOKEN_EXPIRE_MINUTES = 10080 - mock_settings.ENVIRONMENT = "testing" - mock_settings.API_V1_STR = settings.API_V1_STR - mock_settings.SECRET_KEY = settings.SECRET_KEY mock_verify.return_value = _mock_idinfo(user.email) resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) - assert resp.status_code == 200 - - db.refresh(user) - assert user.is_active is True + assert resp.status_code == 403 + assert "Inactive" in resp.json()["error"] @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") @patch("app.api.routes.google_auth.settings")