Compare commits

..

1 Commits

Author SHA1 Message Date
4b0d641a51 tests: better ws support
cherry-picked from https://github.com/goauthentik/authentik/pull/14539

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-18 00:45:55 +02:00
58 changed files with 588 additions and 621 deletions

View File

@ -11,7 +11,7 @@ from django.test.runner import DiscoverRunner
from authentik.lib.config import CONFIG
from authentik.lib.sentry import sentry_init
from authentik.root.signals import post_startup, pre_startup, startup
from tests.e2e.utils import get_docker_tag
from tests.docker import get_docker_tag
# globally set maxDiff to none to show full assert error
TestCase.maxDiff = None

View File

@ -0,0 +1,12 @@
import socket
from os import environ
IS_CI = "CI" in environ
RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1
def get_local_ip() -> str:
"""Get the local machine's IP"""
hostname = socket.gethostname()
ip_addr = socket.gethostbyname(hostname)
return ip_addr

View File

@ -1,29 +1,18 @@
"""authentik e2e testing utilities"""
# This file cannot import anything django or anything that will load django
import json
import os
import socket
from collections.abc import Callable
from functools import lru_cache, wraps
from os import environ
from sys import stderr
from time import sleep
from typing import Any
from typing import TYPE_CHECKING
from unittest.case import TestCase
from urllib.parse import urlencode
from django.apps import apps
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
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 selenium import webdriver
from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.command import Command
@ -33,137 +22,27 @@ from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait
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 tests import IS_CI, RETRIES, get_local_ip
from tests.websocket import BaseWebsocketTestCase
IS_CI = "CI" in environ
RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1
if TYPE_CHECKING:
from authentik.core.models import User
def get_docker_tag() -> str:
"""Get docker-tag based off of CI variables"""
env_pr_branch = "GITHUB_HEAD_REF"
default_branch = "GITHUB_REF"
branch_name = os.environ.get(default_branch, "main")
if os.environ.get(env_pr_branch, "") != "":
branch_name = os.environ[env_pr_branch]
branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
return f"gh-{branch_name}"
def get_local_ip() -> str:
"""Get the local machine's IP"""
hostname = socket.gethostname()
ip_addr = socket.gethostbyname(hostname)
return ip_addr
class DockerTestCase(TestCase):
"""Mixin for dealing with containers"""
max_healthcheck_attempts = 30
__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.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"""
class BaseSeleniumTestCase(TestCase):
"""Mixin which adds helpers for spinning up Selenium"""
host = get_local_ip()
wait_timeout: int
user: User
user: "User"
def setUp(self):
if IS_CI:
print("::group::authentik Logs", file=stderr)
from django.apps import apps
from authentik.core.tests.utils import create_test_admin_user
apps.get_app_config("authentik_tenants").ready()
self.wait_timeout = 60
self.driver = self._get_driver()
@ -290,8 +169,10 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER)
sleep(1)
def assert_user(self, expected_user: User):
def assert_user(self, expected_user: "User"):
"""Check users/me API and assert it matches expected_user"""
from authentik.core.api.users import UserSerializer
self.driver.get(self.url("authentik_api:user-me") + "?format=json")
user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text
user = UserSerializer(data=json.loads(user_json)["user"])
@ -301,46 +182,9 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
self.assertEqual(user["email"].value, expected_user.email)
@lru_cache
def get_loader():
"""Thin wrapper to lazily get a Migration Loader, only when it's needed
and only once"""
return MigrationLoader(connection)
class SeleniumTestCase(BaseSeleniumTestCase, StaticLiveServerTestCase):
"""Test case which spins up a selenium instance and a HTTP-only test server"""
def retry(max_retires=RETRIES, exceptions=None):
"""Retry test multiple times. Default to catching Selenium Timeout Exception"""
if not exceptions:
exceptions = [WebDriverException, TimeoutException, NoSuchElementException]
logger = get_logger()
def retry_actual(func: Callable):
"""Retry test multiple times"""
count = 1
@wraps(func)
def wrapper(self: TransactionTestCase, *args, **kwargs):
"""Run test again if we're below max_retries, including tearDown and
setUp. Otherwise raise the error"""
nonlocal count
try:
return func(self, *args, **kwargs)
except tuple(exceptions) as exc:
count += 1
if count > max_retires:
logger.debug("Exceeded retry count", exc=exc, test=self)
raise exc
logger.debug("Retrying on error", exc=exc, test=self)
self.tearDown()
self._post_teardown()
self._pre_setup()
self.setUp()
return wrapper(self, *args, **kwargs)
return wrapper
return retry_actual
class WebsocketSeleniumTestCase(BaseSeleniumTestCase, BaseWebsocketTestCase):
"""Test case which spins up a selenium instance and a Websocket/HTTP test server"""

48
tests/decorators.py Normal file
View File

@ -0,0 +1,48 @@
"""authentik e2e testing utilities"""
from collections.abc import Callable
from functools import wraps
from django.test.testcases import TransactionTestCase
from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
from structlog.stdlib import get_logger
from tests import RETRIES
def retry(max_retires=RETRIES, exceptions=None):
"""Retry test multiple times. Default to catching Selenium Timeout Exception"""
if not exceptions:
exceptions = [WebDriverException, TimeoutException, NoSuchElementException]
logger = get_logger()
def retry_actual(func: Callable):
"""Retry test multiple times"""
count = 1
@wraps(func)
def wrapper(self: TransactionTestCase, *args, **kwargs):
"""Run test again if we're below max_retries, including tearDown and
setUp. Otherwise raise the error"""
nonlocal count
try:
return func(self, *args, **kwargs)
except tuple(exceptions) as exc:
count += 1
if count > max_retires:
logger.debug("Exceeded retry count", exc=exc, test=self)
raise exc
logger.debug("Retrying on error", exc=exc, test=self)
self.tearDown()
self._post_teardown()
self._pre_setup()
self.setUp()
return wrapper(self, *args, **kwargs)
return wrapper
return retry_actual

139
tests/docker.py Normal file
View File

@ -0,0 +1,139 @@
"""Docker testing helpers"""
import os
from time import sleep
from typing import TYPE_CHECKING, 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 tests import IS_CI
if TYPE_CHECKING:
from authentik.outposts.models import Outpost
def get_docker_tag() -> str:
"""Get docker-tag based off of CI variables"""
env_pr_branch = "GITHUB_HEAD_REF"
default_branch = "GITHUB_REF"
branch_name = os.environ.get(default_branch, "main")
if os.environ.get(env_pr_branch, "") != "":
branch_name = os.environ[env_pr_branch]
branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
return f"gh-{branch_name}"
class DockerTestCase(TestCase):
"""Mixin for dealing with containers"""
max_healthcheck_attempts = 30
__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-{self.__label_id}"
)
super().setUp()
@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 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"
if not IS_CI:
return image
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.stop()
except DockerException:
pass
try:
container.remove(force=True)
except DockerException:
pass
self.__network.remove()
super().tearDown()
def wait_for_container(self, container: Container):
"""Check that container is health"""
attempt = 0
while attempt < self.max_healthcheck_attempts:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
attempt += 1
sleep(0.5)
self.failureException("Container failed to start")
def wait_for_outpost(self, outpost: "Outpost"):
# Wait until outpost healthcheck succeeds
attempt = 0
while attempt < self.max_healthcheck_attempts:
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
return
attempt += 1
sleep(0.5)
self.failureException("Outpost failed to become healthy")

View File

@ -18,10 +18,12 @@ from authentik.stages.authenticator_static.models import (
StaticToken,
)
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDevice
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsAuthenticator(SeleniumTestCase):
class TestFlowsAuthenticator(DockerTestCase, SeleniumTestCase):
"""test flow with otp stages"""
@retry()

View File

@ -11,10 +11,12 @@ from authentik.core.models import User
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsEnroll(SeleniumTestCase):
class TestFlowsEnroll(DockerTestCase, SeleniumTestCase):
"""Test Enroll flow"""
@retry()

View File

@ -2,10 +2,12 @@
from authentik.blueprints.tests import apply_blueprint
from authentik.flows.models import Flow
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsLogin(SeleniumTestCase):
class TestFlowsLogin(DockerTestCase, SeleniumTestCase):
"""test default login flow"""
def tearDown(self):

View File

@ -6,10 +6,12 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from authentik.blueprints.tests import apply_blueprint
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsLoginSFE(SeleniumTestCase):
class TestFlowsLoginSFE(DockerTestCase, SeleniumTestCase):
"""test default login flow"""
def login(self):

View File

@ -13,10 +13,12 @@ from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsRecovery(SeleniumTestCase):
class TestFlowsRecovery(DockerTestCase, SeleniumTestCase):
"""Test Recovery flow"""
def initial_stages(self, user: User):

View File

@ -8,10 +8,12 @@ from authentik.core.models import User
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_key
from authentik.stages.password.models import PasswordStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsStageSetup(SeleniumTestCase):
class TestFlowsStageSetup(DockerTestCase, SeleniumTestCase):
"""test stage setup flows"""
@retry()

View File

@ -16,10 +16,12 @@ from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
from authentik.providers.ldap.models import APIAccessMode, LDAPProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.decorators import retry
from tests.docker import DockerTestCase
from tests.websocket import WebsocketTestCase
class TestProviderLDAP(SeleniumTestCase):
class TestProviderLDAP(DockerTestCase, WebsocketTestCase):
"""LDAP and Outpost e2e tests"""
def start_ldap(self, outpost: Outpost):

View File

@ -18,10 +18,12 @@ from authentik.providers.oauth2.models import (
RedirectURI,
RedirectURIMatchingMode,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2Github(SeleniumTestCase):
class TestProviderOAuth2Github(DockerTestCase, SeleniumTestCase):
"""test OAuth Provider flow"""
def setUp(self):

View File

@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2OAuth(SeleniumTestCase):
class TestProviderOAuth2OAuth(DockerTestCase, SeleniumTestCase):
"""test OAuth with OAuth Provider flow"""
def setUp(self):

View File

@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2OIDC(SeleniumTestCase):
class TestProviderOAuth2OIDC(DockerTestCase, SeleniumTestCase):
"""test OAuth with OpenID Provider flow"""
def setUp(self):

View File

@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
class TestProviderOAuth2OIDCImplicit(DockerTestCase, SeleniumTestCase):
"""test OAuth with OpenID Provider flow"""
def setUp(self):

View File

@ -3,11 +3,8 @@
from base64 import b64encode
from dataclasses import asdict
from json import loads
from sys import platform
from time import sleep
from unittest.case import skip, skipUnless
from channels.testing import ChannelsLiveServerTestCase
from jwt import decode
from selenium.webdriver.common.by import By
@ -18,10 +15,13 @@ from authentik.lib.generators import generate_id
from authentik.outposts.models import DockerServiceConnection, Outpost, OutpostConfig, OutpostType
from authentik.outposts.tasks import outpost_connection_discovery
from authentik.providers.proxy.models import ProxyProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
from tests.websocket import WebsocketTestCase
class TestProviderProxy(SeleniumTestCase):
class TestProviderProxy(DockerTestCase, SeleniumTestCase):
"""Proxy and Outpost e2e tests"""
def setUp(self):
@ -37,13 +37,41 @@ class TestProviderProxy(SeleniumTestCase):
"""Start proxy container based on outpost created"""
self.run_container(
image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"),
ports={
"9000": "9000",
},
environment={
"AUTHENTIK_TOKEN": outpost.token.key,
},
ports={"9000": "9000"},
environment={"AUTHENTIK_TOKEN": outpost.token.key},
)
self.wait_for_outpost(outpost)
def _prepare(self):
# set additionalHeaders to test later
self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"}
self.user.save()
proxy: ProxyProvider = ProxyProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
basic_auth_enabled=True,
basic_auth_user_attribute="basic-username",
basic_auth_password_attribute="basic-password", # nosec
)
# Ensure OAuth2 Params are set
proxy.set_oauth_defaults()
proxy.save()
# we need to create an application to actually access the proxy
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
outpost: Outpost = Outpost.objects.create(
name=generate_id(),
type=OutpostType.PROXY,
)
outpost.providers.add(proxy)
outpost.build_user_permissions(outpost.user)
self.start_proxy(outpost)
@retry()
@apply_blueprint(
@ -61,44 +89,7 @@ class TestProviderProxy(SeleniumTestCase):
@reconcile_app("authentik_crypto")
def test_proxy_simple(self):
"""Test simple outpost setup with single provider"""
# set additionalHeaders to test later
self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"}
self.user.save()
proxy: ProxyProvider = ProxyProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
)
# Ensure OAuth2 Params are set
proxy.set_oauth_defaults()
proxy.save()
# we need to create an application to actually access the proxy
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
outpost: Outpost = Outpost.objects.create(
name=generate_id(),
type=OutpostType.PROXY,
)
outpost.providers.add(proxy)
outpost.build_user_permissions(outpost.user)
self.start_proxy(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
self._prepare()
self.driver.get("http://localhost:9000/api")
self.login()
sleep(1)
@ -137,49 +128,13 @@ class TestProviderProxy(SeleniumTestCase):
@reconcile_app("authentik_crypto")
def test_proxy_basic_auth(self):
"""Test simple outpost setup with single provider"""
self._prepare()
# Setup basic auth
cred = generate_id()
attr = "basic-password" # nosec
self.user.attributes["basic-username"] = cred
self.user.attributes[attr] = cred
self.user.attributes["basic-password"] = cred
self.user.save()
proxy: ProxyProvider = ProxyProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
basic_auth_enabled=True,
basic_auth_user_attribute="basic-username",
basic_auth_password_attribute=attr,
)
# Ensure OAuth2 Params are set
proxy.set_oauth_defaults()
proxy.save()
# we need to create an application to actually access the proxy
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
outpost: Outpost = Outpost.objects.create(
name=generate_id(),
type=OutpostType.PROXY,
)
outpost.providers.add(proxy)
outpost.build_user_permissions(outpost.user)
self.start_proxy(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
self.driver.get("http://localhost:9000/api")
self.login()
sleep(1)
@ -187,9 +142,9 @@ class TestProviderProxy(SeleniumTestCase):
full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text
body = loads(full_body_text)
self.assertEqual(body["headers"]["X-Authentik-Username"], [self.user.username])
self.assertEqual(body.get("headers").get("X-Authentik-Username"), [self.user.username])
auth_header = b64encode(f"{cred}:{cred}".encode()).decode()
self.assertEqual(body["headers"]["Authorization"], [f"Basic {auth_header}"])
self.assertEqual(body.get("headers").get("Authorization"), [f"Basic {auth_header}"])
self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out")
sleep(2)
@ -199,10 +154,7 @@ class TestProviderProxy(SeleniumTestCase):
self.assertIn("You've logged out of", title)
# TODO: Fix flaky test
@skip("Flaky test")
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestProviderProxyConnect(ChannelsLiveServerTestCase):
class TestProviderProxyConnect(DockerTestCase, WebsocketTestCase):
"""Test Proxy connectivity over websockets"""
@retry(exceptions=[AssertionError])
@ -241,14 +193,7 @@ class TestProviderProxyConnect(ChannelsLiveServerTestCase):
outpost.build_user_permissions(outpost.user)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen and state.version:
break
healthcheck_retries += 1
sleep(0.5)
self.wait_for_outpost(outpost)
state = outpost.state
self.assertGreaterEqual(len(state), 1)

View File

@ -13,10 +13,12 @@ from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderProxyForward(SeleniumTestCase):
class TestProviderProxyForward(DockerTestCase, SeleniumTestCase):
"""Proxy and Outpost e2e tests"""
def setUp(self):
@ -30,14 +32,11 @@ class TestProviderProxyForward(SeleniumTestCase):
"""Start proxy container based on outpost created"""
self.run_container(
image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"),
ports={
"9000": "9000",
},
environment={
"AUTHENTIK_TOKEN": outpost.token.key,
},
ports={"9000": "9000"},
environment={"AUTHENTIK_TOKEN": outpost.token.key},
name="ak-test-outpost",
)
self.wait_for_outpost(outpost)
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
@ -77,17 +76,6 @@ class TestProviderProxyForward(SeleniumTestCase):
self.start_outpost(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
@retry()
def test_traefik(self):
"""Test traefik"""

View File

@ -1,7 +1,6 @@
"""Radius e2e tests"""
from dataclasses import asdict
from time import sleep
from pyrad.client import Client
from pyrad.dictionary import Dictionary
@ -9,14 +8,17 @@ from pyrad.packet import AccessAccept, AccessReject, AccessRequest
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, User
from authentik.core.tests.utils import create_test_user
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id, generate_key
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
from authentik.providers.radius.models import RadiusProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.decorators import retry
from tests.docker import DockerTestCase
from tests.websocket import WebsocketTestCase
class TestProviderRadius(SeleniumTestCase):
class TestProviderRadius(DockerTestCase, WebsocketTestCase):
"""Radius Outpost e2e tests"""
def setUp(self):
@ -28,13 +30,13 @@ class TestProviderRadius(SeleniumTestCase):
self.run_container(
image=self.get_container_image("ghcr.io/goauthentik/dev-radius"),
ports={"1812/udp": "1812/udp"},
environment={
"AUTHENTIK_TOKEN": outpost.token.key,
},
environment={"AUTHENTIK_TOKEN": outpost.token.key},
)
self.wait_for_outpost(outpost)
def _prepare(self) -> User:
"""prepare user, provider, app and container"""
self.user = create_test_user()
radius: RadiusProvider = RadiusProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(slug="default-authentication-flow"),
@ -50,17 +52,6 @@ class TestProviderRadius(SeleniumTestCase):
outpost.providers.add(radius)
self.start_radius(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
return outpost
@retry()

View File

@ -14,10 +14,12 @@ from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
from authentik.sources.saml.processors.constants import SAML_BINDING_POST
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderSAML(SeleniumTestCase):
class TestProviderSAML(DockerTestCase, SeleniumTestCase):
"""test SAML Provider flow"""
def setup_client(self, provider: SAMLProvider, force_post: bool = False):

View File

@ -11,10 +11,12 @@ from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestSourceLDAPSamba(SeleniumTestCase):
class TestSourceLDAPSamba(DockerTestCase, SeleniumTestCase):
"""test LDAP Source"""
def setUp(self):

View File

@ -16,7 +16,9 @@ from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.types.registry import SourceType, registry
from authentik.sources.oauth.views.callback import OAuthCallback
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class OAuth1Callback(OAuthCallback):
@ -48,7 +50,7 @@ class OAUth1Type(SourceType):
}
class TestSourceOAuth1(SeleniumTestCase):
class TestSourceOAuth1(DockerTestCase, SeleniumTestCase):
"""Test OAuth1 Source"""
def setUp(self) -> None:

View File

@ -16,10 +16,12 @@ from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestSourceOAuth2(SeleniumTestCase):
class TestSourceOAuth2(DockerTestCase, SeleniumTestCase):
"""test OAuth Source flow"""
def setUp(self):

View File

@ -16,7 +16,9 @@ from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
IDP_CERT = """-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
@ -70,7 +72,7 @@ Sm75WXsflOxuTn08LbgGc4s=
-----END PRIVATE KEY-----"""
class TestSourceSAML(SeleniumTestCase):
class TestSourceSAML(DockerTestCase, SeleniumTestCase):
"""test SAML Source flow"""
def setUp(self):

View File

@ -8,12 +8,14 @@ from docker.types import Healthcheck
from authentik.lib.generators import generate_id
from authentik.lib.utils.http import get_http_session
from authentik.sources.scim.models import SCIMSource
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
TEST_POLL_MAX = 25
class TestSourceSCIM(SeleniumTestCase):
class TestSourceSCIM(DockerTestCase, SeleniumTestCase):
"""test SCIM Source flow"""
def setUp(self):

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -19,7 +19,7 @@ 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 tests.docker import DockerTestCase, get_docker_tag
class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):

View File

@ -19,7 +19,7 @@ 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 tests.docker import DockerTestCase, get_docker_tag
class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):

52
tests/websocket.py Normal file
View File

@ -0,0 +1,52 @@
# This file cannot import anything django or anything that will load django
from sys import stderr
from channels.testing import ChannelsLiveServerTestCase
from daphne.testing import DaphneProcess
from structlog.stdlib import get_logger
from tests import IS_CI, get_local_ip
def set_database_connection():
from django.conf import settings
settings.DATABASES["default"]["NAME"] = settings.DATABASES["default"]["TEST"]["NAME"]
settings.TEST = True
class DatabasePatchDaphneProcess(DaphneProcess):
# See https://github.com/django/channels/issues/2048
# See https://github.com/django/channels/pull/2033
def __init__(self, host, get_application, kwargs=None, setup=None, teardown=None):
super().__init__(host, get_application, kwargs, setup, teardown)
self.setup = set_database_connection
class BaseWebsocketTestCase(ChannelsLiveServerTestCase):
"""Base channels test case"""
host = get_local_ip()
ProtocolServerProcess = DatabasePatchDaphneProcess
class WebsocketTestCase(BaseWebsocketTestCase):
"""Test case to allow testing against a running Websocket/HTTP server"""
def setUp(self):
if IS_CI:
print("::group::authentik Logs", file=stderr)
from django.apps import apps
from authentik.core.tests.utils import create_test_admin_user
apps.get_app_config("authentik_tenants").ready()
self.logger = get_logger()
self.user = create_test_admin_user()
super().setUp()
def tearDown(self):
if IS_CI:
print("::endgroup::", file=stderr)
super().tearDown()

View File

@ -3,11 +3,13 @@
* @import { StorybookConfig } from "@storybook/web-components-vite";
* @import { InlineConfig, Plugin } from "vite";
*/
import { createBundleDefinitions } from "@goauthentik/web/scripts/esbuild/environment";
import { cwd } from "process";
import postcssLit from "rollup-plugin-postcss-lit";
import tsconfigPaths from "vite-tsconfig-paths";
const CSSImportPattern = /import [\w$]+ from .+\.(css)/g;
const NODE_ENV = process.env.NODE_ENV || "development";
const CSSImportPattern = /import [\w\$]+ from .+\.(css)/g;
const JavaScriptFilePattern = /\.m?(js|ts|tsx)$/;
/**
@ -52,7 +54,11 @@ const config = {
*/
const mergedConfig = {
...config,
define: createBundleDefinitions(),
define: {
"process.env.NODE_ENV": JSON.stringify(NODE_ENV),
"process.env.CWD": JSON.stringify(cwd()),
"process.env.AK_API_BASE_PATH": JSON.stringify(process.env.AK_API_BASE_PATH || ""),
},
plugins: [inlineCSSPlugin, ...plugins, postcssLit(), tsconfigPaths()],
};

View File

@ -21,7 +21,7 @@ const log = console.debug.bind(console, logPrefix);
* ESBuild may tree-shake it out of production builds.
*
* ```ts
* if (import.meta.env.NODE_ENV=== "development") {
* if (process.env.NODE_ENV === "development") {
* await import("@goauthentik/esbuild-plugin-live-reload/client")
* .catch(() => console.warn("Failed to import watcher"))
* }

View File

@ -4,20 +4,15 @@
export {};
declare global {
/**
* Environment variables injected by ESBuild.
*/
interface ImportMetaEnv {
/**
* The injected watcher URL for ESBuild.
* This is used for live reloading in development mode.
*
* @format url
*/
readonly ESBUILD_WATCHER_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
readonly env: {
/**
* The injected watcher URL for ESBuild.
* This is used for live reloading in development mode.
*
* @format url
*/
ESBUILD_WATCHER_URL: string;
};
}
}

View File

@ -1,20 +1,6 @@
/**
* @file Utility functions for working with environment variables.
* @file Utility functions for building and copying files.
*/
/// <reference types="./types/global.js" />
//#region Constants
/**
* The current Node.js environment, defaulting to "development" when not set.
*
* Note, this should only be used during the build process.
*
* If you need to check the environment at runtime, use `process.env.NODE_ENV` to
* ensure that module tree-shaking works correctly.
*
*/
export const NodeEnvironment = process.env.NODE_ENV || "development";
/**
* A source environment variable, which can be a string, number, boolean, null, or undefined.
@ -28,26 +14,19 @@ export const NodeEnvironment = process.env.NODE_ENV || "development";
* @typedef {T extends string ? `"${T}"` : T} JSONify
*/
//#endregion
//#region Utilities
/**
* Given an object of environment variables, returns a new object with the same keys and values, but
* with the values serialized as strings.
*
* @template {Record<string, EnvironmentVariable>} EnvRecord
* @template {string} [Prefix='import.meta.env.']
* @template {string} [Prefix='process.env.']
*
* @param {EnvRecord} input
* @param {Prefix} [prefix='import.meta.env.']
* @param {Prefix} [prefix='process.env.']
*
* @returns {{[K in keyof EnvRecord as `${Prefix}${K}`]: JSONify<EnvRecord[K]>}}
*/
export function serializeEnvironmentVars(
input,
prefix = /** @type {Prefix} */ ("import.meta.env."),
) {
export function serializeEnvironmentVars(input, prefix = /** @type {Prefix} */ ("process.env.")) {
/**
* @type {Record<string, string>}
*/
@ -61,5 +40,3 @@ export function serializeEnvironmentVars(
return /** @type {any} */ (env);
}
//#endregion

View File

@ -0,0 +1,16 @@
/**
* @file Constants for JavaScript and TypeScript files.
*/
/// <reference types="../../types/global.js" />
/**
* The current Node.js environment, defaulting to "development" when not set.
*
* Note, this should only be used during the build process.
*
* If you need to check the environment at runtime, use `process.env.NODE_ENV` to
* ensure that module tree-shaking works correctly.
*
*/
export const NodeEnvironment = process.env.NODE_ENV || "development";

View File

@ -1,6 +1,7 @@
/// <reference types="./types/global.js" />
export * from "./paths.js";
export * from "./environment.js";
export * from "./constants.js";
export * from "./build.js";
export * from "./version.js";
export * from "./scripting.js";

View File

@ -1,32 +1,17 @@
import { spawnSync } from "child_process";
import fs from "fs";
import path from "path";
import process from "process";
/**
* @file Lit Localize build script.
*
* @remarks
* Determines if all the Xliff translation source files are present and if the Typescript source files generated from those sources are up-to-date.
*
* If they are not, it runs the locale building script,
* intercepting the long spew of "this string is not translated" and replacing it with a
* Determines if all the Xliff translation source files are present and if the Typescript source
* files generated from those sources are up-to-date. If they are not, it runs the locale building
* script, intercepting the long spew of "this string is not translated" and replacing it with a
* summary of how many strings are missing with respect to the source locale.
*
* @import { ConfigFile } from "@lit/localize-tools/lib/types/config"
*/
import { PackageRoot } from "@goauthentik/web/paths";
import { spawnSync } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import path from "node:path";
/**
* @type {ConfigFile}
*/
const localizeRules = JSON.parse(
readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"),
);
const localizeRules = JSON.parse(fs.readFileSync("./lit-localize.json", "utf-8"));
/**
*
* @param {string} loc
* @returns {boolean}
*/
function generatedFileIsUpToDateWithXliffSource(loc) {
const xliff = path.join("./xliff", `${loc}.xlf`);
const gened = path.join("./src/locales", `${loc}.ts`);
@ -37,7 +22,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) {
// generates a unique error message and halts the build.
try {
var xlfStat = statSync(xliff);
var xlfStat = fs.statSync(xliff);
} catch (_error) {
console.error(`lit-localize expected '${loc}.xlf', but XLF file is not present`);
process.exit(1);
@ -45,7 +30,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) {
// If the generated file doesn't exist, of course it's not up to date.
try {
var genedStat = statSync(gened);
var genedStat = fs.statSync(gened);
} catch (_error) {
return false;
}

View File

@ -1,4 +1,3 @@
/// <reference types="../types/esbuild.js" />
/**
* @file ESBuild script for building the authentik web UI.
*
@ -10,6 +9,7 @@ import {
NodeEnvironment,
readBuildIdentifier,
resolvePackage,
serializeEnvironmentVars,
} from "@goauthentik/monorepo";
import { DistDirectory, DistDirectoryName, EntryPoint, PackageRoot } from "@goauthentik/web/paths";
import { deepmerge } from "deepmerge-ts";
@ -20,10 +20,15 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { mdxPlugin } from "./esbuild/build-mdx-plugin.mjs";
import { createBundleDefinitions } from "./esbuild/environment.mjs";
const logPrefix = "[Build]";
const definitions = serializeEnvironmentVars({
NODE_ENV: NodeEnvironment,
CWD: process.cwd(),
AK_API_BASE_PATH: process.env.AK_API_BASE_PATH,
});
const patternflyPath = resolvePackage("@patternfly/patternfly");
/**
@ -81,7 +86,7 @@ const BASE_ESBUILD_OPTIONS = {
root: MonoRepoRoot,
}),
],
define: createBundleDefinitions(),
define: definitions,
format: "esm",
logOverride: {
/**

View File

@ -1,29 +0,0 @@
/**
* @file ESBuild environment utilities.
*/
import { AuthentikVersion, NodeEnvironment, serializeEnvironmentVars } from "@goauthentik/monorepo";
/**
* Creates a mapping of environment variables to their respective runtime constants.
*/
export function createBundleDefinitions() {
const SerializedNodeEnvironment = /** @type {`"development"` | `"production"`} */ (
JSON.stringify(NodeEnvironment)
);
/**
* @satisfies {Record<ESBuildImportEnvKey, string>}
*/
const envRecord = {
AK_VERSION: AuthentikVersion,
AK_API_BASE_PATH: process.env.AK_API_BASE_PATH ?? "",
};
return {
...serializeEnvironmentVars(envRecord),
// We need to explicitly set this for NPM packages that use `process`
// to determine their environment.
"process.env.NODE_ENV": SerializedNodeEnvironment,
"import.meta.env.NODE_ENV": SerializedNodeEnvironment,
};
}

View File

@ -35,11 +35,6 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
const projectRoot = path.join(__dirname, "..");
process.chdir(projectRoot);
/**
*
* @param {string[]} flags
* @returns
*/
const hasFlag = (flags) => process.argv.length > 1 && flags.includes(process.argv[2]);
const [configFile, files] = hasFlag(["-n", "--nightmare"])

View File

@ -1,36 +1,22 @@
/**
* @file Pseudo-localization script.
*
* @import { ConfigFile } from "@lit/localize-tools/lib/types/config.js"
* @import { Config } from '@lit/localize-tools/lib/types/config.js';
* @import { ProgramMessage } from "@lit/localize-tools/src/messages.js"
* @import { Locale } from "@lit/localize-tools/src/types/locale.js"
*/
import { PackageRoot } from "@goauthentik/web/paths";
import { readFileSync } from "node:fs";
import path from "node:path";
import { readFileSync } from "fs";
import path from "path";
import pseudolocale from "pseudolocale";
import { fileURLToPath } from "url";
import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js";
import { sortProgramMessages } from "@lit/localize-tools/lib/messages.js";
import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.js";
const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE");
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const pseudoLocale = "pseudo-LOCALE";
const targetLocales = [pseudoLocale];
/**
* @type {ConfigFile}
*/
const baseConfig = JSON.parse(readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"));
const baseConfig = JSON.parse(readFileSync(path.join(__dirname, "../lit-localize.json"), "utf-8"));
// Need to make some internal specifications to satisfy the transformer. It doesn't actually matter
// which Localizer we use (transformer or runtime), because all of the functionality we care about
// is in their common parent class, but I had to pick one. Everything else here is just pure
// exploitation of the lit/localize-tools internals.
/**
* @satisfies {Config}
*/
const config = {
...baseConfig,
baseDir: path.join(__dirname, ".."),
@ -42,11 +28,6 @@ const config = {
resolve: (path) => path,
};
/**
*
* @param {ProgramMessage} message
* @returns
*/
const pseudoMessagify = (message) => ({
name: message.name,
contents: message.contents.map((content) =>
@ -55,7 +36,7 @@ const pseudoMessagify = (message) => ({
});
const localizer = new TransformLitLocalizer(config);
const { messages } = localizer.extractSourceMessages();
const messages = localizer.extractSourceMessages().messages;
const translations = messages.map(pseudoMessagify);
const sorted = sortProgramMessages([...messages]);
const formatter = makeFormatter(config);

View File

@ -1,4 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { DefaultBrand } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/EmptyState";
@ -44,7 +45,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
}
return [
[msg("Version"), version.versionCurrent],
[msg("UI Version"), import.meta.env.AK_VERSION],
[msg("UI Version"), VERSION],
[msg("Build"), build],
[msg("Python version"), status.runtime.pythonVersion],
[msg("Platform"), status.runtime.platform],

View File

@ -43,7 +43,7 @@ import {
renderSidebarItems,
} from "./AdminSidebar.js";
if (import.meta.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}

View File

@ -8,6 +8,7 @@ import "@goauthentik/admin/admin-overview/cards/WorkerStatusCard";
import "@goauthentik/admin/admin-overview/charts/AdminLoginAuthorizeChart";
import "@goauthentik/admin/admin-overview/charts/OutpostStatusChart";
import "@goauthentik/admin/admin-overview/charts/SyncStatusChart";
import { VERSION } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
@ -21,6 +22,8 @@ import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import { when } from "lit/directives/when.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
@ -30,17 +33,21 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { SessionUser } from "@goauthentik/api";
function createReleaseNotesURL(semver: string): URL {
const segments = semver.split(".");
const versionFamily = segments.slice(0, -1).join(".");
const release = `${versionFamily}#fixed-in-${segments.join("")}`;
return new URL(`/docs/releases/${release}`, "https://goauthentik.io");
export function versionFamily(): string {
const parts = VERSION.split(".");
parts.pop();
return parts.join(".");
}
const RELEASE = `${VERSION.split(".").slice(0, -1).join(".")}#fixed-in-${VERSION.replaceAll(
".",
"",
)}`;
const AdminOverviewBase = WithLicenseSummary(AKElement);
type Renderer = () => TemplateResult | typeof nothing;
@customElement("ak-admin-overview")
export class AdminOverviewPage extends AdminOverviewBase {
static get styles(): CSSResult[] {
@ -76,11 +83,7 @@ export class AdminOverviewPage extends AdminOverviewBase {
[msg("Check the logs"), paramURL("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")],
[
msg("Check the release notes"),
createReleaseNotesURL(import.meta.env.AK_VERSION).href,
true,
],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
];
@state()
@ -190,6 +193,45 @@ export class AdminOverviewPage extends AdminOverviewBase {
</div>`
: nothing} `;
}
renderActions() {
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
const quickActions: [string, string][] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/"],
[msg("Manage users"), paramURL("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
];
const action = ([label, url]: [string, string]) => {
const isExternal = url.startsWith("https://");
const ex = (truecase: Renderer, falsecase: Renderer) =>
when(isExternal, truecase, falsecase);
const content = html`${label}${ex(
() => html`<i class="fas fa-external-link-alt ak-external-link"></i>`,
() => nothing,
)}`;
return html`<li>
${ex(
() =>
html`<a
href="${url}"
class="pf-u-mb-xl"
rel="noopener noreferrer"
target="_blank"
>${content}</a
>`,
() => html`<a href="${url}" class="pf-u-mb-xl" )>${content}</a>`,
)}
</li>`;
};
return html`${map(quickActions, action)}`;
}
}
declare global {

View File

@ -3,7 +3,7 @@ import {
EventMiddleware,
LoggingMiddleware,
} from "@goauthentik/common/api/middleware.js";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants.js";
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants.js";
import { globalAK } from "@goauthentik/common/global.js";
import { SentryMiddleware } from "@goauthentik/common/sentry";
@ -79,6 +79,4 @@ export function AndNext(url: string): string {
return `?next=${encodeURIComponent(url)}`;
}
console.debug(
`authentik(early): version ${import.meta.env.AK_VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`,
);
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);

View File

@ -1,35 +1,12 @@
/**
* @file Global constants used throughout the application.
*
* @todo Much of this content can be moved to a specific file, element, or component.
*/
/// <reference types="../../types/esbuild.js" />
//#region Patternfly
export const SECONDARY_CLASS = "pf-m-secondary";
export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current";
//#endregion
//#region Application
export const VERSION = "2025.4.1";
export const TITLE_DEFAULT = "authentik";
/**
* The delimiter used to parse the URL for the current route.
*
* @todo Move this to the ak-router.
*/
export const ROUTE_SEPARATOR = ";";
//#endregion
//#region Events
export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";
export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle";
@ -43,17 +20,7 @@ export const EVENT_MESSAGE = "ak-message";
export const EVENT_THEME_CHANGE = "ak-theme-change";
export const EVENT_REFRESH_ENTERPRISE = "ak-refresh-enterprise";
//#endregion
//#region WebSocket
export const WS_MSG_TYPE_MESSAGE = "message";
export const WS_MSG_TYPE_REFRESH = "refresh";
//#endregion
//#region LocalStorage
export const LOCALSTORAGE_AUTHENTIK_KEY = "authentik-local-settings";
//#endregion

View File

@ -1,3 +1,4 @@
import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
export interface PlexPinResponse {
@ -18,7 +19,7 @@ export const DEFAULT_HEADERS = {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Plex-Product": "authentik",
"X-Plex-Version": import.meta.env.AK_VERSION,
"X-Plex-Version": VERSION,
"X-Plex-Device-Vendor": "goauthentik.io",
};

View File

@ -1,3 +1,4 @@
import { VERSION } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { me } from "@goauthentik/common/users";
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
@ -49,7 +50,7 @@ export function configureSentry(canDoPpi = false) {
/MutationObserver.observe/gi,
/NS_ERROR_FAILURE/gi,
],
release: `authentik@${import.meta.env.AK_VERSION}`,
release: `authentik@${VERSION}`,
integrations: [
browserTracingIntegration({
// https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#custom-routing

View File

@ -223,7 +223,7 @@ export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleS
};
}
if (import.meta.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") {
Object.assign(window, {
inspectStyleSheetTree,
serializeStyleSheet,

View File

@ -5,11 +5,11 @@ import {
type StyleRoot,
createStyleSheetUnsafe,
setAdoptedStyleSheets,
} from "@goauthentik/web/common/stylesheets.js";
import { UIConfig } from "@goauthentik/web/common/ui/config.js";
} from "@goauthentik/common/stylesheets.js";
import { UIConfig } from "@goauthentik/common/ui/config.js";
import AKBase from "@goauthentik/web/common/styles/authentik.css";
import AKBaseDark from "@goauthentik/web/common/styles/theme-dark.css";
import AKBase from "@goauthentik/common/styles/authentik.css";
import AKBaseDark from "@goauthentik/common/styles/theme-dark.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";

View File

@ -1,4 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants";
import { PFSize } from "@goauthentik/common/enums.js";
import {
EventContext,
@ -75,7 +76,7 @@ ${context.message as string}
**Version and Deployment (please complete the following information):**
- authentik version: ${import.meta.env.AK_VERSION}
- authentik version: ${VERSION}
- Deployment: [e.g. docker-compose, helm]
**Additional context**

View File

@ -1,3 +1,4 @@
import { VERSION } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
@ -9,19 +10,20 @@ import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
@customElement("ak-version-banner")
export class VersionBanner extends WithVersion(AKElement) {
static styles = [PFBanner];
static get styles() {
return [PFBanner];
}
render() {
if (!this.version?.versionCurrent) return nothing;
if (this.version.versionCurrent === import.meta.env.AK_VERSION) return nothing;
return html`
<div class="pf-c-banner pf-m-sticky pf-m-gold">
${msg(
str`A newer version (${this.version.versionCurrent}) of the UI is available.`,
)}
</div>
`;
return this.version && this.version.versionCurrent !== VERSION
? html`
<div class="pf-c-banner pf-m-sticky pf-m-gold">
${msg(
str`A newer version (${this.version.versionCurrent}) of the UI is available.`,
)}
</div>
`
: nothing;
}
}

View File

@ -14,6 +14,6 @@ import "@goauthentik/flow/stages/password/PasswordStage";
// end of stage import
if (import.meta.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}

View File

@ -43,7 +43,7 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import { CurrentBrand, EventsApi, SessionUser } from "@goauthentik/api";
if (import.meta.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}

View File

@ -2,9 +2,6 @@
{
"extends": "@goauthentik/tsconfig",
"compilerOptions": {
"checkJs": true,
"allowJs": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"emitDeclarationOnly": true,
"experimentalDecorators": true,

View File

@ -1,39 +0,0 @@
/**
* @file Import meta environment variables available via ESBuild.
*/
export {};
declare global {
interface ESBuildImportEnv {
/**
* The authentik version injected by ESBuild during build time.
*
* @format semver
*/
readonly AK_VERSION: string;
/**
* @todo Determine where this is used and if it is needed,
* give it a better name.
* @deprecated
*/
readonly AK_API_BASE_PATH: string;
}
type ESBuildImportEnvKey = keyof ESBuildImportEnv;
/**
* Environment variables injected by ESBuild.
*/
interface ImportMetaEnv extends ESBuildImportEnv {
/**
* An environment variable used to determine
* whether Node.js is running in production mode.
*/
readonly NODE_ENV: "development" | "production";
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
}

View File

@ -1,5 +1,5 @@
/**
* @file Global variables provided by Node.js
* @file Environment variables available via ESBuild.
*/
declare module "module" {
@ -14,8 +14,8 @@ declare module "module" {
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
* ```
*/
const __dirname: string;
// eslint-disable-next-line no-var
var __dirname: string;
}
}
@ -23,16 +23,13 @@ declare module "process" {
global {
namespace NodeJS {
interface ProcessEnv {
/**
* Node environment, if any.
*/
readonly NODE_ENV?: "development" | "production";
CWD: string;
/**
* @todo Determine where this is used and if it is needed,
* give it a better name.
* @deprecated
*/
readonly AK_API_BASE_PATH?: string;
AK_API_BASE_PATH: string;
}
}
}

View File

@ -1,15 +1,17 @@
/// <reference types="@wdio/browser-runner" />
import replace from "@rollup/plugin-replace";
import { browser } from "@wdio/globals";
import type { Options } from "@wdio/types";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createBundleDefinitions } from "scripts/esbuild/environment.mjs";
import type { InlineConfig } from "vite";
import litCSS from "vite-plugin-lit-css";
import path from "path";
import { cwd } from "process";
import { fileURLToPath } from "url";
import type { UserConfig } from "vite";
import litCss from "vite-plugin-lit-css";
import tsconfigPaths from "vite-tsconfig-paths";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const isProdBuild = process.env.NODE_ENV === "production";
const apiBasePath = process.env.AK_API_BASE_PATH || "";
const runHeadless = process.env.CI !== undefined;
const testSafari = process.env.WDIO_TEST_SAFARI !== undefined;
@ -70,9 +72,21 @@ export const config: Options.Testrunner = {
runner: [
"browser",
{
viteConfig: {
define: createBundleDefinitions(),
plugins: [litCSS(), tsconfigPaths()],
viteConfig: (userConfig: UserConfig = { plugins: [] }) => ({
...userConfig,
plugins: [
litCss(),
replace({
"process.env.NODE_ENV": JSON.stringify(
isProdBuild ? "production" : "development",
),
"process.env.CWD": JSON.stringify(cwd()),
"process.env.AK_API_BASE_PATH": JSON.stringify(apiBasePath),
"preventAssignment": true,
}),
...(userConfig?.plugins ?? []),
tsconfigPaths(),
],
resolve: {
alias: {
"@goauthentik/admin": path.resolve(__dirname, "src/admin"),
@ -87,7 +101,7 @@ export const config: Options.Testrunner = {
"@goauthentik/user": path.resolve(__dirname, "src/user"),
},
},
} satisfies InlineConfig,
}),
},
],