From 04e6c1af812c43f04196201f72b387134238ea5a Mon Sep 17 00:00:00 2001 From: awais786 Date: Sat, 16 May 2026 02:16:50 +0500 Subject: [PATCH 1/2] test(auth): pin proxy_user > jwt_user precedence in current_active_user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression-guard for SurfSense's architectural immunity to the cross-app stale-session-on-user-switch class of bug. SurfSense doesn't need an explicit Rule-2-style "compare upstream identity vs session identity → flush on mismatch" middleware (like Plane #29, Outline #19, Penpot #18, Twenty #8 do) because `current_active_user` in app.users already resolves to the upstream identity (proxy_user) over the persisted session (jwt_user) whenever both are present. That precedence IS the contract. If a future refactor flips it (or removes the proxy_user check during a "simplify auth" pass) the stale-session bug class is silently re-introduced — and type-checks pass, so it would ship. These five tests pin the contract: 1. proxy_user wins when both proxy_user and jwt_user are present with different identities (the user-switch scenario) 2. Falls back to jwt_user when proxy_user is absent (header-absent is NOT a logout signal — internal calls, OPTIONS preflight, direct backend hits at 127.0.0.1 legitimately arrive without a proxy header) 3. Raises 401 when neither is present (sanity) 4. Same precedence for current_optional_user 5. current_optional_user returns None (does not raise) when neither is present Cross-app contract: awais786/sso-rules-moneta:openspec/specs/proxy-auth-middleware/spec.md SurfSense's architectural-immunity reasoning: awais786/sso-rules-moneta:surfsense-security.md Co-Authored-By: Claude Opus 4.7 (1M context) --- ...st_current_active_user_proxy_precedence.py | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py diff --git a/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py b/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py new file mode 100644 index 0000000000..2c1f6f65e5 --- /dev/null +++ b/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py @@ -0,0 +1,161 @@ +""" +Regression-guard for SurfSense's architectural immunity to the +stale-session-on-user-switch class of bug. + +SurfSense's `current_active_user` (in app.users) resolves to: + proxy_user (set per-request by ProxyAuthMiddleware from + X-Auth-Request-Email) IF present, ELSE + jwt_user (decoded from Authorization: Bearer JWT) IF present, ELSE + raise 401 Not Authenticated. + +This precedence is what makes SurfSense immune to the cross-app +stale-session-on-user-switch bug class that affected Plane #29 / +Outline #19 / Penpot #18 / Twenty #8. The other apps had to add an +explicit "compare upstream identity vs session identity → flush on +mismatch" middleware. SurfSense doesn't need that because the upstream +identity (proxy_user) ALWAYS wins over the persisted session (jwt_user) +when both are present. + +The cross-app contract: + awais786/sso-rules-moneta:openspec/specs/proxy-auth-middleware/spec.md + +These tests pin the precedence so a future refactor that flips the +priority (or removes the proxy_user check) cannot silently re-introduce +the bug. Without them, a well-intentioned "simplify auth to just use +JWT" change would pass type-checks and ship the regression. +""" + +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException, Request +from starlette.requests import Request as StarletteRequest + +from app.users import current_active_user, current_optional_user + + +def _make_request(proxy_user=None) -> Request: + """Build a minimal Request whose .state.proxy_user is settable.""" + scope = {"type": "http", "headers": [], "method": "GET", "path": "/"} + request = StarletteRequest(scope) + if proxy_user is not None: + request.state.proxy_user = proxy_user + return request + + +def _make_user(email: str, *, is_active: bool = True): + user = MagicMock() + user.id = uuid.uuid4() + user.email = email + user.is_active = is_active + return user + + +@pytest.mark.asyncio +async def test_proxy_user_wins_when_both_proxy_and_jwt_present(): + """ + GIVEN proxy_user resolves alice from upstream header + AND jwt_user resolves bob from Bearer JWT (stale) + WHEN current_active_user runs + THEN returns alice (the upstream identity) + + This is the architectural-immunity contract. SurfSense never serves + the stale JWT identity when an upstream identity is asserted on the + same request. If this test fails, the cross-app stale-session bug + class is re-introduced into SurfSense. + """ + alice = _make_user("alice@example.com") + bob = _make_user("bob@example.com") + request = _make_request(proxy_user=alice) + + # Bypass the SMB auto-join side effect; we're testing precedence only. + import app.users as users_module + original = users_module.auto_join_smb_search_space + users_module.auto_join_smb_search_space = AsyncMock(return_value=None) + try: + result = await current_active_user(request, jwt_user=bob) + finally: + users_module.auto_join_smb_search_space = original + + assert result is alice + assert result is not bob + + +@pytest.mark.asyncio +async def test_falls_back_to_jwt_when_proxy_user_absent(): + """ + GIVEN proxy_user is NOT set on the request (no X-Auth-Request-Email) + AND jwt_user resolves alice from Bearer JWT + WHEN current_active_user runs + THEN returns alice (the JWT identity) + + Header absence is NOT a logout signal per the cross-app spec — it + legitimately occurs for internal calls, bypass routes, OPTIONS + preflight, and direct backend hits at 127.0.0.1. SurfSense MUST + serve the JWT identity in this case, not 401. + """ + alice = _make_user("alice@example.com") + request = _make_request(proxy_user=None) + + import app.users as users_module + original = users_module.auto_join_smb_search_space + users_module.auto_join_smb_search_space = AsyncMock(return_value=None) + try: + result = await current_active_user(request, jwt_user=alice) + finally: + users_module.auto_join_smb_search_space = original + + assert result is alice + + +@pytest.mark.asyncio +async def test_raises_401_when_neither_proxy_nor_jwt_present(): + """ + GIVEN proxy_user is NOT set AND jwt_user is None (no Bearer) + WHEN current_active_user runs + THEN raises 401 Not Authenticated + + Sanity: an entirely unauthenticated request must be rejected. + """ + request = _make_request(proxy_user=None) + + with pytest.raises(HTTPException) as exc_info: + await current_active_user(request, jwt_user=None) + + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_optional_user_returns_proxy_when_both_present(): + """ + GIVEN proxy_user resolves alice AND jwt_user resolves bob + WHEN current_optional_user runs + THEN returns alice + + Same precedence rule, optional variant. Returns None instead of + raising when neither is present. + """ + alice = _make_user("alice@example.com") + bob = _make_user("bob@example.com") + request = _make_request(proxy_user=alice) + + result = await current_optional_user(request, jwt_user=bob) + + assert result is alice + + +@pytest.mark.asyncio +async def test_optional_user_returns_none_when_neither_present(): + """ + GIVEN no proxy_user AND no jwt_user + WHEN current_optional_user runs + THEN returns None (does NOT raise) + """ + request = _make_request(proxy_user=None) + + result = await current_optional_user(request, jwt_user=None) + + assert result is None From 27a1bc36729dd15bb35016bfbe3718cb47287efb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 21:32:53 +0000 Subject: [PATCH 2/2] test(auth): mark proxy precedence regression tests as unit Agent-Logs-Url: https://github.com/Pressingly/SurfSense/sessions/7cb36524-6c58-452f-8b60-9d6256a6caaa Co-authored-by: awais786 <445320+awais786@users.noreply.github.com> --- .../tests/unit/test_current_active_user_proxy_precedence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py b/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py index 2c1f6f65e5..2373a60d62 100644 --- a/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py +++ b/surfsense_backend/tests/unit/test_current_active_user_proxy_precedence.py @@ -36,6 +36,8 @@ from app.users import current_active_user, current_optional_user +pytestmark = pytest.mark.unit + def _make_request(proxy_user=None) -> Request: """Build a minimal Request whose .state.proxy_user is settable."""