diff --git a/convey/apps.py b/convey/apps.py index a704b8a62..e28bbf519 100644 --- a/convey/apps.py +++ b/convey/apps.py @@ -7,7 +7,7 @@ from __future__ import annotations from dataclasses import dataclass -from flask import Flask, request, url_for +from flask import Flask, g, request, url_for from apps import AppRegistry @@ -55,19 +55,35 @@ def _get_selected_facet() -> str | None: Cookie takes precedence - if it differs from config, update config. If no cookie exists, use config value as default. + Validates against active (non-muted) facets; stale values are cleared. """ from .config import get_selected_facet, set_selected_facet cookie_facet = request.cookies.get("selectedFacet") config_facet = get_selected_facet() + # Empty string cookie -> treat as no selection, expire it + if cookie_facet == "": + set_selected_facet(None) + g.clear_facet_cookie = True + return None + + # Resolve: cookie takes precedence + facet = cookie_facet if cookie_facet is not None else config_facet + + # Validate against active (non-muted) facets + if facet: + active_names = {f["name"] for f in _get_facets_data()} + if facet not in active_names: + set_selected_facet(None) + g.clear_facet_cookie = True + return None + # Sync: cookie takes precedence, update config if different if cookie_facet is not None and cookie_facet != config_facet: set_selected_facet(cookie_facet) - return cookie_facet - # No cookie: use config default - return cookie_facet if cookie_facet is not None else config_facet + return facet @dataclass @@ -339,3 +355,9 @@ def register_app_context(app: Flask, registry: AppRegistry) -> None: return url_for("static", filename=f"vendor/{library_name}/{file}") return {"vendor_lib": vendor_lib} + + @app.after_request + def clear_stale_facet_cookie(response): + if getattr(g, "clear_facet_cookie", False): + response.delete_cookie("selectedFacet", path="/", samesite="Lax") + return response diff --git a/tests/baselines/api/config/convey.json b/tests/baselines/api/config/convey.json index 95e93800f..f68e2d3b2 100644 --- a/tests/baselines/api/config/convey.json +++ b/tests/baselines/api/config/convey.json @@ -22,7 +22,7 @@ "capulet", "verona" ], - "selected": "work" + "selected": "montague" } }, "success": true diff --git a/tests/fixtures/journal/config/convey.json b/tests/fixtures/journal/config/convey.json index 2e8b7f910..1b6d47b4f 100644 --- a/tests/fixtures/journal/config/convey.json +++ b/tests/fixtures/journal/config/convey.json @@ -1,6 +1,6 @@ { "facets": { - "selected": "work", + "selected": "montague", "order": ["work", "personal", "full-featured", "montague", "capulet", "verona"] }, "apps": { diff --git a/tests/fixtures/journal/facets/muted-test/facet.json b/tests/fixtures/journal/facets/muted-test/facet.json new file mode 100644 index 000000000..f3fbd73cd --- /dev/null +++ b/tests/fixtures/journal/facets/muted-test/facet.json @@ -0,0 +1 @@ +{"title": "Muted Test", "muted": true} diff --git a/tests/test_convey_facet_cookie.py b/tests/test_convey_facet_cookie.py new file mode 100644 index 000000000..a74459141 --- /dev/null +++ b/tests/test_convey_facet_cookie.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for stale facet cookie validation in convey.""" + +import json + +import pytest + +from convey import create_app + + +@pytest.fixture +def client(journal_copy): + """Create Flask test client with isolated journal copy.""" + app = create_app(str(journal_copy)) + return app.test_client() + + +def _cookie_deleted(response) -> bool: + """Check if selectedFacet cookie was deleted in response.""" + for header in response.headers.getlist("Set-Cookie"): + if header.startswith("selectedFacet=") and "Max-Age=0" in header: + return True + return False + + +class TestFacetCookieValidation: + """Tests for _get_selected_facet() stale cookie validation.""" + + def test_valid_cookie_returned(self, client): + """Valid cookie for active facet -> returned as-is, no cookie cleared.""" + client.set_cookie("selectedFacet", "montague") + response = client.get("/app/home/") + assert response.status_code == 200 + assert not _cookie_deleted(response) + + def test_stale_cookie_cleared(self, client, journal_copy): + """Stale cookie (nonexistent facet) -> cookie expired, config cleared.""" + client.set_cookie("selectedFacet", "nonexistent-facet") + response = client.get("/app/home/") + assert response.status_code == 200 + assert _cookie_deleted(response) + + config = json.loads((journal_copy / "config" / "convey.json").read_text()) + assert config["facets"]["selected"] is None + + def test_no_cookie_uses_config(self, client, journal_copy): + """No cookie -> returns config default, no cookie modification.""" + config_path = journal_copy / "config" / "convey.json" + config = json.loads(config_path.read_text()) + config["facets"]["selected"] = "montague" + config_path.write_text(json.dumps(config, indent=2)) + + response = client.get("/app/home/") + assert response.status_code == 200 + assert not _cookie_deleted(response) + + def test_muted_facet_cookie_stale(self, client, journal_copy): + """Muted facet in cookie -> treated as stale, cookie expired.""" + # muted-test fixture already exists in journal_copy (copied from fixtures) + client.set_cookie("selectedFacet", "muted-test") + response = client.get("/app/home/") + assert response.status_code == 200 + assert _cookie_deleted(response) + + config = json.loads((journal_copy / "config" / "convey.json").read_text()) + assert config["facets"]["selected"] is None + + def test_empty_cookie_cleared(self, client, journal_copy): + """Empty string cookie -> treated as stale, cookie expired, config cleared.""" + client.set_cookie("selectedFacet", "") + response = client.get("/app/home/") + assert response.status_code == 200 + assert _cookie_deleted(response) + + config = json.loads((journal_copy / "config" / "convey.json").read_text()) + assert config["facets"]["selected"] is None + + def test_stale_config_no_cookie(self, client, journal_copy): + """Stale config value with no cookie -> config cleared.""" + config_path = journal_copy / "config" / "convey.json" + config = json.loads(config_path.read_text()) + config["facets"]["selected"] = "nonexistent-facet" + config_path.write_text(json.dumps(config, indent=2)) + + response = client.get("/app/home/") + assert response.status_code == 200 + + config = json.loads(config_path.read_text()) + assert config["facets"]["selected"] is None