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:
Jens L.
2026-01-20 02:15:35 +01:00
committed by GitHub
parent ed17c53c70
commit 083b61ca7f
14 changed files with 234 additions and 120 deletions

114
tests/docker.py Normal file
View 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()

View File

@@ -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",

View File

@@ -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",

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(