Rename channel visibility 'public' to 'community'

- Rename ChannelVisibility.PUBLIC to ChannelVisibility.COMMUNITY
- Update stored value from 'public' to 'community' across model, schema, API, CLI, and frontend
- Add Alembic migration to update existing database rows
- Consolidate upgrade docs: merge v0.11.0, v0.12.0, v0.13.0 into single v0.11.0 section
- Add i18n visibility level translation keys (en, nl)
- Update section headings on channels page to use t() for i18n
- Keep visibility badges lowercase per UI design
This commit is contained in:
Louis King
2026-06-04 14:07:12 +01:00
parent 1491c49ef7
commit f8c2a7bb40
24 changed files with 1545 additions and 86 deletions
+51
View File
@@ -21,6 +21,7 @@ from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models import (
Advertisement,
Base,
Channel,
Message,
Node,
NodeTag,
@@ -445,3 +446,53 @@ def sample_adopted_node(api_db_session, sample_user_profile, sample_node):
api_db_session.commit()
api_db_session.refresh(association)
return association
@pytest.fixture
def sample_channel(api_db_session):
"""Create a sample community channel in the database."""
channel = Channel(
name="TestChannel",
key_hex="AABBCCDDEEFF00112233445566778899",
channel_hash=Channel.compute_channel_hash("AABBCCDDEEFF00112233445566778899"),
visibility="community",
enabled=True,
)
api_db_session.add(channel)
api_db_session.commit()
api_db_session.refresh(channel)
return channel
@pytest.fixture
def sample_member_channel(api_db_session):
"""Create a sample member-only channel in the database."""
key = "11223344556677889900AABBCCDDEEFF"
channel = Channel(
name="MemberChannel",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility="member",
enabled=True,
)
api_db_session.add(channel)
api_db_session.commit()
api_db_session.refresh(channel)
return channel
@pytest.fixture
def sample_admin_channel(api_db_session):
"""Create a sample admin-only channel in the database."""
key = "FFEEDDCCBBAA99887766554433221100"
channel = Channel(
name="AdminChannel",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility="admin",
enabled=True,
)
api_db_session.add(channel)
api_db_session.commit()
api_db_session.refresh(channel)
return channel
+312
View File
@@ -0,0 +1,312 @@
"""Tests for channel_visibility helpers."""
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from meshcore_hub.api.channel_visibility import (
get_all_known_channel_indices,
get_max_visibility_level,
get_visible_channel_indices,
resolve_user_role,
)
from meshcore_hub.common.models import Base
from meshcore_hub.common.models.channel import Channel
@pytest.fixture
def db_session():
"""Create an in-memory SQLite database session."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)
engine.dispose()
def _make_request(
headers: dict | None = None, app_state: dict | None = None
) -> MagicMock:
"""Create a mock FastAPI Request."""
from types import SimpleNamespace
request = MagicMock()
request.headers = headers or {}
state = SimpleNamespace(**(app_state or {}))
request.app.state = state
return request
class TestResolveUserRole:
"""Tests for resolve_user_role()."""
def test_no_header_returns_none(self) -> None:
"""No X-User-Roles header returns None."""
request = _make_request(headers={})
assert resolve_user_role(request) is None
def test_empty_header_returns_none(self) -> None:
"""Empty X-User-Roles header returns None."""
request = _make_request(headers={"x-user-roles": ""})
assert resolve_user_role(request) is None
def test_admin_role(self) -> None:
"""Admin role is resolved correctly."""
request = _make_request(headers={"x-user-roles": "admin"})
assert resolve_user_role(request) == "admin"
def test_operator_role(self) -> None:
"""Operator role is resolved correctly."""
request = _make_request(headers={"x-user-roles": "operator"})
assert resolve_user_role(request) == "operator"
def test_member_role(self) -> None:
"""Member role is resolved correctly."""
request = _make_request(headers={"x-user-roles": "member"})
assert resolve_user_role(request) == "member"
def test_admin_takes_precedence_over_member(self) -> None:
"""Admin takes precedence when multiple roles present."""
request = _make_request(headers={"x-user-roles": "member,admin"})
assert resolve_user_role(request) == "admin"
def test_operator_takes_precedence_over_member(self) -> None:
"""Operator takes precedence over member."""
request = _make_request(headers={"x-user-roles": "member,operator"})
assert resolve_user_role(request) == "operator"
def test_admin_takes_precedence_over_all(self) -> None:
"""Admin takes precedence over operator and member."""
request = _make_request(headers={"x-user-roles": "member,operator,admin"})
assert resolve_user_role(request) == "admin"
def test_unknown_role_returns_none(self) -> None:
"""Unknown role returns None."""
request = _make_request(headers={"x-user-roles": "viewer"})
assert resolve_user_role(request) is None
def test_custom_role_names(self) -> None:
"""Custom OIDC role names from app.state are recognized."""
request = _make_request(
headers={"x-user-roles": "superadmin,moderator"},
app_state={
"oidc_role_admin": "superadmin",
"oidc_role_operator": "moderator",
"oidc_role_member": "user",
},
)
assert resolve_user_role(request) == "admin"
def test_custom_member_role_name(self) -> None:
"""Custom member role name is recognized."""
request = _make_request(
headers={"x-user-roles": "user"},
app_state={
"oidc_role_member": "user",
},
)
assert resolve_user_role(request) == "member"
def test_whitespace_in_header(self) -> None:
"""Whitespace around role names is handled."""
request = _make_request(headers={"x-user-roles": " admin , member "})
assert resolve_user_role(request) == "admin"
class TestGetMaxVisibilityLevel:
"""Tests for get_max_visibility_level()."""
def test_none_returns_zero(self) -> None:
"""Anonymous users get level 0 (community only)."""
assert get_max_visibility_level(None) == 0
def test_community_returns_zero(self) -> None:
assert get_max_visibility_level("community") == 0
def test_member_returns_one(self) -> None:
assert get_max_visibility_level("member") == 1
def test_operator_returns_two(self) -> None:
assert get_max_visibility_level("operator") == 2
def test_admin_returns_three(self) -> None:
assert get_max_visibility_level("admin") == 3
def test_unknown_returns_zero(self) -> None:
assert get_max_visibility_level("unknown") == 0
class TestGetVisibleChannelIndices:
"""Tests for get_visible_channel_indices()."""
def test_always_includes_idx_17(self, db_session) -> None:
"""Built-in Public channel (idx 17) is always visible."""
indices = get_visible_channel_indices(db_session, 0)
assert 17 in indices
def test_community_channels_visible_at_level_0(self, db_session) -> None:
"""Community channels are visible at level 0."""
key = "AABBCCDDEEFF00112233445566778899"
ch = Channel(
name="Community",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility="community",
)
db_session.add(ch)
db_session.commit()
indices = get_visible_channel_indices(db_session, 0)
expected_idx = int(ch.channel_hash, 16)
assert expected_idx in indices
assert 17 in indices
def test_member_channels_hidden_at_level_0(self, db_session) -> None:
"""Member channels are hidden at level 0."""
key = "11223344556677889900AABBCCDDEEFF"
ch = Channel(
name="MembersOnly",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility="member",
)
db_session.add(ch)
db_session.commit()
indices = get_visible_channel_indices(db_session, 0)
ch_idx = int(ch.channel_hash, 16)
assert ch_idx not in indices
def test_member_channels_visible_at_level_1(self, db_session) -> None:
"""Member channels are visible at level 1."""
key = "11223344556677889900AABBCCDDEEFF"
ch = Channel(
name="MemberCh",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility="member",
)
db_session.add(ch)
db_session.commit()
indices = get_visible_channel_indices(db_session, 1)
ch_idx = int(ch.channel_hash, 16)
assert ch_idx in indices
def test_admin_channels_visible_at_level_3(self, db_session) -> None:
"""Admin channels are visible at level 3."""
key = "FFEEDDCCBBAA99887766554433221100"
ch = Channel(
name="AdminCh",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility="admin",
)
db_session.add(ch)
db_session.commit()
indices = get_visible_channel_indices(db_session, 3)
ch_idx = int(ch.channel_hash, 16)
assert ch_idx in indices
def test_admin_channels_hidden_at_level_1(self, db_session) -> None:
"""Admin channels are hidden at level 1."""
key = "FFEEDDCCBBAA99887766554433221100"
ch = Channel(
name="AdminCh",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility="admin",
)
db_session.add(ch)
db_session.commit()
indices = get_visible_channel_indices(db_session, 1)
ch_idx = int(ch.channel_hash, 16)
assert ch_idx not in indices
def test_mixed_visibility_channels(self, db_session) -> None:
"""Multiple channels with different visibility levels."""
pub_key = "AABBCCDDEEFF00112233445566778899"
mem_key = "11223344556677889900AABBCCDDEEFF"
adm_key = "FFEEDDCCBBAA99887766554433221100"
for name, key, vis in [
("Community", pub_key, "community"),
("Member", mem_key, "member"),
("Admin", adm_key, "admin"),
]:
db_session.add(
Channel(
name=name,
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility=vis,
)
)
db_session.commit()
level_0 = get_visible_channel_indices(db_session, 0)
level_1 = get_visible_channel_indices(db_session, 1)
level_3 = get_visible_channel_indices(db_session, 3)
pub_idx = int(Channel.compute_channel_hash(pub_key), 16)
mem_idx = int(Channel.compute_channel_hash(mem_key), 16)
adm_idx = int(Channel.compute_channel_hash(adm_key), 16)
assert pub_idx in level_0
assert mem_idx not in level_0
assert adm_idx not in level_0
assert pub_idx in level_1
assert mem_idx in level_1
assert adm_idx not in level_1
assert pub_idx in level_3
assert mem_idx in level_3
assert adm_idx in level_3
assert 17 in level_0
assert 17 in level_1
assert 17 in level_3
class TestGetAllKnownChannelIndices:
"""Tests for get_all_known_channel_indices()."""
def test_empty_db(self, db_session) -> None:
"""Empty DB returns empty set."""
indices = get_all_known_channel_indices(db_session)
assert indices == set()
def test_returns_all_indices(self, db_session) -> None:
"""Returns all channel indices from DB."""
key1 = "AABBCCDDEEFF00112233445566778899"
key2 = "11223344556677889900AABBCCDDEEFF"
for name, key in [("Ch1", key1), ("Ch2", key2)]:
db_session.add(
Channel(
name=name,
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
)
)
db_session.commit()
indices = get_all_known_channel_indices(db_session)
idx1 = int(Channel.compute_channel_hash(key1), 16)
idx2 = int(Channel.compute_channel_hash(key2), 16)
assert indices == {idx1, idx2}
def test_does_not_include_builtin_17(self, db_session) -> None:
"""Does not include the built-in Public channel (17) unless in DB."""
indices = get_all_known_channel_indices(db_session)
assert 17 not in indices
+323
View File
@@ -0,0 +1,323 @@
"""Tests for channel API routes."""
from meshcore_hub.common.models import Channel
VALID_KEY_32 = "A" * 32
VALID_KEY_64 = "B" * 64
ALT_KEY_32 = "C" * 32
class TestListChannels:
"""Tests for GET /channels endpoint."""
def test_list_channels_empty(self, client_no_auth):
"""Test listing channels when database is empty."""
response = client_no_auth.get("/api/v1/channels")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
def test_list_channels_with_data(self, client_no_auth, sample_channel):
"""Test listing channels with data in database."""
response = client_no_auth.get("/api/v1/channels")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["total"] == 1
assert data["items"][0]["name"] == "TestChannel"
assert data["items"][0]["key_hex"] is not None
assert data["items"][0]["masked_key"] is not None
def test_list_channels_anonymous_only_community(
self, client_no_auth, api_db_session
):
"""Anonymous users only see community channels."""
pub_key = "AABBCCDDEEFF00112233445566778899"
mem_key = "11223344556677889900AABBCCDDEEFF"
for name, key, vis in [
("Community", pub_key, "community"),
("Secret", mem_key, "member"),
]:
ch = Channel(
name=name,
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility=vis,
enabled=True,
)
api_db_session.add(ch)
api_db_session.commit()
response = client_no_auth.get("/api/v1/channels")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["name"] == "Community"
def test_list_channels_admin_sees_all(self, client_no_auth, api_db_session):
"""Admin role header allows seeing all channels."""
pub_key = "AABBCCDDEEFF00112233445566778899"
mem_key = "11223344556677889900AABBCCDDEEFF"
adm_key = "FFEEDDCCBBAA99887766554433221100"
for name, key, vis in [
("Community", pub_key, "community"),
("Member", mem_key, "member"),
("Admin", adm_key, "admin"),
]:
ch = Channel(
name=name,
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility=vis,
enabled=True,
)
api_db_session.add(ch)
api_db_session.commit()
response = client_no_auth.get(
"/api/v1/channels",
headers={"X-User-Roles": "admin"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
def test_list_channels_member_sees_community_and_member(
self, client_no_auth, api_db_session
):
"""Member role sees community and member channels, not admin."""
pub_key = "AABBCCDDEEFF00112233445566778899"
mem_key = "11223344556677889900AABBCCDDEEFF"
adm_key = "FFEEDDCCBBAA99887766554433221100"
for name, key, vis in [
("Community", pub_key, "community"),
("MemberCh", mem_key, "member"),
("AdminCh", adm_key, "admin"),
]:
ch = Channel(
name=name,
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility=vis,
enabled=True,
)
api_db_session.add(ch)
api_db_session.commit()
response = client_no_auth.get(
"/api/v1/channels",
headers={"X-User-Roles": "member"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = {item["name"] for item in data["items"]}
assert names == {"Community", "MemberCh"}
class TestCreateChannel:
"""Tests for POST /channels endpoint."""
def test_create_channel_success(self, client_no_auth):
"""Test creating a channel successfully."""
response = client_no_auth.post(
"/api/v1/channels",
json={
"name": "NewChannel",
"key_hex": VALID_KEY_32,
"visibility": "community",
"enabled": True,
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "NewChannel"
assert data["visibility"] == "community"
assert data["enabled"] is True
assert data["key_hex"] == VALID_KEY_32
assert data["masked_key"] == f"{VALID_KEY_32[:4]}...{VALID_KEY_32[-4:]}"
assert data["channel_hash"] == Channel.compute_channel_hash(VALID_KEY_32)
assert data["id"] is not None
assert data["created_at"] is not None
def test_create_channel_duplicate_name(self, client_no_auth, sample_channel):
"""Test creating channel with duplicate name returns 409."""
response = client_no_auth.post(
"/api/v1/channels",
json={
"name": "TestChannel",
"key_hex": ALT_KEY_32,
},
)
assert response.status_code == 409
assert "already exists" in response.json()["detail"]
def test_create_channel_duplicate_key(self, client_no_auth, sample_channel):
"""Test creating channel with duplicate key returns 409."""
response = client_no_auth.post(
"/api/v1/channels",
json={
"name": "DifferentName",
"key_hex": sample_channel.key_hex,
},
)
assert response.status_code == 409
assert "Key already in use" in response.json()["detail"]
def test_create_channel_invalid_key(self, client_no_auth):
"""Test creating channel with invalid key returns 422."""
response = client_no_auth.post(
"/api/v1/channels",
json={
"name": "BadKey",
"key_hex": "NOT-HEX",
},
)
assert response.status_code == 422
def test_create_channel_aes256_key(self, client_no_auth):
"""Test creating channel with AES-256 key (64 hex chars)."""
response = client_no_auth.post(
"/api/v1/channels",
json={
"name": "AES256",
"key_hex": VALID_KEY_64,
},
)
assert response.status_code == 201
assert response.json()["key_hex"] == VALID_KEY_64
def test_create_channel_with_auth(self, client_with_auth):
"""Test creating channel requires admin key."""
response = client_with_auth.post(
"/api/v1/channels",
json={
"name": "AuthChannel",
"key_hex": VALID_KEY_32,
},
)
assert response.status_code == 401
response = client_with_auth.post(
"/api/v1/channels",
headers={"Authorization": "Bearer test-admin-key"},
json={
"name": "AuthChannel",
"key_hex": VALID_KEY_32,
},
)
assert response.status_code == 201
class TestUpdateChannel:
"""Tests for PUT /channels/{channel_id} endpoint."""
def test_update_channel_visibility(self, client_no_auth, sample_channel):
"""Test updating channel visibility."""
response = client_no_auth.put(
f"/api/v1/channels/{sample_channel.id}",
json={"visibility": "member"},
)
assert response.status_code == 200
assert response.json()["visibility"] == "member"
def test_update_channel_key(self, client_no_auth, sample_channel):
"""Test updating channel key regenerates hash."""
response = client_no_auth.put(
f"/api/v1/channels/{sample_channel.id}",
json={"key_hex": ALT_KEY_32},
)
assert response.status_code == 200
data = response.json()
assert data["key_hex"] == ALT_KEY_32
assert data["channel_hash"] == Channel.compute_channel_hash(ALT_KEY_32)
def test_update_channel_enabled(self, client_no_auth, sample_channel):
"""Test disabling a channel."""
response = client_no_auth.put(
f"/api/v1/channels/{sample_channel.id}",
json={"enabled": False},
)
assert response.status_code == 200
assert response.json()["enabled"] is False
def test_update_channel_not_found(self, client_no_auth):
"""Test updating non-existent channel returns 404."""
response = client_no_auth.put(
"/api/v1/channels/nonexistent-id",
json={"visibility": "admin"},
)
assert response.status_code == 404
def test_update_channel_duplicate_key(self, client_no_auth, api_db_session):
"""Test updating key to one already in use returns 409."""
key1 = "AABBCCDDEEFF00112233445566778899"
key2 = "11223344556677889900AABBCCDDEEFF"
ch1 = Channel(
name="Ch1",
key_hex=key1,
channel_hash=Channel.compute_channel_hash(key1),
)
ch2 = Channel(
name="Ch2",
key_hex=key2,
channel_hash=Channel.compute_channel_hash(key2),
)
api_db_session.add_all([ch1, ch2])
api_db_session.commit()
response = client_no_auth.put(
f"/api/v1/channels/{ch1.id}",
json={"key_hex": key2},
)
assert response.status_code == 409
def test_update_channel_same_key_allowed(self, client_no_auth, sample_channel):
"""Test updating channel with its own key is allowed."""
response = client_no_auth.put(
f"/api/v1/channels/{sample_channel.id}",
json={"key_hex": sample_channel.key_hex},
)
assert response.status_code == 200
class TestDeleteChannel:
"""Tests for DELETE /channels/{channel_id} endpoint."""
def test_delete_channel_success(self, client_no_auth, sample_channel):
"""Test deleting a channel."""
response = client_no_auth.delete(f"/api/v1/channels/{sample_channel.id}")
assert response.status_code == 204
response = client_no_auth.get("/api/v1/channels")
assert response.status_code == 200
assert response.json()["total"] == 0
def test_delete_channel_not_found(self, client_no_auth):
"""Test deleting non-existent channel returns 404."""
response = client_no_auth.delete("/api/v1/channels/nonexistent-id")
assert response.status_code == 404
def test_delete_channel_with_auth(self, client_with_auth, api_db_session):
"""Test deleting channel requires admin key."""
key = "AABBCCDDEEFF00112233445566778899"
ch = Channel(
name="ToDelete",
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
)
api_db_session.add(ch)
api_db_session.commit()
response = client_with_auth.delete(f"/api/v1/channels/{ch.id}")
assert response.status_code == 401
response = client_with_auth.delete(
f"/api/v1/channels/{ch.id}",
headers={"Authorization": "Bearer test-admin-key"},
)
assert response.status_code == 204
+137 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest
from meshcore_hub.common.models import Advertisement, Message, Node
from meshcore_hub.common.models import Advertisement, Message, Node, Channel
from meshcore_hub.common.models import UserProfile
@@ -439,3 +439,139 @@ class TestDashboardFloodOnlyFilter:
data = response.json()
total_count = sum(point["count"] for point in data["data"])
assert total_count == 1
class TestDashboardChannelVisibility:
"""Tests for channel visibility filtering on dashboard stats."""
@pytest.fixture
def channels_with_messages(self, api_db_session):
"""Create public and admin channels with messages."""
pub_key = "AABBCCDDEEFF00112233445566778899"
adm_key = "FFEEDDCCBBAA99887766554433221100"
pub_idx = int(Channel.compute_channel_hash(pub_key), 16)
adm_idx = int(Channel.compute_channel_hash(adm_key), 16)
pub_ch = Channel(
name="CommunityCh",
key_hex=pub_key,
channel_hash=Channel.compute_channel_hash(pub_key),
visibility="community",
enabled=True,
)
adm_ch = Channel(
name="AdminCh",
key_hex=adm_key,
channel_hash=Channel.compute_channel_hash(adm_key),
visibility="admin",
enabled=True,
)
api_db_session.add_all([pub_ch, adm_ch])
pub_msg = Message(
message_type="channel",
channel_idx=pub_idx,
text="Public message",
received_at=datetime.now(timezone.utc),
)
adm_msg = Message(
message_type="channel",
channel_idx=adm_idx,
text="Admin message",
received_at=datetime.now(timezone.utc),
)
direct_msg = Message(
message_type="direct",
pubkey_prefix="abc123",
text="Direct message",
received_at=datetime.now(timezone.utc),
)
api_db_session.add_all([pub_msg, adm_msg, direct_msg])
api_db_session.commit()
return pub_idx, adm_idx
def test_anonymous_sees_only_community_messages(
self, client_no_auth, channels_with_messages
):
"""Anonymous users only see community and direct messages in stats."""
response = client_no_auth.get("/api/v1/dashboard/stats")
assert response.status_code == 200
data = response.json()
assert data["total_messages"] == 2
def test_admin_sees_all_messages(self, client_no_auth, channels_with_messages):
"""Admin users see all messages in stats."""
response = client_no_auth.get(
"/api/v1/dashboard/stats",
headers={"X-User-Roles": "admin"},
)
assert response.status_code == 200
data = response.json()
assert data["total_messages"] == 3
def test_channel_message_counts_filtered(
self, client_no_auth, channels_with_messages
):
"""Channel message counts exclude hidden channels."""
pub_idx, adm_idx = channels_with_messages
response = client_no_auth.get("/api/v1/dashboard/stats")
assert response.status_code == 200
data = response.json()
assert str(pub_idx) in data["channel_message_counts"]
assert str(adm_idx) not in data["channel_message_counts"]
def test_admin_channel_message_counts_all(
self, client_no_auth, channels_with_messages
):
"""Admin users see all channel message counts."""
pub_idx, adm_idx = channels_with_messages
response = client_no_auth.get(
"/api/v1/dashboard/stats",
headers={"X-User-Roles": "admin"},
)
assert response.status_code == 200
data = response.json()
assert str(pub_idx) in data["channel_message_counts"]
assert str(adm_idx) in data["channel_message_counts"]
def test_message_activity_respects_visibility(self, client_no_auth, api_db_session):
"""Message activity endpoint filters by channel visibility."""
adm_key = "FFEEDDCCBBAA99887766554433221100"
adm_idx = int(Channel.compute_channel_hash(adm_key), 16)
adm_ch = Channel(
name="AdminCh",
key_hex=adm_key,
channel_hash=Channel.compute_channel_hash(adm_key),
visibility="admin",
enabled=True,
)
api_db_session.add(adm_ch)
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
adm_msg = Message(
message_type="channel",
channel_idx=adm_idx,
text="Admin msg",
received_at=yesterday,
)
api_db_session.add(adm_msg)
api_db_session.commit()
response_anon = client_no_auth.get("/api/v1/dashboard/message-activity")
assert response_anon.status_code == 200
anon_data = response_anon.json()
anon_total = sum(p["count"] for p in anon_data["data"])
assert anon_total == 0
response_admin = client_no_auth.get(
"/api/v1/dashboard/message-activity",
headers={"X-User-Roles": "admin"},
)
assert response_admin.status_code == 200
admin_data = response_admin.json()
admin_total = sum(p["count"] for p in admin_data["data"])
assert admin_total >= 1
+125 -1
View File
@@ -2,7 +2,9 @@
from datetime import datetime, timedelta, timezone
from meshcore_hub.common.models import EventObserver, Message, Node, NodeTag
import pytest
from meshcore_hub.common.models import EventObserver, Message, Node, NodeTag, Channel
class TestListMessages:
@@ -465,3 +467,125 @@ class TestMessageSort:
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "New"
class TestMessageChannelVisibility:
"""Tests for channel visibility filtering on messages."""
@pytest.fixture
def messages_with_visibility(self, api_db_session):
"""Create messages on public and admin channels."""
pub_key = "AABBCCDDEEFF00112233445566778899"
adm_key = "FFEEDDCCBBAA99887766554433221100"
pub_idx = int(Channel.compute_channel_hash(pub_key), 16)
adm_idx = int(Channel.compute_channel_hash(adm_key), 16)
pub_ch = Channel(
name="CommunityCh",
key_hex=pub_key,
channel_hash=Channel.compute_channel_hash(pub_key),
visibility="community",
enabled=True,
)
adm_ch = Channel(
name="AdminCh",
key_hex=adm_key,
channel_hash=Channel.compute_channel_hash(adm_key),
visibility="admin",
enabled=True,
)
api_db_session.add_all([pub_ch, adm_ch])
pub_msg = Message(
message_type="channel",
channel_idx=pub_idx,
text="Community channel message",
received_at=datetime.now(timezone.utc),
)
adm_msg = Message(
message_type="channel",
channel_idx=adm_idx,
text="Admin channel message",
received_at=datetime.now(timezone.utc),
)
direct_msg = Message(
message_type="direct",
pubkey_prefix="abc123",
text="Direct message",
received_at=datetime.now(timezone.utc),
)
api_db_session.add_all([pub_msg, adm_msg, direct_msg])
api_db_session.commit()
return pub_msg, adm_msg, direct_msg
def test_anonymous_sees_only_community_channel_messages(
self, client_no_auth, messages_with_visibility
):
"""Anonymous users see community channel and direct messages only."""
response = client_no_auth.get("/api/v1/messages")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
texts = {item["text"] for item in data["items"]}
assert "Community channel message" in texts
assert "Direct message" in texts
assert "Admin channel message" not in texts
def test_admin_sees_all_channel_messages(
self, client_no_auth, messages_with_visibility
):
"""Admin users see all channel messages."""
response = client_no_auth.get(
"/api/v1/messages",
headers={"X-User-Roles": "admin"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
texts = {item["text"] for item in data["items"]}
assert "Community channel message" in texts
assert "Admin channel message" in texts
assert "Direct message" in texts
def test_get_message_hidden_channel_returns_404(
self, client_no_auth, messages_with_visibility
):
"""Getting a message on a hidden channel returns 404."""
pub_msg, adm_msg, direct_msg = messages_with_visibility
response = client_no_auth.get(f"/api/v1/messages/{adm_msg.id}")
assert response.status_code == 404
def test_get_message_hidden_channel_visible_to_admin(
self, client_no_auth, messages_with_visibility
):
"""Admin can get a message on an admin channel."""
pub_msg, adm_msg, direct_msg = messages_with_visibility
response = client_no_auth.get(
f"/api/v1/messages/{adm_msg.id}",
headers={"X-User-Roles": "admin"},
)
assert response.status_code == 200
assert response.json()["text"] == "Admin channel message"
def test_get_message_community_channel_visible(
self, client_no_auth, messages_with_visibility
):
"""Anonymous can get a message on a community channel."""
pub_msg, adm_msg, direct_msg = messages_with_visibility
response = client_no_auth.get(f"/api/v1/messages/{pub_msg.id}")
assert response.status_code == 200
assert response.json()["text"] == "Community channel message"
def test_direct_messages_always_visible(
self, client_no_auth, messages_with_visibility
):
"""Direct messages are always visible regardless of channel visibility."""
pub_msg, adm_msg, direct_msg = messages_with_visibility
response = client_no_auth.get(f"/api/v1/messages/{direct_msg.id}")
assert response.status_code == 200
assert response.json()["text"] == "Direct message"