Skip to content

Commit

Permalink
test: python unit tests, WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
Aleksandr Chikovani committed Aug 14, 2024
1 parent a6bc386 commit 4421864
Show file tree
Hide file tree
Showing 12 changed files with 339 additions and 14 deletions.
3 changes: 1 addition & 2 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
[run]
source = ./
omit = mlflow_oidc_auth/tests/*,mlflow_oidc_auth/db/migrations/versions/*
omit = mlflow_oidc_auth/tests/*,mlflow_oidc_auth/db/migrations/versions/*,mlflow_oidc_auth/app.py
42 changes: 42 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Unit tests
on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize
jobs:
python-tests:
name: Run python tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1
with:
python-version: 3.11
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install tox
tox -e py
- name: Build coverage file
run: |
tox -e py
# pytest --cache-clear --junitxml=coverage.xml --cov-report=term-missing:skip-covered --cov=mlflow_oidc_auth > pytest-coverage.txt
- name: Override Coverage Source Path for Sonar
run: sed -i "s@<source>/home/runner/work/mlflow-oidc-auth/mlflow-oidc-auth</source>@<source>/github/workspace</source>@g" /home/runner/work/mlflow-oidc-auth/mlflow-oidc-auth/coverage.xml
- name: debug cov
run: |
pwd
ls -alh
head -n50 coverage.xml
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@e44258b109568baa0df60ed515909fc6c72cba92 # v2.3.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# todo: remove GH App
54 changes: 42 additions & 12 deletions mlflow_oidc_auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,58 @@
app.logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))


class AppConfig:
class MetaAppConfig(type):
"""MetaAppConfig class
Used as a metaclass for AppConfig class to provide dynamic properties for static class.
That's required to have a better code coverage while still allowing to pass the class as config to Flask app.
"""

_DEFAULT_DISCOVERED_URLS = {}

def _initialize_urls(cls):
if len(cls._DEFAULT_DISCOVERED_URLS) == 3:
return
if os.environ.get("OIDC_DISCOVERY_URL", None) is None:
return

response = requests.get(cls.OIDC_DISCOVERY_URL)
config = response.json()
cls._DEFAULT_DISCOVERED_URLS["authorization_endpoint"] = config.get("authorization_endpoint")
cls._DEFAULT_DISCOVERED_URLS["token_endpoint"] = config.get("token_endpoint")
cls._DEFAULT_DISCOVERED_URLS["userinfo_endpoint"] = config.get("userinfo_endpoint")

@property
def OIDC_DISCOVERY_URL(cls):
return os.environ.get("OIDC_DISCOVERY_URL", None)

@property
def OIDC_AUTHORIZATION_URL(cls):
cls._initialize_urls()
return cls._DEFAULT_DISCOVERED_URLS.get("authorization_endpoint", None)

@property
def OIDC_TOKEN_URL(cls):
cls._initialize_urls()
return cls._DEFAULT_DISCOVERED_URLS.get("token_endpoint", None)

@property
def OIDC_USER_URL(cls):
cls._initialize_urls()
return cls._DEFAULT_DISCOVERED_URLS.get("userinfo_endpoint", None)


class AppConfig(metaclass=MetaAppConfig):
DEFAULT_MLFLOW_PERMISSION = os.environ.get("DEFAULT_MLFLOW_PERMISSION", "MANAGE")
SECRET_KEY = os.environ.get("SECRET_KEY", secrets.token_hex(16))
SESSION_TYPE = "cachelib"
OIDC_USERS_DB_URI = os.environ.get("OIDC_USERS_DB_URI", "sqlite:///auth.db")
OIDC_GROUP_NAME = os.environ.get("OIDC_GROUP_NAME", "mlflow")
OIDC_ADMIN_GROUP_NAME = os.environ.get("OIDC_ADMIN_GROUP_NAME", "mlflow-admin")
OIDC_PROVIDER_DISPLAY_NAME = os.environ.get("OIDC_PROVIDER_DISPLAY_NAME", "Login with OIDC")
OIDC_DISCOVERY_URL = os.environ.get("OIDC_DISCOVERY_URL", None)
OIDC_GROUPS_ATTRIBUTE = os.environ.get("OIDC_GROUPS_ATTRIBUTE", "groups")
OIDC_SCOPE = os.environ.get("OIDC_SCOPE", "openid,email,profile")
OIDC_GROUP_DETECTION_PLUGIN = os.environ.get("OIDC_GROUP_DETECTION_PLUGIN", None)
if OIDC_DISCOVERY_URL:
response = requests.get(OIDC_DISCOVERY_URL)
config = response.json()
OIDC_AUTHORIZATION_URL = config.get("authorization_endpoint")
OIDC_TOKEN_URL = config.get("token_endpoint")
OIDC_USER_URL = config.get("userinfo_endpoint")
else:
OIDC_AUTHORIZATION_URL = os.environ.get("OIDC_AUTHORIZATION_URL", None)
OIDC_TOKEN_URL = os.environ.get("OIDC_TOKEN_URL", None)
OIDC_USER_URL = os.environ.get("OIDC_USER_URL", None)
OIDC_REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", None)
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", None)
OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", None)
Expand Down
Empty file.
67 changes: 67 additions & 0 deletions mlflow_oidc_auth/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from unittest import mock


"""
AppConfig initiates it's values on import.
This test is to ensure that all expected properties are present.
"""


class TestAppConfig:
MOCK_OIDC_RESPONSE = {
"authorization_endpoint": "https://example.com/auth",
"token_endpoint": "https://example.com/token",
"userinfo_endpoint": "https://example.com/userinfo",
}
MOCK_OIDC_DISCOVERY_URL = "https://example.com/.well-known/openid-configuration"

@mock.patch("requests.get")
def test_ext_call(self, mock_get, monkeypatch):
mock_response = mock_get.return_value
mock_response.json.return_value = self.MOCK_OIDC_RESPONSE
monkeypatch.setenv("OIDC_DISCOVERY_URL", self.MOCK_OIDC_DISCOVERY_URL)

from mlflow_oidc_auth.config import AppConfig

conf = AppConfig

assert conf.OIDC_AUTHORIZATION_URL == self.MOCK_OIDC_RESPONSE["authorization_endpoint"]

mock_get.assert_called_once_with(self.MOCK_OIDC_DISCOVERY_URL)
assert conf.OIDC_TOKEN_URL == self.MOCK_OIDC_RESPONSE["token_endpoint"]
assert conf.OIDC_USER_URL == self.MOCK_OIDC_RESPONSE["userinfo_endpoint"]

def test_configurations_presented(self):
from mlflow_oidc_auth.config import AppConfig

assert len(AppConfig.SECRET_KEY) == 16 * 2 # 16 bytes in hex

assert all(
conf is not None
for conf in [
AppConfig.DEFAULT_MLFLOW_PERMISSION,
AppConfig.SESSION_TYPE,
AppConfig.OIDC_USERS_DB_URI,
AppConfig.OIDC_GROUP_NAME,
AppConfig.OIDC_ADMIN_GROUP_NAME,
AppConfig.OIDC_PROVIDER_DISPLAY_NAME,
AppConfig.OIDC_GROUPS_ATTRIBUTE,
AppConfig.OIDC_SCOPE,
]
)

assert all(
conf is None
for conf in [
AppConfig.OIDC_GROUP_DETECTION_PLUGIN,
AppConfig.OIDC_REDIRECT_URI,
AppConfig.OIDC_CLIENT_ID,
AppConfig.OIDC_CLIENT_SECRET,
]
)

def test_get_property(self):
from mlflow_oidc_auth.config import AppConfig

assert AppConfig.get_property("SECRET_KEY") == AppConfig.SECRET_KEY
assert AppConfig.get_property("THIS_PROPERTY_DOES_NOT_EXIST") is None
16 changes: 16 additions & 0 deletions mlflow_oidc_auth/tests/test_db_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker


class TestMigrate:
@patch("alembic.command.upgrade")
def test_migrate(self, mock_upgrade):
from mlflow_oidc_auth.db.utils import migrate

engine = create_engine("sqlite:///:memory:")
with sessionmaker(bind=engine)():
migrate(engine, "head")

mock_upgrade.assert_called_once()
56 changes: 56 additions & 0 deletions mlflow_oidc_auth/tests/test_entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import unittest
from mlflow_oidc_auth.entities import User, ExperimentPermission, RegisteredModelPermission, Group


class TestUser(unittest.TestCase):
def test_user_to_json(self):
user = User(
id_="123",
username="test_user",
password_hash="password",
is_admin=True,
display_name="Test User",
experiment_permissions=[ExperimentPermission("exp1", "read")],
registered_model_permissions=[RegisteredModelPermission("model1", "write")],
groups=[Group("group1", "Group 1")],
)

expected_json = {
"id": "123",
"username": "test_user",
"is_admin": True,
"display_name": "Test User",
"experiment_permissions": [{"experiment_id": "exp1", "permission": "read", "user_id": None, "group_id": None}],
"registered_model_permissions": [{"name": "model1", "permission": "write", "user_id": None, "group_id": None}],
"groups": [{"id": "group1", "group_name": "Group 1"}],
}

self.assertEqual(user.to_json(), expected_json)

def test_user_from_json(self):
json_data = {
"id": "123",
"username": "test_user",
"is_admin": True,
"display_name": "Test User",
"experiment_permissions": [{"experiment_id": "exp1", "permission": "read", "user_id": None, "group_id": None}],
"registered_model_permissions": [{"name": "model1", "permission": "write", "user_id": None, "group_id": None}],
"groups": [{"id": "group1", "group_name": "Group 1"}],
}

user = User.from_json(json_data)

self.assertEqual(user.id, "123")
self.assertEqual(user.username, "test_user")
self.assertEqual(user.password_hash, "REDACTED")
self.assertTrue(user.is_admin)
self.assertEqual(user.display_name, "Test User")
self.assertEqual(len(user.experiment_permissions), 1)
self.assertEqual(user.experiment_permissions[0].experiment_id, "exp1")
self.assertEqual(user.experiment_permissions[0].permission, "read")
self.assertEqual(len(user.registered_model_permissions), 1)
self.assertEqual(user.registered_model_permissions[0].name, "model1")
self.assertEqual(user.registered_model_permissions[0].permission, "write")
self.assertEqual(len(user.groups), 1)
self.assertEqual(user.groups[0].id, "group1")
self.assertEqual(user.groups[0].group_name, "Group 1")
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import unittest
from unittest.mock import Mock, patch
from mlflow_oidc_auth.plugins.group_detection_microsoft_entra_id import get_user_groups


class TestGetUserGroups(unittest.TestCase):
@patch("mlflow_oidc_auth.plugins.group_detection_microsoft_entra_id.requests.get")
def test_get_user_groups(self, mock_get):
mock_response = Mock()
mock_response.json.return_value = {
"value": [
{"displayName": "Group 1"},
{"displayName": "Group 2"},
{"displayName": "Group 3"},
]
}
mock_get.return_value = mock_response

access_token = "D34DB33F"
groups = get_user_groups(access_token)

mock_get.assert_called_once_with(
"https://graph.microsoft.com/v1.0/me/memberOf",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
)

expected_groups = ["Group 1", "Group 2", "Group 3"]
self.assertEqual(groups, expected_groups)
55 changes: 55 additions & 0 deletions mlflow_oidc_auth/tests/test_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from unittest import mock
from mlflow_oidc_auth import routes

"""
`routes` contains mutiple routes definitions.
This test is to ensure that all expected routes are present.
"""


class TestRoutes:
def test_routes_presented(self):
assert all(
route is not None
for route in [
routes.HOME,
routes.LOGIN,
routes.LOGOUT,
routes.CALLBACK,
routes.STATIC,
routes.UI,
routes.UI_ROOT,
routes.GET_ACCESS_TOKEN,
routes.GET_CURRENT_USER,
routes.GET_EXPERIMENTS,
routes.GET_MODELS,
routes.GET_USERS,
routes.GET_USER_EXPERIMENTS,
routes.GET_USER_MODELS,
routes.GET_EXPERIMENT_USERS,
routes.GET_MODEL_USERS,
routes.CREATE_USER,
routes.GET_USER,
routes.UPDATE_USER_PASSWORD,
routes.UPDATE_USER_ADMIN,
routes.DELETE_USER,
routes.CREATE_EXPERIMENT_PERMISSION,
routes.GET_EXPERIMENT_PERMISSION,
routes.UPDATE_EXPERIMENT_PERMISSION,
routes.DELETE_EXPERIMENT_PERMISSION,
routes.CREATE_REGISTERED_MODEL_PERMISSION,
routes.GET_REGISTERED_MODEL_PERMISSION,
routes.UPDATE_REGISTERED_MODEL_PERMISSION,
routes.DELETE_REGISTERED_MODEL_PERMISSION,
routes.GET_GROUPS,
routes.GET_GROUP_USERS,
routes.GET_GROUP_EXPERIMENTS_PERMISSION,
routes.CREATE_GROUP_EXPERIMENT_PERMISSION,
routes.DELETE_GROUP_EXPERIMENT_PERMISSION,
routes.UPDATE_GROUP_EXPERIMENT_PERMISSION,
routes.GET_GROUP_MODELS_PERMISSION,
routes.CREATE_GROUP_MODEL_PERMISSION,
routes.DELETE_GROUP_MODEL_PERMISSION,
routes.UPDATE_GROUP_MODEL_PERMISSION,
]
)
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,10 @@ line-length = 128
[project.optional-dependencies]
dev = [
"black==24.8.0",
"pytest==8.3.2",
"pre-commit==3.5.0",
]
test = [
"pytest==8.3.2",
"pytest-cov==5.0.0",
]
7 changes: 7 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
sonar.projectKey=data-platform-hq_mlflow-oidc-auth
sonar.organization=data-platform-hq

sonar.python.version=3.11
sonar.python.coverage.reportPaths=coverage.xml
sonar.test.inclusions=**/test_*.py
sonar.coverage.exclusions==**/test_*.py,**/app.py,**/db/migrations/versions/**/*.*,**/sqlalchemy_store.py,**/views.py # TODO: review sqlalchemy_store.py and views.py
17 changes: 17 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[tox]
envlist = py311
skipsdist = True

[testenv]
deps =
pytest
coverage<=7.6
commands =
pip install -e '.[test]'
coverage run -m pytest -s mlflow_oidc_auth/tests
coverage xml

[coverage:run]
# relative_files = True
source = mlflow_oidc_auth/
branch = True

0 comments on commit 4421864

Please sign in to comment.