Skip to content

Commit b1d060c

Browse files
author
maxim-lixakov
committed
[DOP-21482] - implement unit tests for KeycloakAuthProvider
1 parent fad630c commit b1d060c

File tree

8 files changed

+367
-93
lines changed

8 files changed

+367
-93
lines changed

.env.local

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ export SYNCMASTER__CRYPTO_KEY=UBgPTioFrtH2unlC4XFDiGf5sYfzbdSf_VgiUSaQc94=
1818
# Postgres
1919
export SYNCMASTER__DATABASE__URL=postgresql+asyncpg://syncmaster:changeme@localhost:5432/syncmaster
2020

21-
# Auth
21+
# Keycloack Auth
22+
export SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080/
23+
export SYNCMASTER__AUTH__REALM_NAME=manually_created
24+
export SYNCMASTER__AUTH__CLIENT_ID=manually_created
25+
export SYNCMASTER__AUTH__CLIENT_SECRET=generated_by_keycloak
26+
export SYNCMASTER__AUTH__REDIRECT_URI=http://localhost:8000/auth/callback
27+
export SYNCMASTER__AUTH__SCOPE=email
28+
export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak_provider.KeycloakAuthProvider
29+
30+
# Dummy Auth
2231
export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy_provider.DummyAuthProvider
2332
export SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=secret
2433

poetry.lock

Lines changed: 88 additions & 89 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ onetl = {extras = ["spark", "s3", "hdfs"], version = "^0.12.0"}
136136
faker = ">=28.4.1,<34.0.0"
137137
coverage = "^7.6.1"
138138
gevent = "^24.2.1"
139+
responses = "*"
139140

140141
[tool.poetry.group.dev.dependencies]
141142
mypy = "^1.11.2"

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ def event_loop():
6464
loop.close()
6565

6666

67-
@pytest.fixture(scope="session")
68-
def settings():
69-
return Settings()
67+
@pytest.fixture(scope="session", params=[{}])
68+
def settings(request: pytest.FixtureRequest) -> Settings:
69+
return Settings.parse_obj(request.param)
7070

7171

7272
@pytest.fixture(scope="session")

tests/test_unit/test_auth/__init__.py

Whitespace-only changes.

tests/test_unit/test_auth/mocks/__init__.py

Whitespace-only changes.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import json
2+
from base64 import b64encode
3+
4+
import responses
5+
from cryptography.hazmat.primitives import serialization
6+
from cryptography.hazmat.primitives.asymmetric import rsa
7+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
8+
from itsdangerous import TimestampSigner
9+
from jose import jwt
10+
11+
# copied from .env.docker as backend tries to send requests to corresponding
12+
KEYCLOAK_CONFIG = {
13+
"server_url": "http://keycloak:8080",
14+
"realm_name": "manually_created",
15+
"redirect_uri": "http://localhost:8000/v1/auth/callback",
16+
"client_secret": "generated_by_keycloak",
17+
"scope": "email",
18+
"client_id": "test-client",
19+
}
20+
# create private & public keys to emulate Keycloak signing
21+
PRIVATE_KEY = rsa.generate_private_key(
22+
public_exponent=65537,
23+
key_size=2048,
24+
)
25+
PRIVATE_PEM = PRIVATE_KEY.private_bytes(
26+
encoding=serialization.Encoding.PEM,
27+
format=serialization.PrivateFormat.PKCS8,
28+
encryption_algorithm=serialization.NoEncryption(),
29+
)
30+
PUBLIC_KEY = PRIVATE_KEY.public_key()
31+
32+
33+
def get_public_key_pem(public_key):
34+
public_pem = public_key.public_bytes(
35+
encoding=Encoding.PEM,
36+
format=PublicFormat.SubjectPublicKeyInfo,
37+
)
38+
public_pem_str = public_pem.decode("utf-8")
39+
public_pem_str = public_pem_str.replace("-----BEGIN PUBLIC KEY-----\n", "")
40+
public_pem_str = public_pem_str.replace("-----END PUBLIC KEY-----\n", "")
41+
public_pem_str = public_pem_str.replace("\n", "")
42+
return public_pem_str
43+
44+
45+
def create_session_cookie(payload: dict, session_secret_key: str) -> str:
46+
access_token = jwt.encode(payload, PRIVATE_PEM, algorithm="RS256")
47+
refresh_token = "mock_refresh_token"
48+
session_data = {
49+
"access_token": access_token,
50+
"refresh_token": refresh_token,
51+
}
52+
53+
signer = TimestampSigner(session_secret_key)
54+
json_bytes = json.dumps(session_data).encode("utf-8")
55+
base64_bytes = b64encode(json_bytes)
56+
signed_data = signer.sign(base64_bytes)
57+
return signed_data.decode("utf-8")
58+
59+
60+
def mock_keycloak_well_known(responses_mock):
61+
server_url = KEYCLOAK_CONFIG.get("server_url")
62+
realm_name = KEYCLOAK_CONFIG.get("realm_name")
63+
well_known_url = f"{server_url}/realms/{realm_name}/.well-known/openid-configuration"
64+
65+
responses_mock.add(
66+
responses.GET,
67+
well_known_url,
68+
json={
69+
"authorization_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/auth",
70+
"token_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token",
71+
"userinfo_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/userinfo",
72+
"end_session_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/logout",
73+
"jwks_uri": f"{server_url}/realms/{realm_name}/protocol/openid-connect/certs",
74+
"issuer": f"{server_url}/realms/{realm_name}",
75+
},
76+
status=200,
77+
content_type="application/json",
78+
)
79+
80+
81+
def mock_keycloak_token_endpoint(responses_mock, access_token: str, refresh_token: str):
82+
server_url = KEYCLOAK_CONFIG.get("server_url")
83+
realm_name = KEYCLOAK_CONFIG.get("realm_name")
84+
token_url = f"{server_url}/realms/{realm_name}/protocol/openid-connect/token"
85+
86+
responses_mock.add(
87+
responses.POST,
88+
token_url,
89+
body=json.dumps(
90+
{
91+
"access_token": access_token,
92+
"refresh_token": refresh_token,
93+
"token_type": "bearer",
94+
"expires_in": 3600,
95+
},
96+
),
97+
status=200,
98+
content_type="application/json",
99+
)
100+
101+
102+
def mock_keycloak_realm(responses_mock):
103+
server_url = KEYCLOAK_CONFIG.get("server_url")
104+
realm_name = KEYCLOAK_CONFIG.get("realm_name")
105+
realm_url = f"{server_url}/realms/{realm_name}"
106+
107+
responses_mock.add(
108+
responses.GET,
109+
realm_url,
110+
json={
111+
"realm": realm_name,
112+
"public_key": get_public_key_pem(PUBLIC_KEY),
113+
"token-service": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token",
114+
"account-service": f"{server_url}/realms/{realm_name}/account",
115+
},
116+
status=200,
117+
content_type="application/json",
118+
)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import time
2+
3+
import pytest
4+
import responses
5+
from httpx import AsyncClient
6+
7+
from syncmaster.backend.settings import BackendSettings as Settings
8+
from tests.mocks import MockUser
9+
from tests.test_unit.test_auth.mocks.keycloak import (
10+
create_session_cookie,
11+
mock_keycloak_realm,
12+
mock_keycloak_well_known,
13+
)
14+
15+
KEYCLOAK_PROVIDER = "syncmaster.backend.providers.auth.keycloak_provider.KeycloakAuthProvider"
16+
pytestmark = [pytest.mark.asyncio, pytest.mark.backend]
17+
18+
19+
@responses.activate
20+
@pytest.mark.parametrize("settings", [{"auth": {"provider": KEYCLOAK_PROVIDER}}], indirect=True)
21+
async def test_get_keycloak_user_unauthorized(client: AsyncClient):
22+
mock_keycloak_well_known(responses)
23+
24+
response = await client.get("/v1/users/some_user_id")
25+
26+
# redirect unauthorized user to Keycloak
27+
assert response.status_code == 307
28+
assert "protocol/openid-connect/auth?" in str(
29+
response.next_request.url,
30+
)
31+
32+
33+
@responses.activate
34+
@pytest.mark.parametrize("settings", [{"auth": {"provider": KEYCLOAK_PROVIDER}}], indirect=True)
35+
async def test_get_keycloak_user_authorized(
36+
client: AsyncClient,
37+
simple_user: MockUser,
38+
settings: Settings,
39+
access_token_factory,
40+
):
41+
payload = {
42+
"sub": str(simple_user.id),
43+
"preferred_username": simple_user.username,
44+
"email": simple_user.email,
45+
"given_name": simple_user.first_name,
46+
"middle_name": simple_user.middle_name,
47+
"family_name": simple_user.last_name,
48+
"exp": time.time() + 1000,
49+
}
50+
51+
mock_keycloak_well_known(responses)
52+
mock_keycloak_realm(responses)
53+
54+
session_cookie = create_session_cookie(payload, settings.server.session.secret_key)
55+
headers = {
56+
"Cookie": f"session={session_cookie}",
57+
}
58+
response = await client.get(
59+
f"/v1/users/{simple_user.id}",
60+
headers=headers,
61+
)
62+
63+
assert response.status_code == 200
64+
assert response.json() == {
65+
"id": simple_user.id,
66+
"is_superuser": simple_user.is_superuser,
67+
"username": simple_user.username,
68+
}
69+
70+
71+
@responses.activate
72+
@pytest.mark.parametrize("settings", [{"auth": {"provider": KEYCLOAK_PROVIDER}}], indirect=True)
73+
async def test_get_keycloak_deleted_user(
74+
client: AsyncClient,
75+
simple_user: MockUser,
76+
deleted_user: MockUser,
77+
settings: Settings,
78+
):
79+
payload = {
80+
"sub": str(simple_user.id),
81+
"preferred_username": simple_user.username,
82+
"email": simple_user.email,
83+
"given_name": simple_user.first_name,
84+
"middle_name": simple_user.middle_name,
85+
"family_name": simple_user.last_name,
86+
"exp": time.time() + 1000,
87+
}
88+
89+
mock_keycloak_well_known(responses)
90+
mock_keycloak_realm(responses)
91+
92+
session_cookie = create_session_cookie(payload, settings.server.session.secret_key)
93+
headers = {
94+
"Cookie": f"session={session_cookie}",
95+
}
96+
response = await client.get(
97+
f"/v1/users/{deleted_user.id}",
98+
headers=headers,
99+
)
100+
assert response.status_code == 404
101+
assert response.json() == {
102+
"error": {
103+
"code": "not_found",
104+
"message": "User not found",
105+
"details": None,
106+
},
107+
}
108+
109+
110+
@responses.activate
111+
@pytest.mark.parametrize("settings", [{"auth": {"provider": KEYCLOAK_PROVIDER}}], indirect=True)
112+
async def test_get_keycloak_user_inactive(
113+
client: AsyncClient,
114+
simple_user: MockUser,
115+
inactive_user: MockUser,
116+
settings: Settings,
117+
):
118+
payload = {
119+
"sub": str(inactive_user.id),
120+
"preferred_username": inactive_user.username,
121+
"email": inactive_user.email,
122+
"given_name": inactive_user.first_name,
123+
"middle_name": inactive_user.middle_name,
124+
"family_name": inactive_user.last_name,
125+
"exp": time.time() + 1000,
126+
}
127+
128+
mock_keycloak_well_known(responses)
129+
mock_keycloak_realm(responses)
130+
131+
session_cookie = create_session_cookie(payload, settings.server.session.secret_key)
132+
headers = {
133+
"Cookie": f"session={session_cookie}",
134+
}
135+
136+
response = await client.get(
137+
f"/v1/users/{simple_user.id}",
138+
headers=headers,
139+
)
140+
assert response.status_code == 403
141+
assert response.json() == {
142+
"error": {
143+
"code": "forbidden",
144+
"message": "You have no power here",
145+
"details": None,
146+
},
147+
}

0 commit comments

Comments
 (0)