Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ COPY pyproject.toml uv.lock README.md ./
RUN if [ "${ENVIRONMENT}" = "prod" ]; then \
uv sync --frozen --no-cache --no-dev --no-install-project; \
else \
uv sync --frozen --dev --no-install-project; \
uv sync --frozen --no-cache --dev --no-install-project; \
fi

COPY . .

RUN if [ "${ENVIRONMENT}" = "prod" ]; then \
uv sync --frozen --no-cache --no-dev; \
else \
uv sync --frozen --dev; \
uv sync --frozen --no-cache --dev; \
fi

RUN groupadd -r runner
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ uvicorn app.main.run:make_app --host 0.0.0.0 --port 8000 --reload
```
Full API access:
- create user via sign up
- set its role to `SUPER_ADMIN` manually in DB
- set its role to `super_admin` manually in DB
- log in as super admin

Stop
Expand Down
22 changes: 11 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,33 @@ dependencies = [
"alembic-postgresql-enum==1.10.0",
"bcrypt==5.0.0",
"dishka==1.9.1",
"fastapi==0.133.1",
"fastapi==0.135.3",
"fastapi-error-map==0.9.10",
"psycopg[binary]==3.3.3",
"pydantic-settings==2.13.1",
"pyjwt[crypto]==2.11.0",
"sqlalchemy[mypy]==2.0.47",
"pyjwt[crypto]==2.12.1",
"sqlalchemy[mypy]==2.0.48",
"uuid-utils==0.14.1",
"uvicorn==0.41.0",
"uvicorn==0.42.0",
]

[dependency-groups]
dev = [
"asgi-lifespan==2.1.0",
"coverage==7.13.4",
"deptry==0.24.0",
"coverage==7.13.5",
"deptry==0.25.1",
"httpx==0.28.1",
"import-linter==2.10",
"import-linter==2.11",
"line-profiler==5.0.2",
"mypy==1.19.1",
"mypy==1.20.0",
"pip-audit==2.10.0",
"pre-commit==4.5.1",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-cov==7.0.0",
"ruff==0.15.4",
"pytest-cov==7.1.0",
"ruff==0.15.8",
"slotscheck==0.19.1",
"tombi==0.7.33",
"tombi==0.9.13",
]

[build-system]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""users

Revision ID: c64b121a3428
Revision ID: 0e6c649ac887
Revises:
Create Date: 2026-03-02 22:15:18.425263
Create Date: 2026-04-01 22:28:15.224058

"""

Expand All @@ -13,7 +13,7 @@


# revision identifiers, used by Alembic.
revision: str = "c64b121a3428"
revision: str = "0e6c649ac887"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
Expand All @@ -26,7 +26,7 @@ def upgrade() -> None:
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("username", sa.String(length=20), nullable=False),
sa.Column("password_hash", sa.LargeBinary(), nullable=False),
sa.Column("role", sa.Enum("SUPER_ADMIN", "ADMIN", "USER", name="user_role", native_enum=False), nullable=False),
sa.Column("role", sa.Enum("super_admin", "admin", "user", name="user_role", native_enum=False), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""auth_sessions

Revision ID: 7b50faaefa7c
Revises: c64b121a3428
Create Date: 2026-03-02 23:06:28.995808
Revision ID: c025baa8044e
Revises: 0e6c649ac887
Create Date: 2026-04-01 22:30:11.002095

"""

Expand All @@ -13,8 +13,8 @@


# revision identifiers, used by Alembic.
revision: str = "7b50faaefa7c"
down_revision: Union[str, Sequence[str], None] = "c64b121a3428"
revision: str = "c025baa8044e"
down_revision: Union[str, Sequence[str], None] = "0e6c649ac887"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

Expand Down
9 changes: 9 additions & 0 deletions src/app/infrastructure/persistence_sqla/mappings/user.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from enum import StrEnum

from sqlalchemy import UUID, Boolean, Column, DateTime, Enum, LargeBinary, String, Table
from sqlalchemy.orm import composite

Expand All @@ -7,6 +9,12 @@
from app.core.common.value_objects.utc_datetime import UtcDatetime
from app.infrastructure.persistence_sqla.registry import mapper_registry


def get_strenum_values(enum_cls: type[StrEnum]) -> list[str]:
"""Return member values instead of member names for SQLAlchemy Enum storage."""
return [e.value for e in enum_cls]


users_table = Table(
"users",
mapper_registry.metadata,
Expand All @@ -20,6 +28,7 @@
name="user_role",
native_enum=False,
validate_strings=True,
values_callable=get_strenum_values,
),
nullable=False,
),
Expand Down
22 changes: 22 additions & 0 deletions tests/integration/with_infra/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,25 @@ async def create_user_with_password(
role=role,
is_active=is_active,
)


async def create_super_admin_with_password(
user_service: UserService,
*,
raw_user_id: uuid.UUID | None = None,
raw_username: str | None = None,
raw_password: str | None = None,
is_active: bool = True,
raw_now: datetime | None = None,
) -> User:
"""System role is not assignable via UserService; create as USER, then promote."""
user = await create_user_with_password(
user_service,
raw_user_id=raw_user_id,
raw_username=raw_username,
raw_password=raw_password,
is_active=is_active,
raw_now=raw_now,
)
user.role = UserRole.SUPER_ADMIN
return user
Empty file.
41 changes: 41 additions & 0 deletions tests/integration/with_infra/users/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import httpx
import pytest
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 tests.integration.with_infra.authentication import authenticate
from tests.integration.with_infra.factories import (
create_raw_password,
create_super_admin_with_password,
create_user_with_password,
)


@pytest.fixture
async def it_admin(
it_client: httpx.AsyncClient,
it_session: AsyncSession,
it_user_service: UserService,
) -> User:
password = create_raw_password()
admin = await create_user_with_password(it_user_service, raw_password=password, role=UserRole.ADMIN)
it_session.add(admin)
await it_session.commit()
await authenticate(it_client, admin.username.value, password)
return admin


@pytest.fixture
async def it_super_admin(
it_client: httpx.AsyncClient,
it_session: AsyncSession,
it_user_service: UserService,
) -> User:
password = create_raw_password()
super_admin = await create_super_admin_with_password(it_user_service, raw_password=password)
it_session.add(super_admin)
await it_session.commit()
await authenticate(it_client, super_admin.username.value, password)
return super_admin
3 changes: 3 additions & 0 deletions tests/integration/with_infra/users/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from typing import Final

USERS_ENDPOINT: Final[str] = "/api/v1/users/"
97 changes: 97 additions & 0 deletions tests/integration/with_infra/users/test_activate_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import httpx
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 tests.integration.with_infra.authentication import authenticate
from tests.integration.with_infra.factories import (
create_raw_password,
create_raw_user_id,
create_user,
create_user_with_password,
)
from tests.integration.with_infra.users.constants import USERS_ENDPOINT


async def test_returns_204_and_activates_user(
it_client: httpx.AsyncClient,
it_session: AsyncSession,
it_admin: User,
it_user_service: UserService,
) -> None:
target = create_user(it_user_service, is_active=False)
it_session.add(target)
await it_session.commit()

r = await it_client.put(f"{USERS_ENDPOINT}{target.id_}/activation/")

assert r.status_code == 204
await it_session.refresh(target)
assert target.is_active is True


async def test_returns_204_when_user_already_active(
it_client: httpx.AsyncClient,
it_session: AsyncSession,
it_admin: User,
it_user_service: UserService,
) -> None:
target = create_user(it_user_service, is_active=True)
it_session.add(target)
await it_session.commit()

r = await it_client.put(f"{USERS_ENDPOINT}{target.id_}/activation/")

assert r.status_code == 204
await it_session.refresh(target)
assert target.is_active is True


async def test_returns_401_when_not_authenticated(
it_client: httpx.AsyncClient,
) -> None:
r = await it_client.put(f"{USERS_ENDPOINT}{create_raw_user_id()}/activation/")

assert r.status_code == 401


async def test_returns_403_when_user_role(
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)
target = create_user(it_user_service, is_active=False)
it_session.add_all([user, target])
await it_session.commit()
await authenticate(it_client, user.username.value, password)

r = await it_client.put(f"{USERS_ENDPOINT}{target.id_}/activation/")

assert r.status_code == 403


async def test_returns_403_when_admin_targets_admin(
it_client: httpx.AsyncClient,
it_session: AsyncSession,
it_admin: User,
it_user_service: UserService,
) -> None:
other_admin = create_user(it_user_service, role=UserRole.ADMIN, is_active=False)
it_session.add(other_admin)
await it_session.commit()

r = await it_client.put(f"{USERS_ENDPOINT}{other_admin.id_}/activation/")

assert r.status_code == 403


async def test_returns_404_when_user_not_found(
it_client: httpx.AsyncClient,
it_admin: User,
) -> None:
r = await it_client.put(f"{USERS_ENDPOINT}{create_raw_user_id()}/activation/")

assert r.status_code == 404
Loading