stemtrace 0.3.3__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. {stemtrace-0.3.3 → stemtrace-0.3.4}/.bumpversion.toml +1 -1
  2. {stemtrace-0.3.3 → stemtrace-0.3.4}/CHANGELOG.md +6 -0
  3. {stemtrace-0.3.3 → stemtrace-0.3.4}/PKG-INFO +2 -2
  4. {stemtrace-0.3.3 → stemtrace-0.3.4}/README.md +1 -1
  5. {stemtrace-0.3.3 → stemtrace-0.3.4}/pyproject.toml +1 -1
  6. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/__init__.py +1 -1
  7. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/api/websocket.py +6 -2
  8. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/login_routes.py +21 -4
  9. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/package.json +1 -1
  10. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_form_login.py +104 -6
  11. {stemtrace-0.3.3 → stemtrace-0.3.4}/uv.lock +1 -1
  12. {stemtrace-0.3.3 → stemtrace-0.3.4}/.dockerignore +0 -0
  13. {stemtrace-0.3.3 → stemtrace-0.3.4}/.github/dependabot.yml +0 -0
  14. {stemtrace-0.3.3 → stemtrace-0.3.4}/.github/workflows/ci.yml +0 -0
  15. {stemtrace-0.3.3 → stemtrace-0.3.4}/.github/workflows/e2e.yml +0 -0
  16. {stemtrace-0.3.3 → stemtrace-0.3.4}/.github/workflows/release.yml +0 -0
  17. {stemtrace-0.3.3 → stemtrace-0.3.4}/.gitignore +0 -0
  18. {stemtrace-0.3.3 → stemtrace-0.3.4}/.pre-commit-config.yaml +0 -0
  19. {stemtrace-0.3.3 → stemtrace-0.3.4}/.python-version +0 -0
  20. {stemtrace-0.3.3 → stemtrace-0.3.4}/CONTRIBUTING.md +0 -0
  21. {stemtrace-0.3.3 → stemtrace-0.3.4}/Dockerfile +0 -0
  22. {stemtrace-0.3.3 → stemtrace-0.3.4}/Dockerfile.e2e +0 -0
  23. {stemtrace-0.3.3 → stemtrace-0.3.4}/LICENSE +0 -0
  24. {stemtrace-0.3.3 → stemtrace-0.3.4}/Makefile +0 -0
  25. {stemtrace-0.3.3 → stemtrace-0.3.4}/build_ui.py +0 -0
  26. {stemtrace-0.3.3 → stemtrace-0.3.4}/docker-compose.e2e.rabbitmq.yml +0 -0
  27. {stemtrace-0.3.3 → stemtrace-0.3.4}/docker-compose.e2e.yml +0 -0
  28. {stemtrace-0.3.3 → stemtrace-0.3.4}/docker-compose.rabbitmq.yml +0 -0
  29. {stemtrace-0.3.3 → stemtrace-0.3.4}/docker-compose.yml +0 -0
  30. {stemtrace-0.3.3 → stemtrace-0.3.4}/docs/screenshots/task_details.png +0 -0
  31. {stemtrace-0.3.3 → stemtrace-0.3.4}/docs/screenshots/unregistered.png +0 -0
  32. {stemtrace-0.3.3 → stemtrace-0.3.4}/docs/screenshots/workflow.png +0 -0
  33. {stemtrace-0.3.3 → stemtrace-0.3.4}/examples/celery_app.py +0 -0
  34. {stemtrace-0.3.3 → stemtrace-0.3.4}/examples/fastapi_integration.py +0 -0
  35. {stemtrace-0.3.3 → stemtrace-0.3.4}/examples/with_auth.py +0 -0
  36. {stemtrace-0.3.3 → stemtrace-0.3.4}/examples/with_login.py +0 -0
  37. {stemtrace-0.3.3 → stemtrace-0.3.4}/progress.md +0 -0
  38. {stemtrace-0.3.3 → stemtrace-0.3.4}/scripts/wait_for_http.py +0 -0
  39. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/core/__init__.py +0 -0
  40. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/core/events.py +0 -0
  41. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/core/exceptions.py +0 -0
  42. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/core/graph.py +0 -0
  43. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/core/ports.py +0 -0
  44. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/__init__.py +0 -0
  45. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/bootsteps.py +0 -0
  46. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/config.py +0 -0
  47. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/scrubbing.py +0 -0
  48. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/signals.py +0 -0
  49. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/transports/__init__.py +0 -0
  50. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/transports/memory.py +0 -0
  51. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/transports/rabbitmq.py +0 -0
  52. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/library/transports/redis.py +0 -0
  53. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/py.typed +0 -0
  54. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/__init__.py +0 -0
  55. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/__main__.py +0 -0
  56. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/api/__init__.py +0 -0
  57. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/api/routes.py +0 -0
  58. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/api/schemas.py +0 -0
  59. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/consumer.py +0 -0
  60. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/__init__.py +0 -0
  61. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/auth.py +0 -0
  62. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/extension.py +0 -0
  63. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/form_auth.py +0 -0
  64. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/fastapi/router.py +0 -0
  65. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/store.py +0 -0
  66. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/__init__.py +0 -0
  67. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/biome.json +0 -0
  68. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/index.html +0 -0
  69. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/package-lock.json +0 -0
  70. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/playwright.config.ts +0 -0
  71. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/postcss.config.js +0 -0
  72. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/api/client.ts +0 -0
  73. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/api/queries.ts +0 -0
  74. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +0 -0
  75. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/Filters.tsx +0 -0
  76. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +0 -0
  77. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskList.tsx +0 -0
  78. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +0 -0
  79. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +0 -0
  80. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +0 -0
  81. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/components/Tooltip.tsx +0 -0
  82. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +0 -0
  83. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +0 -0
  84. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/index.css +0 -0
  85. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/main.tsx +0 -0
  86. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/__root.tsx +0 -0
  87. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +0 -0
  88. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/graphs.tsx +0 -0
  89. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/index.tsx +0 -0
  90. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/registry.tsx +0 -0
  91. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +0 -0
  92. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/routes/workers.tsx +0 -0
  93. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/utils/format.ts +0 -0
  94. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/src/vite-env.d.ts +0 -0
  95. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-api.ts +0 -0
  96. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/fixtures/mock-data.ts +0 -0
  97. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/graphs.spec.ts +0 -0
  98. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/registry.spec.ts +0 -0
  99. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/task-detail.spec.ts +0 -0
  100. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/tasks.spec.ts +0 -0
  101. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tests/workers.spec.ts +0 -0
  102. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tsconfig.json +0 -0
  103. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +0 -0
  104. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/frontend/vite.config.ts +0 -0
  105. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/static.py +0 -0
  106. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/ui/templates/login.html +0 -0
  107. {stemtrace-0.3.3 → stemtrace-0.3.4}/src/stemtrace/server/websocket.py +0 -0
  108. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/__init__.py +0 -0
  109. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/conftest.py +0 -0
  110. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/e2e/__init__.py +0 -0
  111. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/e2e/conftest.py +0 -0
  112. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/e2e/tasks.py +0 -0
  113. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/e2e/test_api_e2e.py +0 -0
  114. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/e2e/test_workers_e2e.py +0 -0
  115. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/integration/__init__.py +0 -0
  116. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/integration/test_embedded_static.py +0 -0
  117. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/integration/test_fastapi_integration.py +0 -0
  118. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/integration/test_rabbitmq_transport.py +0 -0
  119. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/integration/test_redis_transport.py +0 -0
  120. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/integration/test_static_ui.py +0 -0
  121. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/integration/test_workers_refresh.py +0 -0
  122. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/__init__.py +0 -0
  123. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_auth.py +0 -0
  124. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_bootsteps.py +0 -0
  125. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_config.py +0 -0
  126. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_consumer.py +0 -0
  127. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_events.py +0 -0
  128. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_examples.py +0 -0
  129. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_exceptions.py +0 -0
  130. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_form_auth.py +0 -0
  131. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_graph.py +0 -0
  132. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_init.py +0 -0
  133. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_routes.py +0 -0
  134. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_scrubbing.py +0 -0
  135. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_signals.py +0 -0
  136. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_store.py +0 -0
  137. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_transports.py +0 -0
  138. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_ui_static_prefix.py +0 -0
  139. {stemtrace-0.3.3 → stemtrace-0.3.4}/tests/unit/test_websocket.py +0 -0
@@ -1,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "0.3.3"
2
+ current_version = "0.3.4"
3
3
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
4
4
  serialize = ["{major}.{minor}.{patch}"]
5
5
  commit = true
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.4] - 2026-06-25
11
+
12
+ ### Fixed
13
+ - WebSocket: deny unauthenticated connections during the handshake instead of accepting and then closing them. The accept-then-close path half-opened the connection, so when an unauthenticated client (e.g. a port scanner) aborted mid-handshake, uvicorn's legacy `websockets` implementation ran `close()` on a protocol whose `transfer_data_task` was never set, raising `AttributeError: 'WebSocketProtocol' object has no attribute 'transfer_data_task'`. Closing before `accept()` rejects the handshake (HTTP 403) without opening the protocol object.
14
+ - Form login: build the login form action and redirects as relative paths instead of absolute URLs, and derive the session cookie's `Secure` flag from `X-Forwarded-Proto`. Behind a TLS-terminating proxy that doesn't forward the scheme to the ASGI scope (e.g. uvicorn without `--forwarded-allow-ips`), absolute URLs were downgraded to `http://`, so the browser posted the login form over http, the proxy answered `301 -> https`, the POST body was dropped, and login silently failed (with a mixed-content warning). stemtrace now works behind such a proxy without requiring forwarded-headers configuration.
15
+
10
16
  ## [0.3.3] - 2026-03-20
11
17
 
12
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stemtrace
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: A lightweight Celery task flow visualizer
5
5
  Project-URL: Homepage, https://github.com/iansokolskyi/stemtrace
6
6
  Project-URL: Documentation, https://github.com/iansokolskyi/stemtrace#readme
@@ -56,7 +56,7 @@ Description-Content-Type: text/markdown
56
56
 
57
57
  **Zero-infrastructure Celery task flow visualizer**
58
58
 
59
- [![PyPI version](https://img.shields.io/badge/pypi-v0.3.3-darklime)](https://pypi.org/project/stemtrace)
59
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.3.4-darklime)](https://pypi.org/project/stemtrace)
60
60
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
61
61
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
62
62
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Zero-infrastructure Celery task flow visualizer**
4
4
 
5
- [![PyPI version](https://img.shields.io/badge/pypi-v0.3.3-darklime)](https://pypi.org/project/stemtrace)
5
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.3.4-darklime)](https://pypi.org/project/stemtrace)
6
6
  [![Python](https://img.shields.io/pypi/pyversions/stemtrace.svg)](https://pypi.org/project/stemtrace/)
7
7
  [![CI](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml/badge.svg)](https://github.com/iansokolskyi/stemtrace/actions/workflows/ci.yml)
8
8
  [![codecov](https://codecov.io/gh/iansokolskyi/stemtrace/graph/badge.svg)](https://codecov.io/gh/iansokolskyi/stemtrace)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stemtrace"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "A lightweight Celery task flow visualizer"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
43
43
  from celery import Celery
44
44
  from fastapi import FastAPI
45
45
 
46
- __version__ = "0.3.3"
46
+ __version__ = "0.3.4"
47
47
  __all__ = [
48
48
  "ConfigurationError",
49
49
  "StemtraceConfig",
@@ -33,8 +33,12 @@ def create_websocket_router(
33
33
  secret=form_auth_config.secret,
34
34
  expected_username=form_auth_config.username,
35
35
  ):
36
- # Close without registering the connection.
37
- await websocket.accept()
36
+ # Deny the handshake without completing it. Accepting first and
37
+ # then closing half-opens the connection; if the unauthenticated
38
+ # client aborts during that dance, uvicorn's legacy websockets
39
+ # impl runs close() on a protocol whose transfer_data_task was
40
+ # never set and raises AttributeError. Closing before accept lets
41
+ # Starlette reject the handshake (HTTP 403) without opening it.
38
42
  await websocket.close(code=1008)
39
43
  return
40
44
 
@@ -40,6 +40,20 @@ def _safe_next(next_path: str | None, *, default: str) -> str:
40
40
  return next_path
41
41
 
42
42
 
43
+ def _request_is_https(request: Request) -> bool:
44
+ """Best-effort HTTPS detection that survives a TLS-terminating proxy.
45
+
46
+ Falls back to the ``X-Forwarded-Proto`` header when the ASGI scheme is
47
+ ``http`` because proxy headers weren't applied to the scope (e.g. uvicorn
48
+ without ``--forwarded-allow-ips``). The first value of a comma-separated
49
+ list is the original client scheme.
50
+ """
51
+ if request.url.scheme == "https":
52
+ return True
53
+ forwarded = request.headers.get("x-forwarded-proto", "")
54
+ return forwarded.split(",")[0].strip().lower() == "https"
55
+
56
+
43
57
  def _login_page_html(*, action_path: str, error: str | None, next_path: str) -> str:
44
58
  """Render the login page HTML from a template."""
45
59
  escaped_title = html.escape(_DEFAULT_TITLE, quote=True)
@@ -92,7 +106,10 @@ def create_login_router(
92
106
  next_path = _safe_next(
93
107
  request.query_params.get("next"), default=default_next_path
94
108
  )
95
- action_path = str(request.url_for("stemtrace_login_submit"))
109
+ # Relative path (not an absolute URL): the browser submits using the
110
+ # page's own scheme/host, so a TLS-terminating proxy that doesn't
111
+ # forward the scheme can't downgrade the form action to http.
112
+ action_path = request.url_for("stemtrace_login_submit").path
96
113
  rendered_html = _login_page_html(
97
114
  action_path=action_path, error=error, next_path=next_path
98
115
  )
@@ -125,7 +142,7 @@ def create_login_router(
125
142
  qs = {"error": "Invalid username or password"}
126
143
  if next_param:
127
144
  qs["next"] = next_param
128
- login_url = str(request.url_for("stemtrace_login_page"))
145
+ login_url = request.url_for("stemtrace_login_page").path
129
146
  return RedirectResponse(
130
147
  url=login_url + "?" + urllib.parse.urlencode(qs),
131
148
  status_code=303,
@@ -141,7 +158,7 @@ def create_login_router(
141
158
  value=config.create_session_cookie_value(),
142
159
  httponly=True,
143
160
  samesite="lax",
144
- secure=request.url.scheme == "https",
161
+ secure=_request_is_https(request),
145
162
  path=config.cookie_path,
146
163
  )
147
164
  return response
@@ -149,7 +166,7 @@ def create_login_router(
149
166
  @router.post("/logout", include_in_schema=False, name="stemtrace_logout")
150
167
  async def logout(request: Request) -> RedirectResponse:
151
168
  """Clear the session cookie and redirect to login."""
152
- login_url = str(request.url_for("stemtrace_login_page"))
169
+ login_url = request.url_for("stemtrace_login_page").path
153
170
  response = RedirectResponse(url=login_url, status_code=303)
154
171
  response.delete_cookie(key=config.cookie_name, path=config.cookie_path)
155
172
  return response
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stemtrace-ui",
3
3
  "private": true,
4
- "version": "0.3.3",
4
+ "version": "0.3.4",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -73,6 +73,107 @@ class TestFormLoginProtection:
73
73
  assert ext.ws_manager.connection_count == 1
74
74
  assert ext.ws_manager.connection_count == 0
75
75
 
76
+ def test_unauthenticated_websocket_denied_at_handshake(self) -> None:
77
+ """An unauthenticated WebSocket must be denied DURING the handshake,
78
+ without calling ``accept()`` first.
79
+
80
+ Accepting the handshake and then closing it half-opens the connection;
81
+ when an unauthenticated client (e.g. a port scanner) aborts during that
82
+ accept->close dance, uvicorn's legacy ``websockets`` implementation runs
83
+ ``close()`` on a protocol whose ``transfer_data_task`` was never set,
84
+ raising ``AttributeError: 'WebSocketProtocol' object has no attribute
85
+ 'transfer_data_task'``. Denying before accept avoids opening the
86
+ protocol object at all. Regression test for that crash.
87
+ """
88
+ app = FastAPI()
89
+ stemtrace.init_app(
90
+ app,
91
+ broker_url="memory://",
92
+ embedded_consumer=False,
93
+ serve_ui=False,
94
+ login_username="admin",
95
+ login_password="secret", # NOSONAR - test credential only
96
+ login_secret="test-secret",
97
+ )
98
+
99
+ with TestClient(app) as client:
100
+ accepted = False
101
+ with (
102
+ pytest.raises(WebSocketDisconnect) as exc_info,
103
+ client.websocket_connect("/stemtrace/ws") as ws,
104
+ ):
105
+ # Only reached if the server accepted the handshake first.
106
+ accepted = True
107
+ ws.receive_text()
108
+
109
+ assert accepted is False, (
110
+ "unauthenticated WebSocket must be denied before accept(); "
111
+ "accepting then closing crashes uvicorn's legacy websockets impl"
112
+ )
113
+ assert exc_info.value.code == 1008
114
+
115
+ def test_login_urls_relative_and_cookie_secure_behind_tls_proxy(self) -> None:
116
+ """Behind a TLS-terminating proxy, login URLs must stay relative and the
117
+ session cookie must still be Secure.
118
+
119
+ When the proxy forwards ``X-Forwarded-Proto: https`` but the ASGI scheme
120
+ is left as ``http`` (e.g. uvicorn without ``--forwarded-allow-ips``),
121
+ building absolute URLs from the request scheme downgrades the login form
122
+ action / redirects to ``http://``. The browser then posts over http, the
123
+ proxy answers 301->https, the POST body is dropped, and login silently
124
+ fails. Relative URLs keep the page's https scheme; the cookie's Secure
125
+ flag falls back to the forwarded-proto header.
126
+ """
127
+ app = FastAPI()
128
+ stemtrace.init_app(
129
+ app,
130
+ broker_url="memory://",
131
+ embedded_consumer=False,
132
+ serve_ui=False,
133
+ login_username="admin",
134
+ login_password="secret", # NOSONAR - test credential only
135
+ login_secret="test-secret",
136
+ )
137
+ # ALB-style header; the ASGI scheme stays http (proxy headers not applied).
138
+ proxied = {"x-forwarded-proto": "https"}
139
+
140
+ with TestClient(app) as client:
141
+ # The login form action is a relative path, never an absolute URL
142
+ # that a proxy could downgrade to http.
143
+ page = client.get("/stemtrace/login", headers=proxied)
144
+ assert page.status_code == 200
145
+ assert 'action="/stemtrace/login"' in page.text
146
+ assert 'action="http' not in page.text
147
+
148
+ # Successful login: relative redirect + Secure cookie (via forwarded proto).
149
+ resp = client.post(
150
+ "/stemtrace/login",
151
+ data={
152
+ "username": "admin",
153
+ "password": "secret",
154
+ "next": "/stemtrace/",
155
+ }, # NOSONAR - test credential only
156
+ headers=proxied,
157
+ follow_redirects=False,
158
+ )
159
+ assert resp.status_code == 303
160
+ assert resp.headers["location"] == "/stemtrace/"
161
+ assert "Secure" in resp.headers["set-cookie"]
162
+
163
+ # Failed login: relative redirect, not an absolute http:// URL.
164
+ bad = client.post(
165
+ "/stemtrace/login",
166
+ data={
167
+ "username": "admin",
168
+ "password": "nope",
169
+ "next": "/stemtrace/",
170
+ }, # NOSONAR - test credential only
171
+ headers=proxied,
172
+ follow_redirects=False,
173
+ )
174
+ assert bad.headers["location"].startswith("/stemtrace/login?")
175
+ assert not bad.headers["location"].startswith("http")
176
+
76
177
  def test_invalid_credentials_redirects_to_login_with_error(self) -> None:
77
178
  """Invalid credentials redirect back to the login page with an error."""
78
179
  app = FastAPI()
@@ -97,9 +198,7 @@ class TestFormLoginProtection:
97
198
  follow_redirects=False,
98
199
  )
99
200
  assert resp.status_code == 303
100
- assert resp.headers["location"].startswith(
101
- "http://testserver/stemtrace/login?"
102
- )
201
+ assert resp.headers["location"].startswith("/stemtrace/login?")
103
202
 
104
203
  page = client.get(resp.headers["location"])
105
204
  assert page.status_code == 200
@@ -135,9 +234,8 @@ class TestFormLoginProtection:
135
234
 
136
235
  logout = client.post("/stemtrace/logout", follow_redirects=False)
137
236
  assert logout.status_code == 303
138
- assert logout.headers["location"].startswith(
139
- "http://testserver/stemtrace/login"
140
- )
237
+ assert logout.headers["location"].startswith("/stemtrace/login")
238
+ assert not logout.headers["location"].startswith("http")
141
239
 
142
240
  forbidden = client.get("/stemtrace/api/health", follow_redirects=False)
143
241
  assert forbidden.status_code == 401
@@ -1595,7 +1595,7 @@ wheels = [
1595
1595
 
1596
1596
  [[package]]
1597
1597
  name = "stemtrace"
1598
- version = "0.3.3"
1598
+ version = "0.3.4"
1599
1599
  source = { editable = "." }
1600
1600
  dependencies = [
1601
1601
  { name = "celery" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes