mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
tests: improve e2e/integration test reliability (#19540)
* add flakefinder Signed-off-by: Jens Langhammer <jens@goauthentik.io> * show local IP in test header Signed-off-by: Jens Langhammer <jens@goauthentik.io> * attempt to join worker on test finish Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix? Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add timeout Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add flush Signed-off-by: Jens Langhammer <jens@goauthentik.io> * stop -> close Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix rare test issue of this failing Signed-off-by: Jens Langhammer <jens@goauthentik.io> * check correctly Signed-off-by: Jens Langhammer <jens@goauthentik.io> * un-serialize rollback? Signed-off-by: Jens Langhammer <jens@goauthentik.io> * explicitly join before db teardown Signed-off-by: Jens Langhammer <jens@goauthentik.io> * skip flaky tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * new broker Signed-off-by: Jens Langhammer <jens@goauthentik.io> * classmethod Signed-off-by: Jens Langhammer <jens@goauthentik.io> * separate docker helpers Signed-off-by: Jens Langhammer <jens@goauthentik.io> * only timeout functions Signed-off-by: Jens Langhammer <jens@goauthentik.io> * type and format Signed-off-by: Jens Langhammer <jens@goauthentik.io> * show detected IP too Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
114
tests/docker.py
Normal file
114
tests/docker.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""authentik e2e testing utilities"""
|
||||
|
||||
from os import environ
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
from unittest.case import TestCase
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from docker.errors import DockerException
|
||||
from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root.test_runner import get_docker_tag
|
||||
|
||||
IS_CI = "CI" in environ
|
||||
|
||||
|
||||
class DockerTestCase(TestCase):
|
||||
"""Mixin for dealing with containers"""
|
||||
|
||||
max_healthcheck_attempts = 45
|
||||
|
||||
__client: DockerClient
|
||||
__network: Network
|
||||
|
||||
__label_id = generate_id()
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.__client = from_env()
|
||||
self.__network = self.docker_client.networks.create(name=f"authentik-test-{generate_id()}")
|
||||
|
||||
@property
|
||||
def docker_client(self) -> DockerClient:
|
||||
return self.__client
|
||||
|
||||
@property
|
||||
def docker_network(self) -> Network:
|
||||
return self.__network
|
||||
|
||||
@property
|
||||
def docker_labels(self) -> dict[str, str]:
|
||||
return {"io.goauthentik.test": self.__label_id}
|
||||
|
||||
def wait_for_container(self, container: Container) -> Container:
|
||||
"""Check that container is health"""
|
||||
attempt = 0
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
sleep(1)
|
||||
attempt += 1
|
||||
if attempt >= self.max_healthcheck_attempts:
|
||||
self.output_container_logs(container)
|
||||
raise self.failureException("Container failed to start")
|
||||
|
||||
def get_container_image(self, base: str) -> str:
|
||||
"""Try to pull docker image based on git branch, fallback to main if not found."""
|
||||
image = f"{base}:gh-main"
|
||||
try:
|
||||
branch_image = f"{base}:{get_docker_tag()}"
|
||||
self.docker_client.images.pull(branch_image)
|
||||
return branch_image
|
||||
except DockerException:
|
||||
self.docker_client.images.pull(image)
|
||||
return image
|
||||
|
||||
def run_container(self, **specs: Any) -> Container:
|
||||
if "network_mode" not in specs:
|
||||
specs["network"] = self.__network.name
|
||||
specs["labels"] = self.docker_labels
|
||||
specs["detach"] = True
|
||||
if hasattr(self, "live_server_url"):
|
||||
specs.setdefault("environment", {})
|
||||
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
|
||||
container: Container = self.docker_client.containers.run(**specs)
|
||||
container.reload()
|
||||
state = container.attrs.get("State", {})
|
||||
if "Health" not in state:
|
||||
return container
|
||||
self.wait_for_container(container)
|
||||
return container
|
||||
|
||||
def output_container_logs(self, container: Container | None = None) -> None:
|
||||
"""Output the container logs to our STDOUT"""
|
||||
if not container:
|
||||
return
|
||||
if IS_CI:
|
||||
image = container.image
|
||||
if image:
|
||||
tags = image.tags[0] if len(image.tags) > 0 else str(image)
|
||||
print(f"::group::Container logs - {tags}")
|
||||
for log in container.logs().decode().split("\n"):
|
||||
print(log)
|
||||
if IS_CI:
|
||||
print("::endgroup::")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
containers: list[Container] = self.docker_client.containers.list(
|
||||
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
|
||||
)
|
||||
for container in containers:
|
||||
self.output_container_logs(container)
|
||||
try:
|
||||
container.kill()
|
||||
except DockerException:
|
||||
pass
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except DockerException:
|
||||
pass
|
||||
self.__network.remove()
|
||||
@@ -1,6 +1,7 @@
|
||||
"""test OAuth2 OpenID Provider flow"""
|
||||
|
||||
from time import sleep
|
||||
from unittest import skip
|
||||
|
||||
from docker.types import Healthcheck
|
||||
from selenium.webdriver.common.by import By
|
||||
@@ -415,6 +416,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
|
||||
"Permission denied",
|
||||
)
|
||||
|
||||
@skip("Flaky test")
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from json import dumps
|
||||
from time import sleep
|
||||
from unittest import skip
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as ec
|
||||
@@ -582,6 +583,7 @@ class TestProviderSAML(SeleniumTestCase):
|
||||
f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
|
||||
)
|
||||
|
||||
@skip("Flaky test")
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
|
||||
@@ -10,7 +10,6 @@ from sys import stderr
|
||||
from tempfile import gettempdir
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
from unittest.case import TestCase
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.apps import apps
|
||||
@@ -19,10 +18,8 @@ from django.db import connection
|
||||
from django.db.migrations.loader import MigrationLoader
|
||||
from django.test.testcases import TransactionTestCase
|
||||
from django.urls import reverse
|
||||
from docker import DockerClient, from_env
|
||||
from docker.errors import DockerException
|
||||
from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
from dramatiq import get_broker
|
||||
from requests import RequestException
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import (
|
||||
@@ -45,9 +42,9 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.root.test_runner import get_docker_tag
|
||||
from authentik.tasks.test import use_test_broker
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
IS_CI = "CI" in environ
|
||||
RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1
|
||||
@@ -56,114 +53,18 @@ SHADOW_ROOT_RETRIES = 5
|
||||
JSONType = dict[str, Any] | list[Any] | str | int | float | bool | None
|
||||
|
||||
|
||||
def get_local_ip() -> str:
|
||||
def get_local_ip(override=True) -> str:
|
||||
"""Get the local machine's IP"""
|
||||
if local_ip := getenv("LOCAL_IP"):
|
||||
if (local_ip := getenv("LOCAL_IP")) and override:
|
||||
return local_ip
|
||||
hostname = socket.gethostname()
|
||||
ip_addr = socket.gethostbyname(hostname)
|
||||
return ip_addr
|
||||
|
||||
|
||||
class DockerTestCase(TestCase):
|
||||
"""Mixin for dealing with containers"""
|
||||
|
||||
max_healthcheck_attempts = 45
|
||||
|
||||
__client: DockerClient
|
||||
__network: Network
|
||||
|
||||
__label_id = generate_id()
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.__client = from_env()
|
||||
self.__network = self.docker_client.networks.create(name=f"authentik-test-{generate_id()}")
|
||||
|
||||
@property
|
||||
def docker_client(self) -> DockerClient:
|
||||
return self.__client
|
||||
|
||||
@property
|
||||
def docker_network(self) -> Network:
|
||||
return self.__network
|
||||
|
||||
@property
|
||||
def docker_labels(self) -> dict:
|
||||
return {"io.goauthentik.test": self.__label_id}
|
||||
|
||||
def wait_for_container(self, container: Container):
|
||||
"""Check that container is health"""
|
||||
attempt = 0
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
sleep(1)
|
||||
attempt += 1
|
||||
if attempt >= self.max_healthcheck_attempts:
|
||||
self.output_container_logs(container)
|
||||
raise self.failureException("Container failed to start")
|
||||
|
||||
def get_container_image(self, base: str) -> str:
|
||||
"""Try to pull docker image based on git branch, fallback to main if not found."""
|
||||
image = f"{base}:gh-main"
|
||||
try:
|
||||
branch_image = f"{base}:{get_docker_tag()}"
|
||||
self.docker_client.images.pull(branch_image)
|
||||
return branch_image
|
||||
except DockerException:
|
||||
self.docker_client.images.pull(image)
|
||||
return image
|
||||
|
||||
def run_container(self, **specs: dict[str, Any]) -> Container:
|
||||
if "network_mode" not in specs:
|
||||
specs["network"] = self.__network.name
|
||||
specs["labels"] = self.docker_labels
|
||||
specs["detach"] = True
|
||||
if hasattr(self, "live_server_url"):
|
||||
specs.setdefault("environment", {})
|
||||
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
|
||||
container = self.docker_client.containers.run(**specs)
|
||||
container.reload()
|
||||
state = container.attrs.get("State", {})
|
||||
if "Health" not in state:
|
||||
return container
|
||||
self.wait_for_container(container)
|
||||
return container
|
||||
|
||||
def output_container_logs(self, container: Container | None = None):
|
||||
"""Output the container logs to our STDOUT"""
|
||||
if IS_CI:
|
||||
image = container.image
|
||||
tags = image.tags[0] if len(image.tags) > 0 else str(image)
|
||||
print(f"::group::Container logs - {tags}")
|
||||
for log in container.logs().decode().split("\n"):
|
||||
print(log)
|
||||
if IS_CI:
|
||||
print("::endgroup::")
|
||||
|
||||
def tearDown(self):
|
||||
containers: list[Container] = self.docker_client.containers.list(
|
||||
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
|
||||
)
|
||||
for container in containers:
|
||||
self.output_container_logs(container)
|
||||
try:
|
||||
container.kill()
|
||||
except DockerException:
|
||||
pass
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except DockerException:
|
||||
pass
|
||||
self.__network.remove()
|
||||
|
||||
|
||||
class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
|
||||
|
||||
serialized_rollback = True
|
||||
host = get_local_ip()
|
||||
wait_timeout: int
|
||||
user: User
|
||||
@@ -232,6 +133,17 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
def driver_container(self) -> Container:
|
||||
return self.docker_client.containers.list(filters={"label": "io.goauthentik.tests"})[0]
|
||||
|
||||
@classmethod
|
||||
def _pre_setup(cls):
|
||||
use_test_broker()
|
||||
return super()._pre_setup()
|
||||
|
||||
def _post_teardown(self):
|
||||
broker = get_broker()
|
||||
broker.flush_all()
|
||||
broker.close()
|
||||
return super()._post_teardown()
|
||||
|
||||
def tearDown(self):
|
||||
if IS_CI:
|
||||
print("::endgroup::", file=stderr)
|
||||
|
||||
@@ -20,7 +20,8 @@ from authentik.outposts.models import (
|
||||
)
|
||||
from authentik.outposts.tasks import outpost_connection_discovery
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from tests.e2e.utils import DockerTestCase, get_docker_tag
|
||||
from authentik.root.test_runner import get_docker_tag
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
@@ -88,7 +89,7 @@ class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
@CONFIG.patch("outposts.container_image_base", "ghcr.io/goauthentik/dev-proxy:gh-main")
|
||||
def test_docker_controller(self):
|
||||
"""test that deployment requires update"""
|
||||
@@ -96,7 +97,7 @@ class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
controller.up()
|
||||
controller.down()
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_docker_static(self):
|
||||
"""test that deployment requires update"""
|
||||
controller = DockerController(self.outpost, self.service_connection)
|
||||
|
||||
@@ -53,7 +53,7 @@ class OutpostKubernetesTests(TestCase):
|
||||
self.outpost.providers.add(self.provider)
|
||||
self.outpost.save()
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_deployment_reconciler(self):
|
||||
"""test that deployment requires update"""
|
||||
controller = ProxyKubernetesController(self.outpost, self.service_connection)
|
||||
@@ -92,7 +92,7 @@ class OutpostKubernetesTests(TestCase):
|
||||
|
||||
deployment_reconciler.delete(deployment_reconciler.get_reference_object())
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_service_reconciler(self):
|
||||
"""test that service requires update"""
|
||||
controller = ProxyKubernetesController(self.outpost, self.service_connection)
|
||||
@@ -121,7 +121,7 @@ class OutpostKubernetesTests(TestCase):
|
||||
|
||||
service_reconciler.delete(service_reconciler.get_reference_object())
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_controller_rename(self):
|
||||
"""test that objects get deleted and re-created with new names"""
|
||||
controller = ProxyKubernetesController(self.outpost, self.service_connection)
|
||||
@@ -134,7 +134,7 @@ class OutpostKubernetesTests(TestCase):
|
||||
apps.read_namespaced_deployment("test", self.outpost.config.kubernetes_namespace)
|
||||
controller.down()
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_controller_full_update(self):
|
||||
"""Test an update that triggers all objects"""
|
||||
controller = ProxyKubernetesController(self.outpost, self.service_connection)
|
||||
|
||||
@@ -20,7 +20,8 @@ from authentik.outposts.models import (
|
||||
from authentik.outposts.tasks import outpost_connection_discovery
|
||||
from authentik.providers.proxy.controllers.docker import DockerController
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from tests.e2e.utils import DockerTestCase, get_docker_tag
|
||||
from authentik.root.test_runner import get_docker_tag
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
@@ -88,7 +89,7 @@ class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
@CONFIG.patch("outposts.container_image_base", "ghcr.io/goauthentik/dev-proxy:gh-main")
|
||||
def test_docker_controller(self):
|
||||
"""test that deployment requires update"""
|
||||
@@ -96,7 +97,7 @@ class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
controller.up()
|
||||
controller.down()
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_docker_static(self):
|
||||
"""test that deployment requires update"""
|
||||
controller = DockerController(self.outpost, self.service_connection)
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestProxyKubernetes(TestCase):
|
||||
outpost_connection_discovery.send()
|
||||
self.controller = None
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_kubernetes_controller_static(self):
|
||||
"""Test Kubernetes Controller"""
|
||||
provider: ProxyProvider = ProxyProvider.objects.create(
|
||||
@@ -48,7 +48,7 @@ class TestProxyKubernetes(TestCase):
|
||||
manifest = self.controller.get_static_deployment()
|
||||
self.assertEqual(len(list(yaml.load_all(manifest, Loader=yaml.SafeLoader))), 4)
|
||||
|
||||
@pytest.mark.timeout(120)
|
||||
@pytest.mark.timeout(120, func_only=True)
|
||||
def test_kubernetes_controller_ingress(self):
|
||||
"""Test Kubernetes Controller's Ingress"""
|
||||
provider: ProxyProvider = ProxyProvider.objects.create(
|
||||
|
||||
Reference in New Issue
Block a user