mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user