feat: Split read/write scopes into app:read/write scopes

This commit is contained in:
Chris Coutinho
2025-10-24 04:38:49 +02:00
parent d55e5708c7
commit d452684535
45 changed files with 1630 additions and 952 deletions
+1
View File
@@ -0,0 +1 @@
"""Unit tests with mocked dependencies for fast feedback."""
+123
View File
@@ -0,0 +1,123 @@
"""Unit tests for Pydantic response models."""
import pytest
from nextcloud_mcp_server.models.notes import (
CreateNoteResponse,
Note,
NoteSearchResult,
SearchNotesResponse,
)
@pytest.mark.unit
def test_note_model_creation():
"""Test creating a Note model with required fields."""
note = Note(
id=123,
title="Test Note",
content="# Test Content",
modified=1700000000,
etag="abc123",
)
assert note.id == 123
assert note.title == "Test Note"
assert note.content == "# Test Content"
assert note.category == "" # default value
assert note.favorite is False # default value
assert note.etag == "abc123"
@pytest.mark.unit
def test_note_modified_datetime_property():
"""Test that Note.modified_datetime converts Unix timestamp correctly."""
note = Note(
id=1,
title="Test",
content="Content",
modified=1700000000,
etag="etag",
)
dt = note.modified_datetime
assert dt.year == 2023 # Nov 14, 2023
assert dt.month == 11
@pytest.mark.unit
def test_create_note_response_serialization():
"""Test CreateNoteResponse can serialize to JSON."""
response = CreateNoteResponse(
id=42,
title="New Note",
category="Work",
etag="xyz789",
)
# Test serialization
data = response.model_dump()
assert data["id"] == 42
assert data["title"] == "New Note"
assert data["category"] == "Work"
assert data["etag"] == "xyz789"
@pytest.mark.unit
def test_search_notes_response_wraps_results():
"""Test SearchNotesResponse wraps list of results correctly.
This is critical - FastMCP mangles raw List[Dict] responses,
so we must wrap them in a response model.
"""
results = [
NoteSearchResult(id=1, title="First Note", category="Work"),
NoteSearchResult(id=2, title="Second Note", category="Personal"),
]
response = SearchNotesResponse(
results=results,
query="test query",
total_found=2,
)
# Verify the response structure
assert len(response.results) == 2
assert response.results[0].id == 1
assert response.results[1].title == "Second Note"
assert response.query == "test query"
assert response.total_found == 2
# Verify it serializes correctly
data = response.model_dump()
assert "results" in data
assert isinstance(data["results"], list)
assert len(data["results"]) == 2
assert data["results"][0]["id"] == 1
@pytest.mark.unit
def test_note_search_result_with_score():
"""Test NoteSearchResult with optional score field."""
result = NoteSearchResult(
id=99,
title="Relevant Note",
category="Archive",
score=0.95,
)
assert result.id == 99
assert result.score == 0.95
@pytest.mark.unit
def test_note_search_result_without_score():
"""Test NoteSearchResult without optional score field."""
result = NoteSearchResult(
id=99,
title="Relevant Note",
category="Archive",
)
assert result.id == 99
assert result.score is None
+65
View File
@@ -0,0 +1,65 @@
"""Unit tests for scope decorator metadata and classification logic."""
import pytest
from nextcloud_mcp_server.auth.scope_authorization import (
InsufficientScopeError,
require_scopes,
)
@pytest.mark.unit
def test_scope_decorator_stores_metadata():
"""Test that @require_scopes decorator stores scope requirements as function metadata."""
@require_scopes("notes:read", "notes:write")
async def example_function():
pass
# Verify metadata is stored
assert hasattr(example_function, "_required_scopes")
assert example_function._required_scopes == ["notes:read", "notes:write"]
@pytest.mark.unit
def test_scope_decorator_with_single_scope():
"""Test decorator with a single scope requirement."""
@require_scopes("calendar:read")
async def example_function():
pass
assert example_function._required_scopes == ["calendar:read"]
@pytest.mark.unit
def test_scope_decorator_with_no_scopes():
"""Test decorator with no scope requirements."""
@require_scopes()
async def example_function():
pass
assert example_function._required_scopes == []
@pytest.mark.unit
def test_insufficient_scope_error():
"""Test InsufficientScopeError exception structure."""
missing = ["notes:write", "calendar:write"]
error = InsufficientScopeError(missing)
assert error.missing_scopes == missing
assert "notes:write" in str(error)
assert "calendar:write" in str(error)
@pytest.mark.unit
def test_insufficient_scope_error_with_custom_message():
"""Test InsufficientScopeError with custom message."""
missing = ["files:write"]
custom_msg = "You need more permissions"
error = InsufficientScopeError(missing, custom_msg)
assert error.missing_scopes == missing
assert str(error) == custom_msg