From 52543bc7a708cb18539194f3e068e81c8369bc60 Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:11:36 +0300 Subject: [PATCH 1/8] Fix user enumeration --- src/app/core/commands/set_user_password.py | 1 - src/app/infrastructure/auth_ctx/handlers/log_in.py | 6 ++---- src/app/presentation/http/account/log_in.py | 2 -- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/app/core/commands/set_user_password.py b/src/app/core/commands/set_user_password.py index fbc1838..ad83ee0 100644 --- a/src/app/core/commands/set_user_password.py +++ b/src/app/core/commands/set_user_password.py @@ -74,7 +74,6 @@ async def execute(self, request: SetUserPasswordRequest) -> None: target=user, ), ) - await self._user_service.change_password( user, password, diff --git a/src/app/infrastructure/auth_ctx/handlers/log_in.py b/src/app/infrastructure/auth_ctx/handlers/log_in.py index 5f9eb10..72f330e 100644 --- a/src/app/infrastructure/auth_ctx/handlers/log_in.py +++ b/src/app/infrastructure/auth_ctx/handlers/log_in.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from typing import Final -from app.core.commands.exceptions import UserNotFoundError from app.core.common.authorization.current_user_service import CurrentUserService from app.core.common.services.user import UserService from app.core.common.value_objects.raw_password import RawPassword @@ -15,7 +14,6 @@ from app.infrastructure.auth_ctx.sqla_user_tx_storage import AuthSqlaUserTxStorage AUTH_ACCOUNT_INACTIVE: Final[str] = "Your account is inactive. Please contact support." -AUTH_PASSWORD_INVALID: Final[str] = "Invalid password." # noqa: S105 logger = logging.getLogger(__name__) @@ -60,10 +58,10 @@ async def execute(self, request: LogInRequest) -> None: password = RawPassword(request.password) user = await self._user_tx_storage.get_by_username(username) if user is None: - raise UserNotFoundError + raise AuthenticationError if not await self._user_service.is_password_valid(user, password): - raise AuthenticationError(AUTH_PASSWORD_INVALID) + raise AuthenticationError if not user.is_active: raise AuthenticationError(AUTH_ACCOUNT_INACTIVE) diff --git a/src/app/presentation/http/account/log_in.py b/src/app/presentation/http/account/log_in.py index e5be4f1..e6fa49c 100644 --- a/src/app/presentation/http/account/log_in.py +++ b/src/app/presentation/http/account/log_in.py @@ -5,7 +5,6 @@ from fastapi import APIRouter, status from fastapi_error_map import ErrorAwareRouter -from app.core.commands.exceptions import UserNotFoundError from app.core.common.authorization.exceptions import AuthorizationError from app.core.common.exceptions import BusinessTypeError from app.infrastructure.adapters.exceptions import PasswordHasherBusyError @@ -26,7 +25,6 @@ def make_log_in_router() -> APIRouter: AuthorizationError: status.HTTP_403_FORBIDDEN, AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN, BusinessTypeError: status.HTTP_400_BAD_REQUEST, - UserNotFoundError: status.HTTP_404_NOT_FOUND, AuthenticationError: status.HTTP_401_UNAUTHORIZED, PasswordHasherBusyError: HTTP_503_SERVICE_UNAVAILABLE_RULE, }, From eeaaaee4e31c68e4998d7f0a51a93103f71ea471 Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:24:43 +0300 Subject: [PATCH 2/8] Fix docker entrypoint --- docker-entrypoint.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0aba861..bdb76c8 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,6 +8,11 @@ case "$1" in alembic upgrade head exec uvicorn app.main.run:make_app --factory --host 0.0.0.0 --port "$PORT" --reload ;; + pytest) + alembic upgrade head + shift + exec pytest "$@" + ;; *) exec "$@" ;; From 073e22c6dd3999e7be8f9c58166a90d71642c280 Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:40:10 +0300 Subject: [PATCH 3/8] Add sign up tests --- .../with_infra/account/__init__.py | 0 .../with_infra/account/constants.py | 3 + .../with_infra/account/test_sign_up.py | 74 +++++++++++++++++++ tests/integration/with_infra/conftest.py | 11 +++ tests/integration/with_infra/factories.py | 54 ++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 tests/integration/with_infra/account/__init__.py create mode 100644 tests/integration/with_infra/account/constants.py create mode 100644 tests/integration/with_infra/account/test_sign_up.py create mode 100644 tests/integration/with_infra/factories.py diff --git a/tests/integration/with_infra/account/__init__.py b/tests/integration/with_infra/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/with_infra/account/constants.py b/tests/integration/with_infra/account/constants.py new file mode 100644 index 0000000..7364fb8 --- /dev/null +++ b/tests/integration/with_infra/account/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SIGN_UP_ENDPOINT: Final[str] = "/api/v1/account/signup/" diff --git a/tests/integration/with_infra/account/test_sign_up.py b/tests/integration/with_infra/account/test_sign_up.py new file mode 100644 index 0000000..4233d8f --- /dev/null +++ b/tests/integration/with_infra/account/test_sign_up.py @@ -0,0 +1,74 @@ +import httpx +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.common.entities.types_ import UserRole +from app.core.common.entities.user import User +from app.core.common.services.user import UserService +from app.core.common.value_objects.raw_password import RawPassword +from app.core.common.value_objects.username import Username +from app.infrastructure.persistence_sqla.mappings.user import users_table +from tests.integration.with_infra.account.constants import SIGN_UP_ENDPOINT +from tests.integration.with_infra.factories import ( + create_raw_password, + create_raw_username, + create_user, +) + + +async def test_returns_204_and_creates_user( + it_client: httpx.AsyncClient, + it_session: AsyncSession, +) -> None: + username = create_raw_username() + password = create_raw_password() + payload = {"username": username, "password": password} + + r = await it_client.post(SIGN_UP_ENDPOINT, json=payload) + + assert r.status_code == 204 + stmt = select(User).where(users_table.c.username == username) + user = await it_session.scalar(stmt) + assert isinstance(user, User) + assert user.username.value == username + assert user.role == UserRole.USER + assert user.is_active is True + + +async def test_returns_400_when_username_is_too_short( + it_client: httpx.AsyncClient, +) -> None: + payload = {"username": "x" * (Username.MIN_LEN - 1), "password": create_raw_password()} + + r = await it_client.post(SIGN_UP_ENDPOINT, json=payload) + + assert r.status_code == 400 + + +async def test_returns_400_when_password_is_too_short( + it_client: httpx.AsyncClient, +) -> None: + payload = {"username": create_raw_username(), "password": "x" * (RawPassword.MIN_LEN - 1)} + + r = await it_client.post(SIGN_UP_ENDPOINT, json=payload) + + assert r.status_code == 400 + + +async def test_returns_409_when_username_already_exists( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + username = create_raw_username() + user = create_user(it_user_service, raw_username=username) + it_session.add(user) + await it_session.commit() + payload = {"username": username, "password": create_raw_password()} + + r = await it_client.post(SIGN_UP_ENDPOINT, json=payload) + + assert r.status_code == 409 + stmt = select(func.count()).select_from(User) + count = await it_session.scalar(stmt) + assert count == 1 diff --git a/tests/integration/with_infra/conftest.py b/tests/integration/with_infra/conftest.py index bf0f27d..f62549f 100644 --- a/tests/integration/with_infra/conftest.py +++ b/tests/integration/with_infra/conftest.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.config.settings import AppSettings +from app.core.common.services.user import UserService from app.infrastructure.persistence_sqla.registry import mapper_registry from app.main.run import make_app @@ -93,3 +94,13 @@ async def it_session( ) -> AsyncIterator[AsyncSession]: async with it_sessionmaker() as session: yield session + + +@pytest.fixture +async def it_user_service( + it_client: httpx.AsyncClient, + it_fastapi_app: FastAPI, +) -> UserService: + container = it_fastapi_app.state.dishka_container + user_service = await container.get(UserService) + return cast(UserService, user_service) diff --git a/tests/integration/with_infra/factories.py b/tests/integration/with_infra/factories.py new file mode 100644 index 0000000..338b805 --- /dev/null +++ b/tests/integration/with_infra/factories.py @@ -0,0 +1,54 @@ +import uuid +from datetime import UTC, datetime + +from app.core.common.entities.types_ import UserId, UserPasswordHash, UserRole +from app.core.common.entities.user import User +from app.core.common.services.user import UserService +from app.core.common.value_objects.username import Username +from app.core.common.value_objects.utc_datetime import UtcDatetime + + +def create_raw_user_id(value: uuid.UUID | None = None) -> uuid.UUID: + return value if value is not None else uuid.uuid4() + + +def create_raw_username(value: str | None = None) -> str: + return value if value is not None else f"user_{uuid.uuid4().hex[:8]}" + + +def create_raw_password(value: str | None = None) -> str: + return value if value is not None else uuid.uuid4().hex + + +def create_raw_password_hash(value: bytes | None = None) -> bytes: + return value if value is not None else uuid.uuid4().bytes + + +def create_raw_now(value: datetime | None = None) -> datetime: + if value is not None: + UtcDatetime(value) + return value + return datetime.now(UTC) + + +def create_user( + user_service: UserService, + *, + raw_user_id: uuid.UUID | None = None, + raw_username: str | None = None, + raw_password_hash: bytes | None = None, + role: UserRole = UserRole.USER, + is_active: bool = True, + raw_now: datetime | None = None, +) -> User: + now = UtcDatetime(raw_now if raw_now is not None else create_raw_now()) + return user_service.create_user( + user_id=UserId(raw_user_id if raw_user_id is not None else create_raw_user_id()), + username=Username(raw_username if raw_username is not None else create_raw_username()), + password_hash=UserPasswordHash( + raw_password_hash if raw_password_hash is not None else create_raw_password_hash() + ), + now=now, + role=role, + is_active=is_active, + ) From ff7d145cf84f3aa4be4c4ed49d9ba619e8e008d5 Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:13:39 +0300 Subject: [PATCH 4/8] Add log in tests --- .../with_infra/account/constants.py | 1 + .../with_infra/account/test_log_in.py | 90 +++++++++++++++++++ tests/integration/with_infra/factories.py | 22 +++++ 3 files changed, 113 insertions(+) create mode 100644 tests/integration/with_infra/account/test_log_in.py diff --git a/tests/integration/with_infra/account/constants.py b/tests/integration/with_infra/account/constants.py index 7364fb8..31667d9 100644 --- a/tests/integration/with_infra/account/constants.py +++ b/tests/integration/with_infra/account/constants.py @@ -1,3 +1,4 @@ from typing import Final SIGN_UP_ENDPOINT: Final[str] = "/api/v1/account/signup/" +LOG_IN_ENDPOINT: Final[str] = "/api/v1/account/login/" diff --git a/tests/integration/with_infra/account/test_log_in.py b/tests/integration/with_infra/account/test_log_in.py new file mode 100644 index 0000000..7a0bf7f --- /dev/null +++ b/tests/integration/with_infra/account/test_log_in.py @@ -0,0 +1,90 @@ +import httpx +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.common.services.user import UserService +from app.core.common.value_objects.raw_password import RawPassword +from app.core.common.value_objects.username import Username +from tests.integration.with_infra.account.constants import LOG_IN_ENDPOINT +from tests.integration.with_infra.factories import ( + create_raw_password, + create_raw_username, + create_user_with_password, +) + + +async def test_returns_204_and_sets_cookie( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + payload = {"username": user.username.value, "password": password} + + r = await it_client.post(LOG_IN_ENDPOINT, json=payload) + + assert r.status_code == 204 + assert "auth_token" in r.cookies + + +async def test_returns_400_when_username_is_too_short( + it_client: httpx.AsyncClient, +) -> None: + payload = {"username": "x" * (Username.MIN_LEN - 1), "password": create_raw_password()} + + r = await it_client.post(LOG_IN_ENDPOINT, json=payload) + + assert r.status_code == 400 + + +async def test_returns_400_when_password_is_too_short( + it_client: httpx.AsyncClient, +) -> None: + payload = {"username": create_raw_username(), "password": "x" * (RawPassword.MIN_LEN - 1)} + + r = await it_client.post(LOG_IN_ENDPOINT, json=payload) + + assert r.status_code == 400 + + +async def test_returns_401_when_user_does_not_exist( + it_client: httpx.AsyncClient, +) -> None: + payload = {"username": create_raw_username(), "password": create_raw_password()} + + r = await it_client.post(LOG_IN_ENDPOINT, json=payload) + + assert r.status_code == 401 + + +async def test_returns_401_when_password_is_wrong( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + user = await create_user_with_password(it_user_service) + it_session.add(user) + await it_session.commit() + payload = {"username": user.username.value, "password": create_raw_password()} + + r = await it_client.post(LOG_IN_ENDPOINT, json=payload) + + assert r.status_code == 401 + + +async def test_returns_401_when_user_is_inactive( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password, is_active=False) + it_session.add(user) + await it_session.commit() + payload = {"username": user.username.value, "password": password} + + r = await it_client.post(LOG_IN_ENDPOINT, json=payload) + + assert r.status_code == 401 diff --git a/tests/integration/with_infra/factories.py b/tests/integration/with_infra/factories.py index 338b805..a64b190 100644 --- a/tests/integration/with_infra/factories.py +++ b/tests/integration/with_infra/factories.py @@ -4,6 +4,7 @@ from app.core.common.entities.types_ import UserId, UserPasswordHash, UserRole from app.core.common.entities.user import User from app.core.common.services.user import UserService +from app.core.common.value_objects.raw_password import RawPassword from app.core.common.value_objects.username import Username from app.core.common.value_objects.utc_datetime import UtcDatetime @@ -52,3 +53,24 @@ def create_user( role=role, is_active=is_active, ) + + +async def create_user_with_password( + user_service: UserService, + *, + raw_user_id: uuid.UUID | None = None, + raw_username: str | None = None, + raw_password: str | None = None, + role: UserRole = UserRole.USER, + is_active: bool = True, + raw_now: datetime | None = None, +) -> User: + now = UtcDatetime(raw_now if raw_now is not None else create_raw_now()) + return await user_service.create_user_with_raw_password( + user_id=UserId(raw_user_id if raw_user_id is not None else create_raw_user_id()), + username=Username(raw_username if raw_username is not None else create_raw_username()), + raw_password=RawPassword(raw_password if raw_password is not None else create_raw_password()), + now=now, + role=role, + is_active=is_active, + ) From 8624c24553dd6f9f99369c1cf1aa4d4e33872302 Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:35:41 +0300 Subject: [PATCH 5/8] Add log out tests, fix existing --- .../with_infra/account/constants.py | 2 ++ .../with_infra/account/test_log_in.py | 4 +-- .../with_infra/account/test_log_out.py | 32 +++++++++++++++++++ .../with_infra/account/test_sign_up.py | 1 - .../integration/with_infra/authentication.py | 6 ++++ 5 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/integration/with_infra/account/test_log_out.py create mode 100644 tests/integration/with_infra/authentication.py diff --git a/tests/integration/with_infra/account/constants.py b/tests/integration/with_infra/account/constants.py index 31667d9..dbdae17 100644 --- a/tests/integration/with_infra/account/constants.py +++ b/tests/integration/with_infra/account/constants.py @@ -1,4 +1,6 @@ from typing import Final +AUTH_COOKIE_NAME: Final[str] = "auth_token" SIGN_UP_ENDPOINT: Final[str] = "/api/v1/account/signup/" LOG_IN_ENDPOINT: Final[str] = "/api/v1/account/login/" +LOG_OUT_ENDPOINT: Final[str] = "/api/v1/account/logout/" diff --git a/tests/integration/with_infra/account/test_log_in.py b/tests/integration/with_infra/account/test_log_in.py index 7a0bf7f..6e5b489 100644 --- a/tests/integration/with_infra/account/test_log_in.py +++ b/tests/integration/with_infra/account/test_log_in.py @@ -4,7 +4,7 @@ from app.core.common.services.user import UserService from app.core.common.value_objects.raw_password import RawPassword from app.core.common.value_objects.username import Username -from tests.integration.with_infra.account.constants import LOG_IN_ENDPOINT +from tests.integration.with_infra.account.constants import AUTH_COOKIE_NAME, LOG_IN_ENDPOINT from tests.integration.with_infra.factories import ( create_raw_password, create_raw_username, @@ -26,7 +26,7 @@ async def test_returns_204_and_sets_cookie( r = await it_client.post(LOG_IN_ENDPOINT, json=payload) assert r.status_code == 204 - assert "auth_token" in r.cookies + assert AUTH_COOKIE_NAME in r.cookies async def test_returns_400_when_username_is_too_short( diff --git a/tests/integration/with_infra/account/test_log_out.py b/tests/integration/with_infra/account/test_log_out.py new file mode 100644 index 0000000..02ad78f --- /dev/null +++ b/tests/integration/with_infra/account/test_log_out.py @@ -0,0 +1,32 @@ +import httpx +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.common.services.user import UserService +from tests.integration.with_infra.account.constants import AUTH_COOKIE_NAME, LOG_OUT_ENDPOINT +from tests.integration.with_infra.authentication import authenticate +from tests.integration.with_infra.factories import create_raw_password, create_user_with_password + + +async def test_returns_204_and_clears_cookie( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + await authenticate(it_client, user.username.value, password) + + r = await it_client.delete(LOG_OUT_ENDPOINT) + + assert r.status_code == 204 + assert AUTH_COOKIE_NAME not in it_client.cookies + + +async def test_returns_401_when_not_authenticated( + it_client: httpx.AsyncClient, +) -> None: + r = await it_client.delete(LOG_OUT_ENDPOINT) + + assert r.status_code == 401 diff --git a/tests/integration/with_infra/account/test_sign_up.py b/tests/integration/with_infra/account/test_sign_up.py index 4233d8f..b262b4c 100644 --- a/tests/integration/with_infra/account/test_sign_up.py +++ b/tests/integration/with_infra/account/test_sign_up.py @@ -30,7 +30,6 @@ async def test_returns_204_and_creates_user( stmt = select(User).where(users_table.c.username == username) user = await it_session.scalar(stmt) assert isinstance(user, User) - assert user.username.value == username assert user.role == UserRole.USER assert user.is_active is True diff --git a/tests/integration/with_infra/authentication.py b/tests/integration/with_infra/authentication.py new file mode 100644 index 0000000..0e96e41 --- /dev/null +++ b/tests/integration/with_infra/authentication.py @@ -0,0 +1,6 @@ +import httpx + + +async def authenticate(client: httpx.AsyncClient, username: str, password: str) -> None: + r = await client.post("/api/v1/account/login/", json={"username": username, "password": password}) + assert r.status_code == 204 From c25a45b0fafb2ed463cb94be5bbe16cc71f7f1be Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:43:02 +0300 Subject: [PATCH 6/8] Add reauthentication tests --- .../with_infra/account/test_log_in.py | 18 ++++++++++++++++++ .../with_infra/account/test_sign_up.py | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/tests/integration/with_infra/account/test_log_in.py b/tests/integration/with_infra/account/test_log_in.py index 6e5b489..87b03e2 100644 --- a/tests/integration/with_infra/account/test_log_in.py +++ b/tests/integration/with_infra/account/test_log_in.py @@ -5,6 +5,7 @@ from app.core.common.value_objects.raw_password import RawPassword from app.core.common.value_objects.username import Username from tests.integration.with_infra.account.constants import AUTH_COOKIE_NAME, LOG_IN_ENDPOINT +from tests.integration.with_infra.authentication import authenticate from tests.integration.with_infra.factories import ( create_raw_password, create_raw_username, @@ -88,3 +89,20 @@ async def test_returns_401_when_user_is_inactive( r = await it_client.post(LOG_IN_ENDPOINT, json=payload) assert r.status_code == 401 + + +async def test_returns_403_when_already_authenticated( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + await authenticate(it_client, user.username.value, password) + payload = {"username": user.username.value, "password": password} + + r = await it_client.post(LOG_IN_ENDPOINT, json=payload) + + assert r.status_code == 403 diff --git a/tests/integration/with_infra/account/test_sign_up.py b/tests/integration/with_infra/account/test_sign_up.py index b262b4c..c72d2ef 100644 --- a/tests/integration/with_infra/account/test_sign_up.py +++ b/tests/integration/with_infra/account/test_sign_up.py @@ -9,10 +9,12 @@ from app.core.common.value_objects.username import Username from app.infrastructure.persistence_sqla.mappings.user import users_table from tests.integration.with_infra.account.constants import SIGN_UP_ENDPOINT +from tests.integration.with_infra.authentication import authenticate from tests.integration.with_infra.factories import ( create_raw_password, create_raw_username, create_user, + create_user_with_password, ) @@ -71,3 +73,20 @@ async def test_returns_409_when_username_already_exists( stmt = select(func.count()).select_from(User) count = await it_session.scalar(stmt) assert count == 1 + + +async def test_returns_403_when_already_authenticated( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + await authenticate(it_client, user.username.value, password) + payload = {"username": create_raw_username(), "password": create_raw_password()} + + r = await it_client.post(SIGN_UP_ENDPOINT, json=payload) + + assert r.status_code == 403 From 082211e27ee2fe7150de38635222137a497cb89f Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:50:03 +0300 Subject: [PATCH 7/8] Add password change tests, fix existing --- .../with_infra/account/constants.py | 1 + .../account/test_change_password.py | 89 +++++++++++++++++++ .../integration/with_infra/authentication.py | 4 +- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/integration/with_infra/account/test_change_password.py diff --git a/tests/integration/with_infra/account/constants.py b/tests/integration/with_infra/account/constants.py index dbdae17..7784738 100644 --- a/tests/integration/with_infra/account/constants.py +++ b/tests/integration/with_infra/account/constants.py @@ -4,3 +4,4 @@ SIGN_UP_ENDPOINT: Final[str] = "/api/v1/account/signup/" LOG_IN_ENDPOINT: Final[str] = "/api/v1/account/login/" LOG_OUT_ENDPOINT: Final[str] = "/api/v1/account/logout/" +CHANGE_PASSWORD_ENDPOINT: Final[str] = "/api/v1/account/password/" diff --git a/tests/integration/with_infra/account/test_change_password.py b/tests/integration/with_infra/account/test_change_password.py new file mode 100644 index 0000000..c2484ec --- /dev/null +++ b/tests/integration/with_infra/account/test_change_password.py @@ -0,0 +1,89 @@ +import httpx +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.common.services.user import UserService +from app.core.common.value_objects.raw_password import RawPassword +from tests.integration.with_infra.account.constants import CHANGE_PASSWORD_ENDPOINT +from tests.integration.with_infra.authentication import authenticate +from tests.integration.with_infra.factories import create_raw_password, create_user_with_password + + +async def test_returns_204_and_changes_password( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + await authenticate(it_client, user.username.value, password) + old_password_hash = user.password_hash + payload = {"current_password": password, "new_password": create_raw_password()} + + r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload) + + assert r.status_code == 204 + await it_session.refresh(user) + assert user.password_hash != old_password_hash + + +async def test_returns_400_when_new_password_is_too_short( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + await authenticate(it_client, user.username.value, password) + payload = {"current_password": password, "new_password": "x" * (RawPassword.MIN_LEN - 1)} + + r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload) + + assert r.status_code == 400 + + +async def test_returns_400_when_new_password_equals_current( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + await authenticate(it_client, user.username.value, password) + payload = {"current_password": password, "new_password": password} + + r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload) + + assert r.status_code == 400 + + +async def test_returns_401_when_not_authenticated( + it_client: httpx.AsyncClient, +) -> None: + payload = {"current_password": create_raw_password(), "new_password": create_raw_password()} + + r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload) + + assert r.status_code == 401 + + +async def test_returns_403_when_current_password_is_wrong( + it_client: httpx.AsyncClient, + it_session: AsyncSession, + it_user_service: UserService, +) -> None: + password = create_raw_password() + user = await create_user_with_password(it_user_service, raw_password=password) + it_session.add(user) + await it_session.commit() + await authenticate(it_client, user.username.value, password) + payload = {"current_password": create_raw_password(), "new_password": create_raw_password()} + + r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload) + + assert r.status_code == 403 diff --git a/tests/integration/with_infra/authentication.py b/tests/integration/with_infra/authentication.py index 0e96e41..005ba14 100644 --- a/tests/integration/with_infra/authentication.py +++ b/tests/integration/with_infra/authentication.py @@ -1,6 +1,8 @@ import httpx +from tests.integration.with_infra.account.constants import LOG_IN_ENDPOINT + async def authenticate(client: httpx.AsyncClient, username: str, password: str) -> None: - r = await client.post("/api/v1/account/login/", json={"username": username, "password": password}) + r = await client.post(LOG_IN_ENDPOINT, json={"username": username, "password": password}) assert r.status_code == 204 From a9a1cdb2d9e5304b177ee6e7fe346bf3fa7daaf1 Mon Sep 17 00:00:00 2001 From: ivan-borovets <130386813+ivan-borovets@users.noreply.github.com> Date: Thu, 26 Mar 2026 00:03:29 +0300 Subject: [PATCH 8/8] Add smoke tests --- tests/smoke/conftest.py | 25 ++++++++++++++++ tests/smoke/presentation/__init__.py | 0 tests/smoke/presentation/http/__init__.py | 0 .../presentation/http/test_health_router.py | 29 +++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 tests/smoke/conftest.py create mode 100644 tests/smoke/presentation/__init__.py create mode 100644 tests/smoke/presentation/http/__init__.py create mode 100644 tests/smoke/presentation/http/test_health_router.py diff --git a/tests/smoke/conftest.py b/tests/smoke/conftest.py new file mode 100644 index 0000000..972b935 --- /dev/null +++ b/tests/smoke/conftest.py @@ -0,0 +1,25 @@ +from collections.abc import AsyncIterator + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from fastapi import FastAPI + +from app.main.run import make_app + + +@pytest.fixture +def smoke_app() -> FastAPI: + return make_app() + + +@pytest.fixture +async def smoke_client(smoke_app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: + async with ( + LifespanManager(smoke_app, startup_timeout=60) as manager, + httpx.AsyncClient( + transport=httpx.ASGITransport(app=manager.app), + base_url="http://test", + ) as client, + ): + yield client diff --git a/tests/smoke/presentation/__init__.py b/tests/smoke/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/smoke/presentation/http/__init__.py b/tests/smoke/presentation/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/smoke/presentation/http/test_health_router.py b/tests/smoke/presentation/http/test_health_router.py new file mode 100644 index 0000000..c02da32 --- /dev/null +++ b/tests/smoke/presentation/http/test_health_router.py @@ -0,0 +1,29 @@ +import httpx +import pytest +from fastapi import FastAPI, status + + +async def test_liveness_probe(smoke_client: httpx.AsyncClient) -> None: + r = await smoke_client.get("/livez/") + + assert r.status_code == status.HTTP_200_OK + assert r.json() == "OK" + + +async def test_readiness_probe(smoke_client: httpx.AsyncClient) -> None: + r = await smoke_client.get("/healthz/") + + assert r.status_code == status.HTTP_200_OK + assert r.json() == "OK" + + +async def test_error_handling_prod_contract( + smoke_client: httpx.AsyncClient, + smoke_app: FastAPI, +) -> None: + if smoke_app.debug: + pytest.skip("Not applicable when DEBUG=true") + + r = await smoke_client.get("/nonexistent/") + + assert r.status_code == status.HTTP_404_NOT_FOUND