diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0aba861d..bdb76c84 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 "$@" ;; diff --git a/src/app/core/commands/set_user_password.py b/src/app/core/commands/set_user_password.py index fbc18387..ad83ee0d 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 5f9eb101..72f330e4 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 e5be4f1c..e6fa49c2 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, }, diff --git a/tests/integration/with_infra/account/__init__.py b/tests/integration/with_infra/account/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/with_infra/account/constants.py b/tests/integration/with_infra/account/constants.py new file mode 100644 index 00000000..77847389 --- /dev/null +++ b/tests/integration/with_infra/account/constants.py @@ -0,0 +1,7 @@ +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/" +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 00000000..c2484ec0 --- /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/account/test_log_in.py b/tests/integration/with_infra/account/test_log_in.py new file mode 100644 index 00000000..87b03e2b --- /dev/null +++ b/tests/integration/with_infra/account/test_log_in.py @@ -0,0 +1,108 @@ +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 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, + 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_COOKIE_NAME 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 + + +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_log_out.py b/tests/integration/with_infra/account/test_log_out.py new file mode 100644 index 00000000..02ad78fb --- /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 new file mode 100644 index 00000000..c72d2ef3 --- /dev/null +++ b/tests/integration/with_infra/account/test_sign_up.py @@ -0,0 +1,92 @@ +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.authentication import authenticate +from tests.integration.with_infra.factories import ( + create_raw_password, + create_raw_username, + create_user, + create_user_with_password, +) + + +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.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 + + +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 diff --git a/tests/integration/with_infra/authentication.py b/tests/integration/with_infra/authentication.py new file mode 100644 index 00000000..005ba14b --- /dev/null +++ b/tests/integration/with_infra/authentication.py @@ -0,0 +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(LOG_IN_ENDPOINT, json={"username": username, "password": password}) + assert r.status_code == 204 diff --git a/tests/integration/with_infra/conftest.py b/tests/integration/with_infra/conftest.py index bf0f27d4..f62549f7 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 00000000..a64b1902 --- /dev/null +++ b/tests/integration/with_infra/factories.py @@ -0,0 +1,76 @@ +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.raw_password import RawPassword +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, + ) + + +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, + ) diff --git a/tests/smoke/conftest.py b/tests/smoke/conftest.py new file mode 100644 index 00000000..972b935c --- /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 00000000..e69de29b diff --git a/tests/smoke/presentation/http/__init__.py b/tests/smoke/presentation/http/__init__.py new file mode 100644 index 00000000..e69de29b 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 00000000..c02da325 --- /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