This is an automated email from the ASF dual-hosted git repository.

dheerajturaga pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 6445ba58062 Add tests for edge3 worker API app and JWT auth (#72482)
6445ba58062 is described below

commit 6445ba58062fe03c3e0d2d111d4784b475f5ad9d
Author: Dheeren Mohta <[email protected]>
AuthorDate: Sun Sep 6 09:09:05 2026 +0530

    Add tests for edge3 worker API app and JWT auth (#72482)
    
    The FastAPI app factory and the JWT authorization layer for the Edge
    Worker API had no dedicated unit tests, leaving the route/mount wiring
    (v1 API prefix, UI prefix, static/res mounts, .cjs mimetype) and the
    authorization flow (method-claim checks, per-exception anonymized 403
    responses, validator caching semantics) unverified against regressions.
    
    Closes: #72266
---
 .../tests/unit/always/test_project_structure.py    |   2 -
 .../edge3/tests/unit/edge3/worker_api/test_app.py  |  65 +++++++++
 .../edge3/tests/unit/edge3/worker_api/test_auth.py | 152 +++++++++++++++++++++
 3 files changed, 217 insertions(+), 2 deletions(-)

diff --git a/airflow-core/tests/unit/always/test_project_structure.py 
b/airflow-core/tests/unit/always/test_project_structure.py
index bc1bca06407..fe0d230deac 100644
--- a/airflow-core/tests/unit/always/test_project_structure.py
+++ b/airflow-core/tests/unit/always/test_project_structure.py
@@ -109,8 +109,6 @@ class TestProjectStructure:
             "providers/edge3/tests/unit/edge3/models/test_edge_job.py",
             "providers/edge3/tests/unit/edge3/models/test_edge_logs.py",
             "providers/edge3/tests/unit/edge3/models/test_edge_worker.py",
-            "providers/edge3/tests/unit/edge3/worker_api/test_app.py",
-            "providers/edge3/tests/unit/edge3/worker_api/test_auth.py",
             "providers/edge3/tests/unit/edge3/worker_api/test_datamodels.py",
             
"providers/edge3/tests/unit/edge3/worker_api/test_datamodels_ui.py",
             
"providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/test_login.py",
diff --git a/providers/edge3/tests/unit/edge3/worker_api/test_app.py 
b/providers/edge3/tests/unit/edge3/worker_api/test_app.py
new file mode 100644
index 00000000000..ec1c54522c9
--- /dev/null
+++ b/providers/edge3/tests/unit/edge3/worker_api/test_app.py
@@ -0,0 +1,65 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import mimetypes
+from unittest import mock
+
+import pytest
+from fastapi.routing import APIRoute
+from starlette.routing import Mount
+
+from airflow.providers.edge3.worker_api.app import create_edge_worker_api_app
+
+
[email protected]
+def app():
+    # The React UI bundle (``plugins/www/dist``) is only produced by a JS 
build step and
+    # is not present in a plain source checkout, so the real StaticFiles 
directory check
+    # would fail here regardless of how create_edge_worker_api_app() itself 
behaves.
+    with mock.patch("airflow.providers.edge3.worker_api.app.StaticFiles") as 
mocked_static_files:
+        mocked_static_files.side_effect = lambda *args, **kwargs: 
mock.MagicMock()
+        yield create_edge_worker_api_app()
+
+
+def _api_route_paths(app) -> set[str]:
+    return {route.path for route in app.routes if isinstance(route, APIRoute)}
+
+
+class TestCreateEdgeWorkerApiApp:
+    def test_v1_routers_are_mounted_under_the_v1_prefix(self, app):
+        route_paths = _api_route_paths(app)
+        assert "/v1/health" in route_paths
+        assert "/v1/jobs/fetch/{worker_name}" in route_paths
+        assert 
"/v1/logs/logfile_path/{dag_id}/{task_id}/{run_id}/{try_number}/{map_index}" in 
route_paths
+        assert "/v1/worker/{worker_name}" in route_paths
+
+    def test_ui_router_is_mounted_under_the_ui_prefix(self, app):
+        route_paths = _api_route_paths(app)
+        assert "/ui/worker" in route_paths
+        assert not any(path.startswith("/v1") for path in route_paths if path 
== "/ui/worker")
+
+    def test_static_and_res_directories_are_mounted(self, app):
+        mounts = {route.path: route for route in app.routes if 
isinstance(route, Mount)}
+        assert "/static" in mounts
+        assert "/res" in mounts
+        assert mounts["/static"].name == "react_static_plugin_files"
+        assert mounts["/res"].name == "react_res_plugin_files"
+
+    def test_cjs_mimetype_is_registered_as_javascript(self, app):
+        # Serving .cjs with the wrong mimetype breaks the Edge Worker UI in 
the browser.
+        assert mimetypes.guess_type("plugin.cjs")[0] == 
"application/javascript"
diff --git a/providers/edge3/tests/unit/edge3/worker_api/test_auth.py 
b/providers/edge3/tests/unit/edge3/worker_api/test_auth.py
new file mode 100644
index 00000000000..3d4263be855
--- /dev/null
+++ b/providers/edge3/tests/unit/edge3/worker_api/test_auth.py
@@ -0,0 +1,152 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from unittest import mock
+
+import jwt
+import pytest
+from fastapi import HTTPException, Request
+from itsdangerous import BadSignature
+
+from airflow.api_fastapi.auth.tokens import JWTGenerator
+from airflow.providers.edge3.worker_api.auth import (
+    jwt_token_authorization,
+    jwt_token_authorization_rest,
+    jwt_validator,
+)
+
+from tests_common.test_utils.config import conf_vars
+
+JWT_SECRET = "test-jwt-secret"
+
+
+def _token(method: str | None = "test.method", secret: str = JWT_SECRET) -> 
str:
+    # Mirrors how 
providers/edge3/src/airflow/providers/edge3/cli/api_client.py generates
+    # tokens for real edge workers, so the token shape (iss/aud/exp/... 
claims) matches
+    # what jwt_token_authorization() actually has to validate in production.
+    generator = JWTGenerator(secret_key=secret, valid_for=300, audience="api")
+    return generator.generate(extras={"method": method} if method is not None 
else {})
+
+
[email protected](autouse=True)
+def _reset_jwt_validator_cache():
+    # jwt_validator() is cached: make sure config overrides in one test can 
never leak
+    # a stale validator into another.
+    jwt_validator.cache_clear()
+    yield
+    jwt_validator.cache_clear()
+
+
+class TestJwtTokenAuthorization:
+    @conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"})
+    def test_matching_method_claim_is_authorized(self):
+        jwt_token_authorization("test.method", _token("test.method"))
+
+    @conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"})
+    def test_missing_method_claim_is_forbidden(self):
+        with pytest.raises(HTTPException) as exc_info:
+            jwt_token_authorization("test.method", _token(method=None))
+        assert exc_info.value.status_code == 403
+
+    @conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "5"})
+    def test_mismatched_method_claim_is_forbidden(self):
+        with pytest.raises(HTTPException) as exc_info:
+            jwt_token_authorization("test.method", _token("other.method"))
+        assert exc_info.value.status_code == 403
+
+
+class TestJwtTokenAuthorizationForbiddenResponse:
+    """
+    Every handled failure is collapsed onto the same anonymised 403 response by
+    ``_forbidden_response`` so callers can never distinguish *why* a token was 
rejected -
+    only the server-side log carries the real reason.
+    """
+
+    @pytest.mark.parametrize(
+        "error",
+        [
+            BadSignature("Signature does not match"),
+            jwt.InvalidAudienceError("Invalid audience"),
+            jwt.InvalidSignatureError("Signature verification failed"),
+            jwt.ImmatureSignatureError("The token is not yet valid"),
+            jwt.ExpiredSignatureError("Signature has expired"),
+            jwt.InvalidIssuedAtError("Issued at claim is in the future"),
+            ValueError("Some other unexpected failure"),
+        ],
+    )
+    @mock.patch("airflow.providers.edge3.worker_api.auth.jwt_validate", 
autospec=True)
+    def test_each_handled_failure_is_forbidden_and_anonymized(self, 
mock_jwt_validate, error):
+        mock_jwt_validate.side_effect = error
+
+        with pytest.raises(HTTPException) as exc_info:
+            jwt_token_authorization("test.method", "some-token")
+
+        assert exc_info.value.status_code == 403
+        assert "error_id=" in exc_info.value.detail
+        assert str(error) not in exc_info.value.detail
+
+
+class TestJwtValidatorCaching:
+    def test_validator_is_cached_and_reuses_previously_configured_secret(self):
+        with conf_vars({("api_auth", "jwt_secret"): "secret-one"}):
+            first = jwt_validator()
+
+        with conf_vars({("api_auth", "jwt_secret"): "secret-two"}):
+            # No cache_clear() call: the cached validator (built with 
"secret-one")
+            # is silently reused, per the caveat called out for this function.
+            second = jwt_validator()
+
+        assert first is second
+        assert second.secret_key == "secret-one"
+
+    def test_cache_clear_picks_up_the_new_secret(self):
+        with conf_vars({("api_auth", "jwt_secret"): "secret-one"}):
+            first = jwt_validator()
+
+        jwt_validator.cache_clear()
+
+        with conf_vars({("api_auth", "jwt_secret"): "secret-two"}):
+            second = jwt_validator()
+
+        assert second is not first
+        assert second.secret_key == "secret-two"
+
+    @conf_vars({("api_auth", "jwt_secret"): JWT_SECRET, ("api_auth", 
"jwt_leeway"): "90"})
+    def test_leeway_is_read_from_config(self):
+        assert jwt_validator().leeway == 90
+
+
+class TestJwtTokenAuthorizationRest:
+    @pytest.mark.parametrize(
+        ("path", "expected_method"),
+        [
+            ("/edge_worker/v1/jobs/fetch/worker1", "jobs/fetch/worker1"),
+            ("/edge_worker/v1/health", "health"),
+            ("/some/other/path", "/some/other/path"),
+        ],
+    )
+    
@mock.patch("airflow.providers.edge3.worker_api.auth.jwt_token_authorization", 
autospec=True)
+    def test_strips_edge_worker_v1_prefix_and_falls_back_to_full_path(
+        self, mock_jwt_token_authorization, path, expected_method
+    ):
+        request = mock.MagicMock(spec=Request)
+        request.url.path = path
+
+        jwt_token_authorization_rest(request, authorization="some-token")
+
+        mock_jwt_token_authorization.assert_called_once_with(expected_method, 
"some-token")

Reply via email to