Files
meshcore-hub/tests/test_web/test_features.py
T
Louis King 76f3dfa7eb feat: raw packet capture, browse, and classification (v0.13.0)
Add a first-class Raw Packets feature that captures every inbound MeshCore
packet from the LetsMesh `packets` feed exactly as received, independent of
how the collector later classifies it.

Capture & storage
- New `RawPacket` model + migration (raw_packets table) with single and
  composite indexes for the dominant filter-then-sort-by-newest queries.
- Collector-side `RAW_PACKET_CAPTURE_ENABLED` flag (default off); capture hook
  reuses the decoder's per-hex cache (no second decode), one row per observer
  reception, never blocks event dispatch.
- Separate `RAW_PACKET_RETENTION_DAYS` (falls back to DATA_RETENTION_DAYS);
  cleanup runs regardless of capture so disabling drains the table. Raw-packet
  observers retained in the is_observer recompute union.

API
- `GET /packets` and `/packets/{id}` with rich filtering, role-aware Redis
  cache key, and channel-visibility redaction (restricted-channel packets are
  returned metadata-only, not hidden, so pagination counts stay stable).

Web
- `FEATURE_PACKETS` flag (default off). Responsive Packets page (table desktop,
  cards mobile) plus a Packet Detail page (breadcrumb nav, raw hex + decoded).
- Nav entry after Messages on all three surfaces; home.js reordered so Map
  precedes Members; new packets icon + colour.

Finer-grained classification
- Replace the single `letsmesh_packet` catch-all with per-payload-type event
  types (req, ack, encrypted_direct, encrypted_channel, grp_data, multipart,
  control, raw_custom, ...); letsmesh_packet kept only as the unresolved-type
  safety net.

Link from structured tables
- Add `packet_hash` to advertisements and messages (populated at ingest);
  exact `packet_hash` filter on /packets; cube-icon link on the Adverts and
  Messages lists -> /packets?packet_hash=<hash>, shown only when the feature is
  on and the row has a stored hash.

Docs/config: .env.example, docker-compose (collector + web), AGENTS.md,
SCHEMAS.md, docs/letsmesh.md, docs/upgrading.md (## v0.13.0), en/nl i18n, and a
plan/tasks doc under docs/plans/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:40:31 +01:00

377 lines
14 KiB
Python

"""Tests for feature flags functionality."""
import json
import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.app import create_app
from tests.test_web.conftest import ALL_FEATURES_ENABLED, MockHttpClient
class TestFeatureFlagsConfig:
"""Test feature flags in config."""
def test_all_features_enabled_by_default(self, client: TestClient) -> None:
"""All non-OIDC features should be enabled by default in config JSON."""
response = client.get("/")
assert response.status_code == 200
html = response.text
# Extract config JSON from script tag
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
features = config["features"]
non_oidc_features = {k: v for k, v in features.items() if k != "members"}
assert all(
non_oidc_features.values()
), "All non-OIDC features should be enabled by default"
def test_features_dict_has_all_keys(self, client: TestClient) -> None:
"""Features dict should have all 7 expected keys."""
response = client.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
features = config["features"]
expected_keys = {
"dashboard",
"nodes",
"advertisements",
"messages",
"map",
"members",
"pages",
}
assert set(features.keys()) == expected_keys
def test_disabled_features_in_config(self, client_no_features: TestClient) -> None:
"""Disabled features should be false in config JSON."""
response = client_no_features.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
features = config["features"]
assert all(not v for v in features.values()), "All features should be disabled"
class TestFeatureFlagsNav:
"""Test feature flags affect navigation."""
def test_enabled_features_show_nav_links(self, client: TestClient) -> None:
"""Enabled features should show nav links."""
response = client.get("/")
html = response.text
assert 'href="/dashboard"' in html
assert 'href="/nodes"' in html
assert 'href="/advertisements"' in html
assert 'href="/messages"' in html
assert 'href="/map"' in html
def test_disabled_features_hide_nav_links(
self, client_no_features: TestClient
) -> None:
"""Disabled features should not show nav links."""
response = client_no_features.get("/")
html = response.text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' not in html
assert 'href="/advertisements"' not in html
assert 'href="/messages"' not in html
assert 'href="/map"' not in html
assert 'href="/members"' not in html
def test_home_link_always_present(self, client_no_features: TestClient) -> None:
"""Home link should always be present."""
response = client_no_features.get("/")
html = response.text
assert 'href="/"' in html
class TestFeatureFlagsEndpoints:
"""Test feature flags affect endpoints."""
def test_map_data_returns_404_when_disabled(
self, client_no_features: TestClient
) -> None:
"""/map/data should return 404 when map feature is disabled."""
response = client_no_features.get("/map/data")
assert response.status_code == 404
assert response.json()["detail"] == "Map feature is disabled"
def test_map_data_returns_200_when_enabled(self, client: TestClient) -> None:
"""/map/data should return 200 when map feature is enabled."""
response = client.get("/map/data")
assert response.status_code == 200
def test_custom_page_returns_404_when_disabled(
self, client_no_features: TestClient
) -> None:
"""/spa/pages/{slug} should return 404 when pages feature is disabled."""
response = client_no_features.get("/spa/pages/about")
assert response.status_code == 404
assert response.json()["detail"] == "Pages feature is disabled"
def test_custom_pages_empty_when_disabled(
self, client_no_features: TestClient
) -> None:
"""Custom pages should be empty in config when pages feature is disabled."""
response = client_no_features.get("/")
html = response.text
start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = html.index(";", start)
config = json.loads(html[start:end])
assert config["custom_pages"] == []
class TestFeatureFlagsSEO:
"""Test feature flags affect SEO endpoints."""
def test_sitemap_includes_all_when_enabled(self, client: TestClient) -> None:
"""Sitemap should include all feature-enabled pages."""
response = client.get("/sitemap.xml")
assert response.status_code == 200
content = response.text
assert "/dashboard" in content
assert "/nodes" in content
assert "/advertisements" in content
assert "/map" in content
def test_sitemap_excludes_disabled_features(
self, client_no_features: TestClient
) -> None:
"""Sitemap should exclude disabled features."""
response = client_no_features.get("/sitemap.xml")
assert response.status_code == 200
content = response.text
assert "/dashboard" not in content
assert "/nodes" not in content
assert "/advertisements" not in content
assert "/map" not in content
assert "/members" not in content
def test_sitemap_always_includes_home(self, client_no_features: TestClient) -> None:
"""Sitemap should always include the home page."""
response = client_no_features.get("/sitemap.xml")
assert response.status_code == 200
content = response.text
# Home page has an empty path, so check for base URL loc
assert "<loc>" in content
def test_robots_txt_adds_disallow_for_disabled(
self, client_no_features: TestClient
) -> None:
"""Robots.txt should add Disallow for disabled features."""
response = client_no_features.get("/robots.txt")
assert response.status_code == 200
content = response.text
assert "Disallow: /dashboard" in content
assert "Disallow: /nodes" in content
assert "Disallow: /advertisements" in content
assert "Disallow: /map" in content
assert "Disallow: /members" in content
assert "Disallow: /pages" in content
def test_robots_txt_default_disallows_when_enabled(
self, client: TestClient
) -> None:
"""Robots.txt should only disallow messages and nodes/ when all enabled."""
response = client.get("/robots.txt")
assert response.status_code == 200
content = response.text
assert "Disallow: /messages" in content
assert "Disallow: /nodes/" in content
# Should not disallow the full /nodes path (only /nodes/ for detail pages)
lines = content.strip().split("\n")
disallow_lines = [
line.strip() for line in lines if line.startswith("Disallow:")
]
assert "Disallow: /nodes" not in disallow_lines or any(
line == "Disallow: /nodes/" for line in disallow_lines
)
class TestPacketsFeatureFlag:
"""Test the packets feature flag (off by default)."""
def _make_app(self, mock_http_client: MockHttpClient, packets: bool):
features = dict(ALL_FEATURES_ENABLED)
features["packets"] = packets
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features=features,
)
app.state.http_client = mock_http_client
return TestClient(app, raise_server_exceptions=True)
def test_packets_nav_hidden_when_disabled(
self, mock_http_client: MockHttpClient
) -> None:
"""The packets nav link is absent when the feature is off."""
client = self._make_app(mock_http_client, packets=False)
html = client.get("/").text
assert 'href="/packets"' not in html
# Messages still shows (ordering sanity)
assert 'href="/messages"' in html
def test_packets_nav_shown_when_enabled(
self, mock_http_client: MockHttpClient
) -> None:
"""The packets nav link appears when the feature is on."""
client = self._make_app(mock_http_client, packets=True)
html = client.get("/").text
assert 'href="/packets"' in html
def test_packets_disabled_by_default_in_settings(self) -> None:
"""The declared default for feature_packets is False (env-independent)."""
from meshcore_hub.common.config import WebSettings
# Check the field default directly so a local .env cannot mask it.
assert WebSettings.model_fields["feature_packets"].default is False
class TestFeatureFlagsIndividual:
"""Test individual feature flags."""
@pytest.fixture
def _make_client(self, mock_http_client: MockHttpClient):
"""Factory to create a client with specific features disabled."""
def _create(disabled_feature: str) -> TestClient:
features = {
"dashboard": True,
"nodes": True,
"advertisements": True,
"messages": True,
"map": True,
"members": True,
"pages": True,
}
features[disabled_feature] = False
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features=features,
)
app.state.http_client = mock_http_client
return TestClient(app, raise_server_exceptions=True)
return _create
def test_disable_map_only(self, _make_client) -> None:
"""Disabling only map should hide map but show others."""
client = _make_client("map")
response = client.get("/")
html = response.text
assert 'href="/map"' not in html
assert 'href="/dashboard"' in html
assert 'href="/nodes"' in html
# Map data endpoint should 404
response = client.get("/map/data")
assert response.status_code == 404
def test_disable_dashboard_only(self, _make_client) -> None:
"""Disabling only dashboard should hide dashboard but show others."""
client = _make_client("dashboard")
response = client.get("/")
html = response.text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' in html
assert 'href="/map"' in html
class TestDashboardAutoDisable:
"""Test that dashboard is automatically disabled when it has no content."""
def test_dashboard_auto_disabled_when_all_stats_off(
self, mock_http_client: MockHttpClient
) -> None:
"""Dashboard should auto-disable when nodes, adverts, messages all off."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features={
"dashboard": True,
"nodes": False,
"advertisements": False,
"messages": False,
"map": True,
"members": True,
"pages": True,
},
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
html = response.text
assert 'href="/dashboard"' not in html
# Check config JSON also reflects it
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
assert config["features"]["dashboard"] is False
def test_map_auto_disabled_when_nodes_off(
self, mock_http_client: MockHttpClient
) -> None:
"""Map should auto-disable when nodes is off (map depends on nodes)."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features={
"dashboard": True,
"nodes": False,
"advertisements": True,
"messages": True,
"map": True,
"members": True,
"pages": True,
},
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
html = response.text
assert 'href="/map"' not in html
# Check config JSON also reflects it
config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
assert config["features"]["map"] is False
# Map data endpoint should 404
response = client.get("/map/data")
assert response.status_code == 404
def test_dashboard_stays_enabled_with_one_stat(
self, mock_http_client: MockHttpClient
) -> None:
"""Dashboard should stay enabled when at least one stat feature is on."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features={
"dashboard": True,
"nodes": True,
"advertisements": False,
"messages": False,
"map": True,
"members": True,
"pages": True,
},
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
response = client.get("/")
assert 'href="/dashboard"' in response.text