Skip to content

Commit b7caf01

Browse files
Add some tests (#96)
* Fix user enumeration * Fix docker entrypoint * Add sign up tests * Add log in tests * Add log out tests, fix existing * Add reauthentication tests * Add password change tests, fix existing * Add smoke tests
1 parent dd13a4e commit b7caf01

17 files changed

Lines changed: 484 additions & 7 deletions

File tree

docker-entrypoint.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ case "$1" in
88
alembic upgrade head
99
exec uvicorn app.main.run:make_app --factory --host 0.0.0.0 --port "$PORT" --reload
1010
;;
11+
pytest)
12+
alembic upgrade head
13+
shift
14+
exec pytest "$@"
15+
;;
1116
*)
1217
exec "$@"
1318
;;

src/app/core/commands/set_user_password.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ async def execute(self, request: SetUserPasswordRequest) -> None:
7474
target=user,
7575
),
7676
)
77-
7877
await self._user_service.change_password(
7978
user,
8079
password,

src/app/infrastructure/auth_ctx/handlers/log_in.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from dataclasses import dataclass
33
from typing import Final
44

5-
from app.core.commands.exceptions import UserNotFoundError
65
from app.core.common.authorization.current_user_service import CurrentUserService
76
from app.core.common.services.user import UserService
87
from app.core.common.value_objects.raw_password import RawPassword
@@ -15,7 +14,6 @@
1514
from app.infrastructure.auth_ctx.sqla_user_tx_storage import AuthSqlaUserTxStorage
1615

1716
AUTH_ACCOUNT_INACTIVE: Final[str] = "Your account is inactive. Please contact support."
18-
AUTH_PASSWORD_INVALID: Final[str] = "Invalid password." # noqa: S105
1917

2018
logger = logging.getLogger(__name__)
2119

@@ -60,10 +58,10 @@ async def execute(self, request: LogInRequest) -> None:
6058
password = RawPassword(request.password)
6159
user = await self._user_tx_storage.get_by_username(username)
6260
if user is None:
63-
raise UserNotFoundError
61+
raise AuthenticationError
6462

6563
if not await self._user_service.is_password_valid(user, password):
66-
raise AuthenticationError(AUTH_PASSWORD_INVALID)
64+
raise AuthenticationError
6765

6866
if not user.is_active:
6967
raise AuthenticationError(AUTH_ACCOUNT_INACTIVE)

src/app/presentation/http/account/log_in.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from fastapi import APIRouter, status
66
from fastapi_error_map import ErrorAwareRouter
77

8-
from app.core.commands.exceptions import UserNotFoundError
98
from app.core.common.authorization.exceptions import AuthorizationError
109
from app.core.common.exceptions import BusinessTypeError
1110
from app.infrastructure.adapters.exceptions import PasswordHasherBusyError
@@ -26,7 +25,6 @@ def make_log_in_router() -> APIRouter:
2625
AuthorizationError: status.HTTP_403_FORBIDDEN,
2726
AlreadyAuthenticatedError: status.HTTP_403_FORBIDDEN,
2827
BusinessTypeError: status.HTTP_400_BAD_REQUEST,
29-
UserNotFoundError: status.HTTP_404_NOT_FOUND,
3028
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
3129
PasswordHasherBusyError: HTTP_503_SERVICE_UNAVAILABLE_RULE,
3230
},

tests/integration/with_infra/account/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from typing import Final
2+
3+
AUTH_COOKIE_NAME: Final[str] = "auth_token"
4+
SIGN_UP_ENDPOINT: Final[str] = "/api/v1/account/signup/"
5+
LOG_IN_ENDPOINT: Final[str] = "/api/v1/account/login/"
6+
LOG_OUT_ENDPOINT: Final[str] = "/api/v1/account/logout/"
7+
CHANGE_PASSWORD_ENDPOINT: Final[str] = "/api/v1/account/password/"
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import httpx
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
4+
from app.core.common.services.user import UserService
5+
from app.core.common.value_objects.raw_password import RawPassword
6+
from tests.integration.with_infra.account.constants import CHANGE_PASSWORD_ENDPOINT
7+
from tests.integration.with_infra.authentication import authenticate
8+
from tests.integration.with_infra.factories import create_raw_password, create_user_with_password
9+
10+
11+
async def test_returns_204_and_changes_password(
12+
it_client: httpx.AsyncClient,
13+
it_session: AsyncSession,
14+
it_user_service: UserService,
15+
) -> None:
16+
password = create_raw_password()
17+
user = await create_user_with_password(it_user_service, raw_password=password)
18+
it_session.add(user)
19+
await it_session.commit()
20+
await authenticate(it_client, user.username.value, password)
21+
old_password_hash = user.password_hash
22+
payload = {"current_password": password, "new_password": create_raw_password()}
23+
24+
r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload)
25+
26+
assert r.status_code == 204
27+
await it_session.refresh(user)
28+
assert user.password_hash != old_password_hash
29+
30+
31+
async def test_returns_400_when_new_password_is_too_short(
32+
it_client: httpx.AsyncClient,
33+
it_session: AsyncSession,
34+
it_user_service: UserService,
35+
) -> None:
36+
password = create_raw_password()
37+
user = await create_user_with_password(it_user_service, raw_password=password)
38+
it_session.add(user)
39+
await it_session.commit()
40+
await authenticate(it_client, user.username.value, password)
41+
payload = {"current_password": password, "new_password": "x" * (RawPassword.MIN_LEN - 1)}
42+
43+
r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload)
44+
45+
assert r.status_code == 400
46+
47+
48+
async def test_returns_400_when_new_password_equals_current(
49+
it_client: httpx.AsyncClient,
50+
it_session: AsyncSession,
51+
it_user_service: UserService,
52+
) -> None:
53+
password = create_raw_password()
54+
user = await create_user_with_password(it_user_service, raw_password=password)
55+
it_session.add(user)
56+
await it_session.commit()
57+
await authenticate(it_client, user.username.value, password)
58+
payload = {"current_password": password, "new_password": password}
59+
60+
r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload)
61+
62+
assert r.status_code == 400
63+
64+
65+
async def test_returns_401_when_not_authenticated(
66+
it_client: httpx.AsyncClient,
67+
) -> None:
68+
payload = {"current_password": create_raw_password(), "new_password": create_raw_password()}
69+
70+
r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload)
71+
72+
assert r.status_code == 401
73+
74+
75+
async def test_returns_403_when_current_password_is_wrong(
76+
it_client: httpx.AsyncClient,
77+
it_session: AsyncSession,
78+
it_user_service: UserService,
79+
) -> None:
80+
password = create_raw_password()
81+
user = await create_user_with_password(it_user_service, raw_password=password)
82+
it_session.add(user)
83+
await it_session.commit()
84+
await authenticate(it_client, user.username.value, password)
85+
payload = {"current_password": create_raw_password(), "new_password": create_raw_password()}
86+
87+
r = await it_client.put(CHANGE_PASSWORD_ENDPOINT, json=payload)
88+
89+
assert r.status_code == 403
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import httpx
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
4+
from app.core.common.services.user import UserService
5+
from app.core.common.value_objects.raw_password import RawPassword
6+
from app.core.common.value_objects.username import Username
7+
from tests.integration.with_infra.account.constants import AUTH_COOKIE_NAME, LOG_IN_ENDPOINT
8+
from tests.integration.with_infra.authentication import authenticate
9+
from tests.integration.with_infra.factories import (
10+
create_raw_password,
11+
create_raw_username,
12+
create_user_with_password,
13+
)
14+
15+
16+
async def test_returns_204_and_sets_cookie(
17+
it_client: httpx.AsyncClient,
18+
it_session: AsyncSession,
19+
it_user_service: UserService,
20+
) -> None:
21+
password = create_raw_password()
22+
user = await create_user_with_password(it_user_service, raw_password=password)
23+
it_session.add(user)
24+
await it_session.commit()
25+
payload = {"username": user.username.value, "password": password}
26+
27+
r = await it_client.post(LOG_IN_ENDPOINT, json=payload)
28+
29+
assert r.status_code == 204
30+
assert AUTH_COOKIE_NAME in r.cookies
31+
32+
33+
async def test_returns_400_when_username_is_too_short(
34+
it_client: httpx.AsyncClient,
35+
) -> None:
36+
payload = {"username": "x" * (Username.MIN_LEN - 1), "password": create_raw_password()}
37+
38+
r = await it_client.post(LOG_IN_ENDPOINT, json=payload)
39+
40+
assert r.status_code == 400
41+
42+
43+
async def test_returns_400_when_password_is_too_short(
44+
it_client: httpx.AsyncClient,
45+
) -> None:
46+
payload = {"username": create_raw_username(), "password": "x" * (RawPassword.MIN_LEN - 1)}
47+
48+
r = await it_client.post(LOG_IN_ENDPOINT, json=payload)
49+
50+
assert r.status_code == 400
51+
52+
53+
async def test_returns_401_when_user_does_not_exist(
54+
it_client: httpx.AsyncClient,
55+
) -> None:
56+
payload = {"username": create_raw_username(), "password": create_raw_password()}
57+
58+
r = await it_client.post(LOG_IN_ENDPOINT, json=payload)
59+
60+
assert r.status_code == 401
61+
62+
63+
async def test_returns_401_when_password_is_wrong(
64+
it_client: httpx.AsyncClient,
65+
it_session: AsyncSession,
66+
it_user_service: UserService,
67+
) -> None:
68+
user = await create_user_with_password(it_user_service)
69+
it_session.add(user)
70+
await it_session.commit()
71+
payload = {"username": user.username.value, "password": create_raw_password()}
72+
73+
r = await it_client.post(LOG_IN_ENDPOINT, json=payload)
74+
75+
assert r.status_code == 401
76+
77+
78+
async def test_returns_401_when_user_is_inactive(
79+
it_client: httpx.AsyncClient,
80+
it_session: AsyncSession,
81+
it_user_service: UserService,
82+
) -> None:
83+
password = create_raw_password()
84+
user = await create_user_with_password(it_user_service, raw_password=password, is_active=False)
85+
it_session.add(user)
86+
await it_session.commit()
87+
payload = {"username": user.username.value, "password": password}
88+
89+
r = await it_client.post(LOG_IN_ENDPOINT, json=payload)
90+
91+
assert r.status_code == 401
92+
93+
94+
async def test_returns_403_when_already_authenticated(
95+
it_client: httpx.AsyncClient,
96+
it_session: AsyncSession,
97+
it_user_service: UserService,
98+
) -> None:
99+
password = create_raw_password()
100+
user = await create_user_with_password(it_user_service, raw_password=password)
101+
it_session.add(user)
102+
await it_session.commit()
103+
await authenticate(it_client, user.username.value, password)
104+
payload = {"username": user.username.value, "password": password}
105+
106+
r = await it_client.post(LOG_IN_ENDPOINT, json=payload)
107+
108+
assert r.status_code == 403
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import httpx
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
4+
from app.core.common.services.user import UserService
5+
from tests.integration.with_infra.account.constants import AUTH_COOKIE_NAME, LOG_OUT_ENDPOINT
6+
from tests.integration.with_infra.authentication import authenticate
7+
from tests.integration.with_infra.factories import create_raw_password, create_user_with_password
8+
9+
10+
async def test_returns_204_and_clears_cookie(
11+
it_client: httpx.AsyncClient,
12+
it_session: AsyncSession,
13+
it_user_service: UserService,
14+
) -> None:
15+
password = create_raw_password()
16+
user = await create_user_with_password(it_user_service, raw_password=password)
17+
it_session.add(user)
18+
await it_session.commit()
19+
await authenticate(it_client, user.username.value, password)
20+
21+
r = await it_client.delete(LOG_OUT_ENDPOINT)
22+
23+
assert r.status_code == 204
24+
assert AUTH_COOKIE_NAME not in it_client.cookies
25+
26+
27+
async def test_returns_401_when_not_authenticated(
28+
it_client: httpx.AsyncClient,
29+
) -> None:
30+
r = await it_client.delete(LOG_OUT_ENDPOINT)
31+
32+
assert r.status_code == 401
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import httpx
2+
from sqlalchemy import func, select
3+
from sqlalchemy.ext.asyncio import AsyncSession
4+
5+
from app.core.common.entities.types_ import UserRole
6+
from app.core.common.entities.user import User
7+
from app.core.common.services.user import UserService
8+
from app.core.common.value_objects.raw_password import RawPassword
9+
from app.core.common.value_objects.username import Username
10+
from app.infrastructure.persistence_sqla.mappings.user import users_table
11+
from tests.integration.with_infra.account.constants import SIGN_UP_ENDPOINT
12+
from tests.integration.with_infra.authentication import authenticate
13+
from tests.integration.with_infra.factories import (
14+
create_raw_password,
15+
create_raw_username,
16+
create_user,
17+
create_user_with_password,
18+
)
19+
20+
21+
async def test_returns_204_and_creates_user(
22+
it_client: httpx.AsyncClient,
23+
it_session: AsyncSession,
24+
) -> None:
25+
username = create_raw_username()
26+
password = create_raw_password()
27+
payload = {"username": username, "password": password}
28+
29+
r = await it_client.post(SIGN_UP_ENDPOINT, json=payload)
30+
31+
assert r.status_code == 204
32+
stmt = select(User).where(users_table.c.username == username)
33+
user = await it_session.scalar(stmt)
34+
assert isinstance(user, User)
35+
assert user.role == UserRole.USER
36+
assert user.is_active is True
37+
38+
39+
async def test_returns_400_when_username_is_too_short(
40+
it_client: httpx.AsyncClient,
41+
) -> None:
42+
payload = {"username": "x" * (Username.MIN_LEN - 1), "password": create_raw_password()}
43+
44+
r = await it_client.post(SIGN_UP_ENDPOINT, json=payload)
45+
46+
assert r.status_code == 400
47+
48+
49+
async def test_returns_400_when_password_is_too_short(
50+
it_client: httpx.AsyncClient,
51+
) -> None:
52+
payload = {"username": create_raw_username(), "password": "x" * (RawPassword.MIN_LEN - 1)}
53+
54+
r = await it_client.post(SIGN_UP_ENDPOINT, json=payload)
55+
56+
assert r.status_code == 400
57+
58+
59+
async def test_returns_409_when_username_already_exists(
60+
it_client: httpx.AsyncClient,
61+
it_session: AsyncSession,
62+
it_user_service: UserService,
63+
) -> None:
64+
username = create_raw_username()
65+
user = create_user(it_user_service, raw_username=username)
66+
it_session.add(user)
67+
await it_session.commit()
68+
payload = {"username": username, "password": create_raw_password()}
69+
70+
r = await it_client.post(SIGN_UP_ENDPOINT, json=payload)
71+
72+
assert r.status_code == 409
73+
stmt = select(func.count()).select_from(User)
74+
count = await it_session.scalar(stmt)
75+
assert count == 1
76+
77+
78+
async def test_returns_403_when_already_authenticated(
79+
it_client: httpx.AsyncClient,
80+
it_session: AsyncSession,
81+
it_user_service: UserService,
82+
) -> None:
83+
password = create_raw_password()
84+
user = await create_user_with_password(it_user_service, raw_password=password)
85+
it_session.add(user)
86+
await it_session.commit()
87+
await authenticate(it_client, user.username.value, password)
88+
payload = {"username": create_raw_username(), "password": create_raw_password()}
89+
90+
r = await it_client.post(SIGN_UP_ENDPOINT, json=payload)
91+
92+
assert r.status_code == 403

0 commit comments

Comments
 (0)