diff --git a/.github/workflows/oidc-integration-test.yml b/.github/workflows/oidc-integration-test.yml new file mode 100644 index 0000000000..9de9b1bf5f --- /dev/null +++ b/.github/workflows/oidc-integration-test.yml @@ -0,0 +1,34 @@ +--- +name: "OIDC Integration Test" +on: + workflow_dispatch: +jobs: + test: + name: "Test" + runs-on: "ubuntu-24.04" + steps: + - name: "Check out repository" + uses: "actions/checkout@v4" + - name: "Save user id" + id: user_id + run: | + echo "user_id=$(id -u)" >> $GITHUB_OUTPUT + - name: "Save group id" + id: group_id + run: | + echo "group_id=$(id -g)" >> $GITHUB_OUTPUT + - name: "Set up buildx" + uses: "docker/setup-buildx-action@v3" + - name: "Run tests" + run: | + ./run.sh + shell: "bash" + working-directory: "tests/integration" + env: + USER_ID: ${{ steps.user_id.outputs.user_id }} + GROUP_ID: ${{ steps.group_id.outputs.group_id }} + UBUNTU_VERSION: "22.04" + PYTHON_VERSION: "3.9" + COMPOSE_DOCKER_CLI_BUILD: 1 + DOCKER_BUILDKIT: 1 + PYTEST_ADDOPTS: -vv diff --git a/hack/Dockerfile b/hack/Dockerfile index e02c1cef97..5394410717 100644 --- a/hack/Dockerfile +++ b/hack/Dockerfile @@ -292,4 +292,21 @@ FROM base AS archivematica-tests # ----------------------------------------------------------------------------- +FROM base AS archivematica-dashboard-integration-tests + +USER root + +RUN set -ex \ + && python3 -m playwright install-deps firefox + +USER archivematica + +RUN set -ex \ + && mkdir -p /var/archivematica/.cache/ms-playwright \ + && python3 -m playwright install firefox + +ENV PYTHONPATH=/src/src/dashboard/src/:/src/src/archivematicaCommon/lib/ + +# ----------------------------------------------------------------------------- + FROM ${TARGET} diff --git a/pyproject.toml b/pyproject.toml index ffac2b1782..8447059dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ module = [ "tests.archivematicaCommon.test_execute_functions", "tests.dashboard.fpr.test_views", "tests.dashboard.test_oidc", + "tests.integration.test_oidc_auth", "tests.MCPClient.conftest", "tests.MCPClient.test_characterize_file", "tests.MCPClient.test_has_packages", diff --git a/requirements-dev.in b/requirements-dev.in index 76c381b58a..e59d1be229 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -7,5 +7,11 @@ pytest pytest-cov pytest-django pytest-mock +pytest-playwright pytest-randomly tox + +# playwright requires specific versions of greenlet which may clash with our +# gevent dependency in requirements.txt. +# See https://github.com/microsoft/playwright-python/issues/2190 +git+https://github.com/microsoft/playwright-python.git@d9cdfbb1e178b6770625e9f857139aff77516af0#egg=playwright diff --git a/requirements-dev.txt b/requirements-dev.txt index 8da8c58e2c..282144b6ff 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -96,10 +96,11 @@ gearman3 @ git+https://github.com/artefactual-labs/python-gearman.git@b68efc868c # via -r requirements.txt gevent==24.2.1 # via -r requirements.txt -greenlet==3.1.0 +greenlet==3.1.1 # via # -r requirements.txt # gevent + # playwright gunicorn==23.0.0 # via -r requirements.txt idna==3.10 @@ -169,11 +170,15 @@ platformdirs==4.3.6 # via # tox # virtualenv +playwright @ git+https://github.com/microsoft/playwright-python.git@d9cdfbb1e178b6770625e9f857139aff77516af0 + # via + # -r requirements-dev.in + # pytest-playwright pluggy==1.5.0 # via # pytest # tox -prometheus-client==0.20.0 +prometheus-client==0.21.0 # via # -r requirements.txt # django-prometheus @@ -190,6 +195,8 @@ pycparser==2.22 # via # -r requirements.txt # cffi +pyee==12.0.0 + # via playwright pyopenssl==24.2.1 # via # -r requirements.txt @@ -203,16 +210,22 @@ pyproject-hooks==1.1.0 pytest==8.3.3 # via # -r requirements-dev.in + # pytest-base-url # pytest-cov # pytest-django # pytest-mock + # pytest-playwright # pytest-randomly +pytest-base-url==2.1.0 + # via pytest-playwright pytest-cov==5.0.0 # via -r requirements-dev.in pytest-django==4.9.0 # via -r requirements-dev.in pytest-mock==3.14.0 # via -r requirements-dev.in +pytest-playwright==0.5.2 + # via -r requirements-dev.in pytest-randomly==3.15.0 # via -r requirements-dev.in python-cas==1.6.0 @@ -232,6 +245,8 @@ python-mimeparse==2.0.0 # via # -r requirements.txt # django-tastypie +python-slugify==8.0.4 + # via pytest-playwright referencing==0.35.1 # via # -r requirements.txt @@ -244,6 +259,7 @@ requests==2.32.3 # amclient # mozilla-django-oidc # opf-fido + # pytest-base-url # python-cas rpds-py==0.20.0 # via @@ -260,6 +276,8 @@ sqlparse==0.5.1 # via # -r requirements.txt # django +text-unidecode==1.3 + # via python-slugify tomli==2.0.1 # via # build @@ -274,6 +292,7 @@ typing-extensions==4.12.2 # via # -r requirements.txt # asgiref + # pyee unidecode==1.3.8 # via -r requirements.txt urllib3==2.2.3 diff --git a/requirements.txt b/requirements.txt index a6e1eb72e4..c86b8827ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -62,7 +62,7 @@ gearman3 @ git+https://github.com/artefactual-labs/python-gearman.git@b68efc868c # via -r requirements.in gevent==24.2.1 # via -r requirements.in -greenlet==3.1.0 +greenlet==3.1.1 # via gevent gunicorn==23.0.0 # via -r requirements.in @@ -102,7 +102,7 @@ orjson==3.10.7 # via -r requirements.in packaging==24.1 # via gunicorn -prometheus-client==0.20.0 +prometheus-client==0.21.0 # via # -r requirements.in # django-prometheus diff --git a/tests/dashboard/test_migrations.py b/tests/dashboard/test_migrations.py index 201d857ae5..48abffb8f5 100644 --- a/tests/dashboard/test_migrations.py +++ b/tests/dashboard/test_migrations.py @@ -7,7 +7,7 @@ @pytest.mark.parametrize( - "host_and_port, base_url", + "host_and_port, expected_result", [ ((None, None), ""), (("", ""), ""), @@ -19,15 +19,15 @@ (("http://foobar.tld:789/asdf", "8089"), "http://foobar.tld:789/asdf"), ], ) -def test_0066_get_base_url(host_and_port, base_url): +def test_0066_get_base_url(host_and_port, expected_result): """Test _get_baseurl.""" - assert base_url == mod._get_base_url(*host_and_port), "Failed with args %s" % ( - host_and_port + assert expected_result == mod._get_base_url(*host_and_port), ( + "Failed with args %s" % (host_and_port) ) @pytest.mark.parametrize( - "base_url, host_and_port", + "url, expected_result", [ (None, ("", "")), ("", ("", "")), @@ -38,8 +38,6 @@ def test_0066_get_base_url(host_and_port, base_url): ("http://foobar.tld:8089/subpath", ("foobar.tld", "8089")), ], ) -def test_0066_get_host_and_port(base_url, host_and_port): +def test_0066_get_host_and_port(url, expected_result): """Test _get_host_and_port.""" - assert host_and_port == mod._get_host_and_port( - base_url - ), f"Failed with arg {base_url}" + assert expected_result == mod._get_host_and_port(url), f"Failed with arg {url}" diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 0000000000..36c0cf9553 --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,84 @@ +--- +name: am-integration + +services: + + mysql: + image: "percona:8.0" + command: "--character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci" + environment: + MYSQL_ROOT_PASSWORD: "root" + # These are used in the settings.testmysql modules + MYSQL_USER: "archivematica" + MYSQL_PASSWORD: "demo" + MYSQL_DATABASE: "test_DASHBOARDTEST" + cap_add: + - "SYS_NICE" + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 15s + + archivematica-dashboard: + build: + context: "../../" + dockerfile: "hack/Dockerfile" + args: + TARGET: "archivematica-dashboard-integration-tests" + USER_ID: ${USER_ID:-1000} + GROUP_ID: ${GROUP_ID:-1000} + UBUNTU_VERSION: ${UBUNTU_VERSION:-22.04} + PYTHON_VERSION: ${PYTHON_VERSION:-3.9} + command: ["pytest", "--browser", "firefox", "/src/tests/integration/"] + hostname: "archivematica-dashboard" + environment: + PYTEST_ADDOPTS: ${PYTEST_ADDOPTS:-} + RUN_INTEGRATION_TESTS: "true" + DJANGO_LIVE_TEST_SERVER_ADDRESS: "archivematica-dashboard:8000" + DJANGO_ALLOW_ASYNC_UNSAFE: true + FORWARDED_ALLOW_IPS: "*" + AM_GUNICORN_ACCESSLOG: "/dev/null" + AM_GUNICORN_RELOAD: "true" + AM_GUNICORN_RELOAD_ENGINE: "auto" + DJANGO_SETTINGS_MODULE: "settings.testmysql" + ARCHIVEMATICA_DASHBOARD_DASHBOARD_GEARMAN_SERVER: "gearmand:4730" + ARCHIVEMATICA_DASHBOARD_DASHBOARD_ELASTICSEARCH_SERVER: "elasticsearch:9200" + ARCHIVEMATICA_DASHBOARD_DASHBOARD_PROMETHEUS_ENABLED: "1" + ARCHIVEMATICA_DASHBOARD_CLIENT_USER: "archivematica" + ARCHIVEMATICA_DASHBOARD_CLIENT_PASSWORD: "demo" + ARCHIVEMATICA_DASHBOARD_CLIENT_HOST: "mysql" + ARCHIVEMATICA_DASHBOARD_CLIENT_DATABASE: "MCP" + ARCHIVEMATICA_DASHBOARD_SEARCH_ENABLED: "${AM_SEARCH_ENABLED:-true}" + ARCHIVEMATICA_DASHBOARD_OIDC_AUTHENTICATION: "true" + OIDC_RP_CLIENT_ID: "am-dashboard" + OIDC_RP_CLIENT_SECRET: "example-secret" + OIDC_OP_AUTHORIZATION_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/auth" + OIDC_OP_TOKEN_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/token" + OIDC_OP_USER_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/userinfo" + OIDC_OP_JWKS_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/certs" + OIDC_OP_LOGOUT_ENDPOINT: "http://keycloak:8080/realms/demo/protocol/openid-connect/logout" + OIDC_RP_SIGN_ALGO: "RS256" + volumes: + - "../../:/src" + depends_on: + mysql: + condition: service_healthy + links: + - "mysql" + - "keycloak" + + keycloak: + image: "quay.io/keycloak/keycloak:latest" + command: ["start-dev", "--import-realm"] + restart: "unless-stopped" + environment: + KEYCLOAK_ADMIN: "admin" + KEYCLOAK_ADMIN_PASSWORD: "admin" + KC_METRICS_ENABLED: true + KC_LOG_LEVEL: "INFO" + ports: + - 8080:8080 + volumes: + - "./etc/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro" diff --git a/tests/integration/etc/keycloak/realm.json b/tests/integration/etc/keycloak/realm.json new file mode 100644 index 0000000000..6da93acaa6 --- /dev/null +++ b/tests/integration/etc/keycloak/realm.json @@ -0,0 +1,52 @@ +[ + { + "id": "demo", + "realm": "demo", + "sslRequired": "none", + "enabled": true, + "eventsEnabled": true, + "eventsExpiration": 900, + "adminEventsEnabled": true, + "adminEventsDetailsEnabled": true, + "attributes": { + "adminEventsExpiration": "900" + }, + "clients": [ + { + "id": "am-dashboard", + "clientId": "am-dashboard", + "name": "am-dashboard", + "enabled": true, + "rootUrl": "http://archivematica-dashboard:8000", + "adminUrl": "http://archivematica-dashboard:8000", + "baseUrl": "http://archivematica-dashboard:8000", + "clientAuthenticatorType": "client-secret", + "secret": "example-secret", + "redirectUris": ["http://archivematica-dashboard:8000/*"], + "webOrigins": ["http://archivematica-dashboard:8000"], + "standardFlowEnabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false + } + ], + "users": [ + { + "id": "demo", + "email": "demo@example.com", + "username": "demo", + "firstName": "Demo", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [ + { + "temporary": false, + "type": "password", + "value": "demo" + } + ] + } + ] + } +] diff --git a/tests/integration/run.sh b/tests/integration/run.sh new file mode 100755 index 0000000000..a698b371d1 --- /dev/null +++ b/tests/integration/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd ${__dir} + +docker compose build archivematica-dashboard + +status=$? + +if [ $status -ne 0 ]; then + exit $status +fi + +docker compose run --rm archivematica-dashboard + +status=$? + +if [ -z "${REUSE_TEST_ENV}" ]; then + docker compose down --volumes +fi + +exit $status diff --git a/tests/integration/test_oidc_auth.py b/tests/integration/test_oidc_auth.py new file mode 100644 index 0000000000..3405532646 --- /dev/null +++ b/tests/integration/test_oidc_auth.py @@ -0,0 +1,151 @@ +import os +import uuid +from typing import Type + +import pytest +from components import helpers +from django.contrib.auth.models import User +from django.urls import reverse +from playwright.sync_api import Page +from pytest_django.fixtures import SettingsWrapper +from pytest_django.live_server_helper import LiveServer + +if "RUN_INTEGRATION_TESTS" not in os.environ: + pytest.skip("Skipping integration tests", allow_module_level=True) + + +@pytest.fixture +def dashboard_uuid() -> None: + helpers.set_setting("dashboard_uuid", str(uuid.uuid4())) + + +@pytest.fixture +def user(django_user_model: Type[User]) -> User: + user = django_user_model.objects.create( + username="foobar", + email="foobar@example.com", + first_name="Foo", + last_name="Bar", + ) + user.set_password("foobar1A,") + user.save() + + return user + + +@pytest.mark.django_db +def test_oidc_backend_creates_local_user( + page: Page, + live_server: LiveServer, + dashboard_uuid: None, + django_user_model: Type[User], +) -> None: + page.goto(live_server.url) + + page.get_by_role("link", name="Log in with OpenID Connect").click() + page.get_by_label("Username or email").fill("demo@example.com") + page.get_by_label("Password", exact=True).fill("demo") + page.get_by_role("button", name="Sign In").click() + + assert page.url == f"{live_server.url}/transfer/" + page.get_by_text("demo@example.com").click() + page.get_by_role("link", name="Your profile").click() + + assert page.url == f"{live_server.url}{reverse('accounts:profile')}" + assert [ + i.strip() + for i in page.locator("dl.dl-horizontal").text_content().splitlines() + if i.strip() + ] == [ + "Username", + "demo@example.com", + "Name", + "Demo User", + "E-mail", + "demo@example.com", + "Admin", + "no", + ] + + assert ( + django_user_model.objects.filter( + username="demo@example.com", first_name="Demo", last_name="User" + ).count() + == 1 + ) + + +@pytest.mark.django_db +def test_local_authentication_backend_authenticates_existing_user( + page: Page, live_server: LiveServer, dashboard_uuid: None, user: User +) -> None: + page.goto(live_server.url) + + page.get_by_label("Username").fill("foobar") + page.get_by_label("Password").fill("foobar1A,") + page.get_by_text("Log in", exact=True).click() + + assert page.url == f"{live_server.url}/transfer/" + + page.get_by_text("foobar").click() + page.get_by_role("link", name="Your profile").click() + + assert page.url == f"{live_server.url}{reverse('accounts:profile')}" + assert [ + i.strip() + for i in page.locator("dl.dl-horizontal").text_content().splitlines() + if i.strip() + ] == [ + "Username", + "foobar", + "Name", + "Foo Bar", + "E-mail", + "foobar@example.com", + "Admin", + "no", + ] + + +@pytest.mark.django_db +def test_removing_model_authentication_backend_disables_local_authentication( + page: Page, + live_server: LiveServer, + dashboard_uuid: None, + user: User, + settings: SettingsWrapper, +) -> None: + disabled_backends = ["django.contrib.auth.backends.ModelBackend"] + settings.AUTHENTICATION_BACKENDS = [ + b for b in settings.AUTHENTICATION_BACKENDS if b not in disabled_backends + ] + + page.goto(live_server.url) + + page.get_by_label("Username").fill("foobar") + page.get_by_label("Password").fill("foobar1A,") + page.get_by_text("Log in", exact=True).click() + + assert page.url == f"{live_server.url}{settings.LOGIN_URL}" + assert ( + "Please enter a correct username and password" + in page.locator("div.alert").text_content().strip() + ) + + +@pytest.mark.django_db +def test_setting_login_url_redirects_to_oidc_login_page( + page: Page, + live_server: LiveServer, + dashboard_uuid: None, + user: User, + settings: SettingsWrapper, +) -> None: + page.goto(live_server.url) + assert page.url == f"{live_server.url}{reverse('accounts:login')}" + + settings.LOGIN_URL = reverse("oidc_authentication_init") + + page.goto(live_server.url) + + assert page.url.startswith(settings.OIDC_OP_AUTHORIZATION_ENDPOINT)