Files
meshcore-hub/tests/test_api/test_user_profiles.py
T
Louis King 620747baa3 fix(web): show node last_seen instead of adopted_at on profile
The User Profile page rendered each adopted node's relative time from
adopted_at (the adoption date) rather than the node's most recent
activity, so it always showed the time since adoption (e.g. "35 days
ago") even when the node had advertised today.

Expose last_seen on AdoptedNodeRead and render it on the profile page,
falling back to "-" when null (matching the nodes/node-detail pages).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:48:32 +01:00

489 lines
18 KiB
Python

"""Tests for user profile API routes."""
from unittest.mock import patch
import pytest
from meshcore_hub.common.models import UserProfile
from meshcore_hub.common.models.user_profile_node import UserProfileNode
TEST_USER_ID = "oidc-user-123"
OTHER_USER_ID = "oidc-user-456"
USER_HEADERS = {"X-User-Id": TEST_USER_ID, "X-User-Roles": "operator"}
USER_HEADERS_WITH_NAME = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "operator",
"X-User-Name": "IdP Display Name",
}
OTHER_USER_HEADERS = {"X-User-Id": OTHER_USER_ID, "X-User-Roles": "operator"}
OPERATOR_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "operator",
}
MEMBER_ONLY_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "member",
}
NO_ROLES_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "",
}
class TestListProfiles:
"""Tests for GET /user/profiles endpoint."""
def test_list_profiles_returns_list(self, client_no_auth, sample_user_profile):
"""Test listing profiles returns a paginated list."""
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert data["total"] >= 1
def test_list_profiles_no_user_id(self, client_no_auth, sample_user_profile):
"""Test that list profiles does not expose user_id."""
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
for item in data["items"]:
assert "user_id" not in item
def test_list_profiles_includes_roles(self, client_no_auth, sample_user_profile):
"""Test that list profiles includes roles."""
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) >= 1
assert "roles" in data["items"][0]
def test_list_profiles_includes_node_count(
self, client_no_auth, sample_user_profile, sample_adopted_node, sample_node
):
"""Test that list profiles includes node_count and adopted_nodes."""
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
profile = next(p for p in data["items"] if p["id"] == sample_user_profile.id)
assert profile["node_count"] == 1
assert len(profile["adopted_nodes"]) == 1
assert profile["adopted_nodes"][0]["name"] == sample_node.name
def test_list_profiles_resilient_to_orphaned_adoption(
self, client_no_auth, api_db_session, sample_user_profile, sample_node
):
"""Test that orphaned UserProfileNode rows don't cause 500 errors."""
from sqlalchemy import text
adoption = UserProfileNode(
user_profile_id=sample_user_profile.id,
node_id=sample_node.id,
)
api_db_session.add(adoption)
api_db_session.commit()
api_db_session.execute(text("PRAGMA foreign_keys=OFF"))
api_db_session.execute(
text("DELETE FROM nodes WHERE id = :id"),
{"id": sample_node.id},
)
api_db_session.commit()
api_db_session.execute(text("PRAGMA foreign_keys=ON"))
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
profile = next(p for p in data["items"] if p["id"] == sample_user_profile.id)
assert profile["adopted_nodes"] == []
class TestGetMyProfile:
"""Tests for GET /user/profile/me endpoint."""
def test_get_my_profile_returns_full_profile(
self, client_no_auth, sample_user_profile
):
"""Test /me returns the full profile with user_id for authenticated user."""
response = client_no_auth.get(
"/api/v1/user/profile/me",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == TEST_USER_ID
assert data["name"] == sample_user_profile.name
def test_get_my_profile_auto_creates(self, client_no_auth):
"""Test /me auto-creates profile for new user."""
new_headers = {
"X-User-Id": "brand-new-user",
"X-User-Roles": "member",
"X-User-Name": "New User",
}
response = client_no_auth.get(
"/api/v1/user/profile/me",
headers=new_headers,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == "brand-new-user"
assert data["name"] == "New User"
def test_get_my_profile_requires_user_id(self, client_no_auth):
"""Test /me returns 401 without X-User-Id header."""
response = client_no_auth.get("/api/v1/user/profile/me")
assert response.status_code == 401
class TestGetProfile:
"""Tests for GET /user/profile/{profile_id} endpoint."""
def test_get_profile_auto_creates_for_owner(self, client_no_auth):
"""Test getting a non-existent profile auto-creates it for the owner."""
response = client_no_auth.get(
"/api/v1/user/profile/nonexistent-uuid",
headers=USER_HEADERS,
)
assert response.status_code == 404
def test_get_existing_profile_by_uuid(self, client_no_auth, sample_user_profile):
"""Test getting an existing profile by UUID."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == sample_user_profile.name
assert data["callsign"] == sample_user_profile.callsign
def test_get_profile_public_no_auth(self, client_no_auth, sample_user_profile):
"""Test that profile can be viewed without authentication."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.id}",
)
assert response.status_code == 200
data = response.json()
assert "user_id" not in data
assert data["name"] == sample_user_profile.name
def test_get_profile_owner_sees_user_id(self, client_no_auth, sample_user_profile):
"""Test that owner sees user_id in their own profile."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data.get("user_id") == sample_user_profile.user_id
def test_get_profile_with_adopted_nodes(
self, client_no_auth, sample_user_profile, sample_adopted_node
):
"""Test profile includes adopted nodes."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert len(data["nodes"]) == 1
assert data["nodes"][0]["public_key"] == "abc123def456abc123def456abc123de"
assert "adopted_at" in data["nodes"][0]
assert data["nodes"][0]["last_seen"] is not None
def test_get_profile_returns_roles(self, client_no_auth, sample_user_profile):
"""Test that profile includes roles."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert "roles" in data
assert "operator" in data["roles"]
def test_get_profile_not_found(self, client_no_auth):
"""Test 404 for non-existent profile UUID."""
response = client_no_auth.get(
"/api/v1/user/profile/00000000-0000-0000-0000-000000000000",
)
assert response.status_code == 404
class TestUpdateProfile:
"""Tests for PUT /user/profile/{profile_id} endpoint."""
def test_update_profile_name(self, client_no_auth, sample_user_profile):
"""Test updating profile name."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"name": "New Name"},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "New Name"
assert data["callsign"] == sample_user_profile.callsign
def test_update_profile_callsign(self, client_no_auth, sample_user_profile):
"""Test updating profile callsign."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"callsign": "G1NEW"},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["callsign"] == "G1NEW"
assert data["name"] == sample_user_profile.name
def test_update_profile_description(self, client_no_auth, sample_user_profile):
"""Test updating profile description."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"description": "Operator of IP2 repeaters"},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["description"] == "Operator of IP2 repeaters"
def test_update_profile_url(self, client_no_auth, sample_user_profile):
"""Test updating profile url."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"url": "https://qrz.com/db/W1TEST"},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["url"] == "https://qrz.com/db/W1TEST"
def test_update_profile_url_rejects_invalid(
self, client_no_auth, sample_user_profile
):
"""Test that invalid URLs are rejected."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"url": "not-a-valid-url"},
headers=USER_HEADERS,
)
assert response.status_code == 422
def test_update_profile_clear_callsign(self, client_no_auth, sample_user_profile):
"""Test clearing callsign via explicit null."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"callsign": None},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["callsign"] is None
def test_update_profile_clear_description(
self, client_no_auth, sample_user_profile
):
"""Test clearing description via explicit null."""
# First set a description
client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"description": "Initial description"},
headers=USER_HEADERS,
)
# Then clear it
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"description": None},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["description"] is None
def test_update_profile_clear_url(self, client_no_auth, sample_user_profile):
"""Test clearing url via explicit null."""
# First set a url
client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"url": "https://qrz.com/db/W1TEST"},
headers=USER_HEADERS,
)
# Then clear it
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"url": None},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["url"] is None
def test_update_profile_name_cannot_be_cleared(
self, client_no_auth, sample_user_profile
):
"""Test that name cannot be cleared (set by IdP on login)."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"name": None},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == sample_user_profile.name
def test_update_profile_rejects_wrong_user(
self, client_no_auth, sample_user_profile
):
"""Test that a user cannot update another user's profile."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"name": "Hacked"},
headers=OTHER_USER_HEADERS,
)
assert response.status_code == 403
def test_update_profile_rejects_missing_user_id(
self, client_no_auth, sample_user_profile
):
"""Test that missing X-User-Id header is rejected."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"name": "No Auth"},
)
assert response.status_code == 401
def test_update_profile_not_found(self, client_no_auth):
"""Test 404 for updating non-existent profile."""
response = client_no_auth.put(
"/api/v1/user/profile/00000000-0000-0000-0000-000000000000",
json={"name": "Ghost"},
headers=USER_HEADERS,
)
assert response.status_code == 404
def test_update_profile_with_no_roles(self, client_no_auth, sample_user_profile):
"""Test that profile update works without any roles (regression guard)."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.id}",
json={"callsign": "NR1"},
headers=NO_ROLES_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["callsign"] == "NR1"
class TestListProfilesExcludeTest:
"""Tests for exclude_test query parameter on GET /user/profiles."""
@pytest.fixture
def profiles_with_test_role(self, api_db_session):
"""Create profiles including some with the test role."""
profiles = []
for user_id, name, roles in [
("real-op", "Real Operator", "operator"),
("real-mem", "Real Member", "member"),
("test-op", "Test Operator", "operator,test"),
("test-mem", "Test Member", "member,test"),
]:
p = UserProfile(user_id=user_id, name=name, roles=roles)
api_db_session.add(p)
profiles.append(p)
api_db_session.commit()
return profiles
def test_exclude_test_true_filters_test_users(
self, client_no_auth, profiles_with_test_role
):
"""Test that exclude_test=true (default) filters test users."""
with patch(
"meshcore_hub.api.routes.user_profiles.get_web_settings"
) as mock_settings:
settings = mock_settings.return_value
settings.oidc_role_test = "test"
response = client_no_auth.get(
"/api/v1/user/profiles?exclude_test=true",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
names = [item["name"] for item in data["items"]]
assert "Real Operator" in names
assert "Real Member" in names
assert "Test Operator" not in names
assert "Test Member" not in names
assert data["total"] == 2
def test_exclude_test_default_is_true(
self, client_no_auth, profiles_with_test_role
):
"""Test that exclude_test defaults to true."""
with patch(
"meshcore_hub.api.routes.user_profiles.get_web_settings"
) as mock_settings:
settings = mock_settings.return_value
settings.oidc_role_test = "test"
response = client_no_auth.get(
"/api/v1/user/profiles",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
def test_exclude_test_false_includes_test_users(
self, client_no_auth, profiles_with_test_role
):
"""Test that exclude_test=false includes test users."""
with patch(
"meshcore_hub.api.routes.user_profiles.get_web_settings"
) as mock_settings:
settings = mock_settings.return_value
settings.oidc_role_test = "test"
response = client_no_auth.get(
"/api/v1/user/profiles?exclude_test=false",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 4
def test_empty_test_role_does_not_filter(
self, client_no_auth, profiles_with_test_role
):
"""Test that an empty test role does not filter any users."""
with patch(
"meshcore_hub.api.routes.user_profiles.get_web_settings"
) as mock_settings:
settings = mock_settings.return_value
settings.oidc_role_test = ""
response = client_no_auth.get(
"/api/v1/user/profiles?exclude_test=true",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 4