Phase 0.1: Remove backend deprecated code
- Delete routes: counter.py, sum.py - Delete jobs.py and worker.py - Delete tests: test_counter.py, test_jobs.py - Update audit.py: keep only price-history endpoints - Update models.py: remove VIEW_COUNTER, INCREMENT_COUNTER, USE_SUM permissions - Update models.py: remove Counter, SumRecord, CounterRecord, RandomNumberOutcome models - Update schemas.py: remove sum/counter related schemas - Update main.py: remove deleted router imports - Update test_permissions.py: remove tests for deprecated features - Update test_price_history.py: remove worker-related tests - Update conftest.py: remove mock_enqueue_job fixture - Update auth.py: fix example in docstring
This commit is contained in:
parent
ea85198171
commit
5bad1e7e17
14 changed files with 35 additions and 1393 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
# Set required env vars before importing app
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only")
|
||||
|
|
@ -239,18 +238,3 @@ async def user_no_roles(client_factory):
|
|||
"cookies": dict(response.cookies),
|
||||
"response": response,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_enqueue_job():
|
||||
"""Mock job enqueueing for tests that hit the counter increment endpoint.
|
||||
|
||||
pgqueuer requires PostgreSQL-specific features that aren't available
|
||||
in the test database setup. We mock the enqueue function to avoid
|
||||
connection issues while still testing the counter logic.
|
||||
|
||||
Tests that call POST /api/counter/increment must use this fixture.
|
||||
"""
|
||||
mock = AsyncMock(return_value=1) # Return a fake job ID
|
||||
with patch("routes.counter.enqueue_random_number_job", mock):
|
||||
yield mock
|
||||
|
|
|
|||
|
|
@ -1,239 +0,0 @@
|
|||
"""Tests for counter endpoints.
|
||||
|
||||
Note: Registration now requires an invite code.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from auth import COOKIE_NAME
|
||||
from models import ROLE_REGULAR
|
||||
from tests.conftest import create_user_with_roles
|
||||
from tests.helpers import create_invite_for_godfather, unique_email
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_enqueues_job_with_user_id(client_factory, mock_enqueue_job):
|
||||
"""Verify that incrementing the counter enqueues a job with the user's ID."""
|
||||
async with client_factory.get_db_session() as db:
|
||||
godfather = await create_user_with_roles(
|
||||
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
|
||||
)
|
||||
invite_code = await create_invite_for_godfather(db, godfather.id)
|
||||
|
||||
reg = await client_factory.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": unique_email(),
|
||||
"password": "testpass123",
|
||||
"invite_identifier": invite_code,
|
||||
},
|
||||
)
|
||||
cookies = dict(reg.cookies)
|
||||
|
||||
# Get user ID from the me endpoint
|
||||
async with client_factory.create(cookies=cookies) as authed:
|
||||
me_response = await authed.get("/api/auth/me")
|
||||
user_id = me_response.json()["id"]
|
||||
|
||||
# Increment counter
|
||||
response = await authed.post("/api/counter/increment")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify enqueue was called with the correct user_id
|
||||
mock_enqueue_job.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
# Protected endpoint tests - without auth
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_counter_requires_auth(client):
|
||||
response = await client.get("/api/counter")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_counter_requires_auth(client):
|
||||
response = await client.post("/api/counter/increment")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_counter_invalid_cookie(client_factory):
|
||||
async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken"}) as authed:
|
||||
response = await authed.get("/api/counter")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_counter_invalid_cookie(client_factory):
|
||||
async with client_factory.create(cookies={COOKIE_NAME: "invalidtoken"}) as authed:
|
||||
response = await authed.post("/api/counter/increment")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# Authenticated counter tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_counter_authenticated(client_factory):
|
||||
async with client_factory.get_db_session() as db:
|
||||
godfather = await create_user_with_roles(
|
||||
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
|
||||
)
|
||||
invite_code = await create_invite_for_godfather(db, godfather.id)
|
||||
|
||||
reg = await client_factory.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": unique_email(),
|
||||
"password": "testpass123",
|
||||
"invite_identifier": invite_code,
|
||||
},
|
||||
)
|
||||
cookies = dict(reg.cookies)
|
||||
|
||||
async with client_factory.create(cookies=cookies) as authed:
|
||||
response = await authed.get("/api/counter")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "value" in response.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_counter(client_factory, mock_enqueue_job):
|
||||
async with client_factory.get_db_session() as db:
|
||||
godfather = await create_user_with_roles(
|
||||
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
|
||||
)
|
||||
invite_code = await create_invite_for_godfather(db, godfather.id)
|
||||
|
||||
reg = await client_factory.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": unique_email(),
|
||||
"password": "testpass123",
|
||||
"invite_identifier": invite_code,
|
||||
},
|
||||
)
|
||||
cookies = dict(reg.cookies)
|
||||
|
||||
async with client_factory.create(cookies=cookies) as authed:
|
||||
# Get current value
|
||||
before = await authed.get("/api/counter")
|
||||
before_value = before.json()["value"]
|
||||
|
||||
# Increment
|
||||
response = await authed.post("/api/counter/increment")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["value"] == before_value + 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_counter_multiple(client_factory, mock_enqueue_job):
|
||||
async with client_factory.get_db_session() as db:
|
||||
godfather = await create_user_with_roles(
|
||||
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
|
||||
)
|
||||
invite_code = await create_invite_for_godfather(db, godfather.id)
|
||||
|
||||
reg = await client_factory.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": unique_email(),
|
||||
"password": "testpass123",
|
||||
"invite_identifier": invite_code,
|
||||
},
|
||||
)
|
||||
cookies = dict(reg.cookies)
|
||||
|
||||
async with client_factory.create(cookies=cookies) as authed:
|
||||
# Get starting value
|
||||
before = await authed.get("/api/counter")
|
||||
start = before.json()["value"]
|
||||
|
||||
# Increment 3 times
|
||||
await authed.post("/api/counter/increment")
|
||||
await authed.post("/api/counter/increment")
|
||||
response = await authed.post("/api/counter/increment")
|
||||
|
||||
assert response.json()["value"] == start + 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_counter_after_increment(client_factory, mock_enqueue_job):
|
||||
async with client_factory.get_db_session() as db:
|
||||
godfather = await create_user_with_roles(
|
||||
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
|
||||
)
|
||||
invite_code = await create_invite_for_godfather(db, godfather.id)
|
||||
|
||||
reg = await client_factory.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": unique_email(),
|
||||
"password": "testpass123",
|
||||
"invite_identifier": invite_code,
|
||||
},
|
||||
)
|
||||
cookies = dict(reg.cookies)
|
||||
|
||||
async with client_factory.create(cookies=cookies) as authed:
|
||||
before = await authed.get("/api/counter")
|
||||
start = before.json()["value"]
|
||||
|
||||
await authed.post("/api/counter/increment")
|
||||
await authed.post("/api/counter/increment")
|
||||
|
||||
response = await authed.get("/api/counter")
|
||||
assert response.json()["value"] == start + 2
|
||||
|
||||
|
||||
# Counter is shared between users
|
||||
@pytest.mark.asyncio
|
||||
async def test_counter_shared_between_users(client_factory, mock_enqueue_job):
|
||||
# Create godfather and invites for two users
|
||||
async with client_factory.get_db_session() as db:
|
||||
godfather = await create_user_with_roles(
|
||||
db, unique_email("gf"), "pass123", [ROLE_REGULAR]
|
||||
)
|
||||
invite1 = await create_invite_for_godfather(db, godfather.id)
|
||||
invite2 = await create_invite_for_godfather(db, godfather.id)
|
||||
|
||||
# Create first user
|
||||
reg1 = await client_factory.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": unique_email("share1"),
|
||||
"password": "testpass123",
|
||||
"invite_identifier": invite1,
|
||||
},
|
||||
)
|
||||
cookies1 = dict(reg1.cookies)
|
||||
|
||||
async with client_factory.create(cookies=cookies1) as user1:
|
||||
# Get starting value
|
||||
before = await user1.get("/api/counter")
|
||||
start = before.json()["value"]
|
||||
|
||||
await user1.post("/api/counter/increment")
|
||||
await user1.post("/api/counter/increment")
|
||||
|
||||
# Create second user - should see the increments
|
||||
reg2 = await client_factory.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"email": unique_email("share2"),
|
||||
"password": "testpass123",
|
||||
"invite_identifier": invite2,
|
||||
},
|
||||
)
|
||||
cookies2 = dict(reg2.cookies)
|
||||
|
||||
async with client_factory.create(cookies=cookies2) as user2:
|
||||
response = await user2.get("/api/counter")
|
||||
assert response.json()["value"] == start + 2
|
||||
|
||||
# Second user increments
|
||||
await user2.post("/api/counter/increment")
|
||||
|
||||
# First user sees the increment
|
||||
async with client_factory.create(cookies=cookies1) as user1:
|
||||
response = await user1.get("/api/counter")
|
||||
assert response.json()["value"] == start + 3
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
"""Tests for job handler logic."""
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from worker import process_random_number_job
|
||||
|
||||
|
||||
def create_mock_pool(mock_conn: AsyncMock) -> MagicMock:
|
||||
"""Create a mock asyncpg pool with proper async context manager behavior."""
|
||||
mock_pool = MagicMock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_acquire():
|
||||
yield mock_conn
|
||||
|
||||
mock_pool.acquire = mock_acquire
|
||||
return mock_pool
|
||||
|
||||
|
||||
class TestRandomNumberJobHandler:
|
||||
"""Tests for the random number job handler logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generates_random_number_in_range(self):
|
||||
"""Verify random number is in range [0, 100]."""
|
||||
# Create mock job
|
||||
job = MagicMock()
|
||||
job.id = 123
|
||||
job.payload = json.dumps({"user_id": 1}).encode()
|
||||
|
||||
# Create mock db pool
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
# Run the job handler
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
# Verify execute was called
|
||||
mock_conn.execute.assert_called_once()
|
||||
call_args = mock_conn.execute.call_args
|
||||
|
||||
# Extract the value argument (position 3 in the args)
|
||||
# Args: (query, job_id, user_id, value, duration_ms, status)
|
||||
value = call_args[0][3]
|
||||
|
||||
assert 0 <= value <= 100, f"Value {value} is not in range [0, 100]"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_correct_user_id(self):
|
||||
"""Verify the correct user_id is stored in the outcome."""
|
||||
user_id = 42
|
||||
|
||||
job = MagicMock()
|
||||
job.id = 123
|
||||
job.payload = json.dumps({"user_id": user_id}).encode()
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
mock_conn.execute.assert_called_once()
|
||||
call_args = mock_conn.execute.call_args
|
||||
|
||||
# Args: (query, job_id, user_id, value, duration_ms, status)
|
||||
stored_user_id = call_args[0][2]
|
||||
assert stored_user_id == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_job_id(self):
|
||||
"""Verify the job_id is stored in the outcome."""
|
||||
job_id = 456
|
||||
|
||||
job = MagicMock()
|
||||
job.id = job_id
|
||||
job.payload = json.dumps({"user_id": 1}).encode()
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
mock_conn.execute.assert_called_once()
|
||||
call_args = mock_conn.execute.call_args
|
||||
|
||||
# Args: (query, job_id, user_id, value, duration_ms, status)
|
||||
stored_job_id = call_args[0][1]
|
||||
assert stored_job_id == job_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_status_completed(self):
|
||||
"""Verify the status is set to 'completed'."""
|
||||
job = MagicMock()
|
||||
job.id = 123
|
||||
job.payload = json.dumps({"user_id": 1}).encode()
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
mock_conn.execute.assert_called_once()
|
||||
call_args = mock_conn.execute.call_args
|
||||
|
||||
# Args: (query, job_id, user_id, value, duration_ms, status)
|
||||
status = call_args[0][5]
|
||||
assert status == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_records_duration_ms(self):
|
||||
"""Verify duration_ms is recorded (should be >= 0)."""
|
||||
job = MagicMock()
|
||||
job.id = 123
|
||||
job.payload = json.dumps({"user_id": 1}).encode()
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
mock_conn.execute.assert_called_once()
|
||||
call_args = mock_conn.execute.call_args
|
||||
|
||||
# Args: (query, job_id, user_id, value, duration_ms, status)
|
||||
duration_ms = call_args[0][4]
|
||||
assert isinstance(duration_ms, int)
|
||||
assert duration_ms >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_user_id_does_not_insert(self):
|
||||
"""Verify no insert happens if user_id is missing from payload."""
|
||||
job = MagicMock()
|
||||
job.id = 123
|
||||
job.payload = json.dumps({}).encode() # Missing user_id
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
# Should not have called execute
|
||||
mock_conn.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_payload_does_not_insert(self):
|
||||
"""Verify no insert happens with empty payload."""
|
||||
job = MagicMock()
|
||||
job.id = 123
|
||||
job.payload = None
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
# Should not have called execute
|
||||
mock_conn.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_json_payload_does_not_insert(self):
|
||||
"""Verify no insert happens with malformed JSON payload."""
|
||||
job = MagicMock()
|
||||
job.id = 123
|
||||
job.payload = b"not valid json {"
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
await process_random_number_job(job, mock_pool)
|
||||
|
||||
# Should not have called execute
|
||||
mock_conn.execute.assert_not_called()
|
||||
|
|
@ -50,10 +50,10 @@ class TestRoleAssignment:
|
|||
data = response.json()
|
||||
permissions = data["permissions"]
|
||||
|
||||
# Should have counter and sum permissions
|
||||
assert Permission.VIEW_COUNTER.value in permissions
|
||||
assert Permission.INCREMENT_COUNTER.value in permissions
|
||||
assert Permission.USE_SUM.value in permissions
|
||||
# Should have profile and booking permissions
|
||||
assert Permission.MANAGE_OWN_PROFILE.value in permissions
|
||||
assert Permission.BOOK_APPOINTMENT.value in permissions
|
||||
assert Permission.VIEW_OWN_APPOINTMENTS.value in permissions
|
||||
|
||||
# Should NOT have audit permission
|
||||
assert Permission.VIEW_AUDIT.value not in permissions
|
||||
|
|
@ -69,10 +69,8 @@ class TestRoleAssignment:
|
|||
# Should have audit permission
|
||||
assert Permission.VIEW_AUDIT.value in permissions
|
||||
|
||||
# Should NOT have counter/sum permissions
|
||||
assert Permission.VIEW_COUNTER.value not in permissions
|
||||
assert Permission.INCREMENT_COUNTER.value not in permissions
|
||||
assert Permission.USE_SUM.value not in permissions
|
||||
# Should NOT have booking permissions (those are for regular users)
|
||||
assert Permission.BOOK_APPOINTMENT.value not in permissions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_with_no_roles_has_no_permissions(
|
||||
|
|
@ -86,124 +84,6 @@ class TestRoleAssignment:
|
|||
assert data["permissions"] == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Counter Endpoint Access Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCounterAccess:
|
||||
"""Test access control for counter endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_can_view_counter(self, client_factory, regular_user):
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/counter")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "value" in response.json()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_can_increment_counter(
|
||||
self, client_factory, regular_user, mock_enqueue_job
|
||||
):
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post("/api/counter/increment")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "value" in response.json()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_view_counter(self, client_factory, admin_user):
|
||||
"""Admin users should be forbidden from counter endpoints."""
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/counter")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "permission" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_increment_counter(self, client_factory, admin_user):
|
||||
"""Admin users should be forbidden from incrementing counter."""
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.post("/api/counter/increment")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_without_roles_cannot_view_counter(
|
||||
self, client_factory, user_no_roles
|
||||
):
|
||||
"""Users with no roles should be forbidden."""
|
||||
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
|
||||
response = await client.get("/api/counter")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_view_counter(self, client):
|
||||
"""Unauthenticated requests should get 401."""
|
||||
response = await client.get("/api/counter")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_increment_counter(self, client):
|
||||
"""Unauthenticated requests should get 401."""
|
||||
response = await client.post("/api/counter/increment")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sum Endpoint Access Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSumAccess:
|
||||
"""Test access control for sum endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_can_use_sum(self, client_factory, regular_user):
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/sum",
|
||||
json={"a": 5, "b": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["result"] == 8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_use_sum(self, client_factory, admin_user):
|
||||
"""Admin users should be forbidden from sum endpoint."""
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/sum",
|
||||
json={"a": 5, "b": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_without_roles_cannot_use_sum(
|
||||
self, client_factory, user_no_roles
|
||||
):
|
||||
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
|
||||
response = await client.post(
|
||||
"/api/sum",
|
||||
json={"a": 5, "b": 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_use_sum(self, client):
|
||||
response = await client.post(
|
||||
"/api/sum",
|
||||
json={"a": 5, "b": 3},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audit Endpoint Access Tests
|
||||
# =============================================================================
|
||||
|
|
@ -213,89 +93,37 @@ class TestAuditAccess:
|
|||
"""Test access control for audit endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_view_counter_audit(self, client_factory, admin_user):
|
||||
async def test_admin_can_view_price_history(self, client_factory, admin_user):
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/counter")
|
||||
response = await client.get("/api/audit/price-history")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "records" in data
|
||||
assert "total" in data
|
||||
# Returns a list
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_view_sum_audit(self, client_factory, admin_user):
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/sum")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "records" in data
|
||||
assert "total" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_cannot_view_counter_audit(
|
||||
async def test_regular_user_cannot_view_price_history(
|
||||
self, client_factory, regular_user
|
||||
):
|
||||
"""Regular users should be forbidden from audit endpoints."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/counter")
|
||||
response = await client.get("/api/audit/price-history")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "permission" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_cannot_view_sum_audit(
|
||||
self, client_factory, regular_user
|
||||
):
|
||||
"""Regular users should be forbidden from audit endpoints."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/sum")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_without_roles_cannot_view_audit(
|
||||
self, client_factory, user_no_roles
|
||||
):
|
||||
async with client_factory.create(cookies=user_no_roles["cookies"]) as client:
|
||||
response = await client.get("/api/audit/counter")
|
||||
response = await client.get("/api/audit/price-history")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_view_counter_audit(self, client):
|
||||
response = await client.get("/api/audit/counter")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_view_sum_audit(self, client):
|
||||
response = await client.get("/api/audit/sum")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_view_random_jobs(self, client_factory, admin_user):
|
||||
"""Admin should be able to view random job outcomes."""
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/random-jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
# Returns a list (no pagination)
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_cannot_view_random_jobs(
|
||||
self, client_factory, regular_user
|
||||
):
|
||||
"""Regular users should be forbidden from random-jobs endpoint."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/random-jobs")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_view_random_jobs(self, client):
|
||||
"""Unauthenticated users should get 401."""
|
||||
response = await client.get("/api/audit/random-jobs")
|
||||
async def test_unauthenticated_cannot_view_price_history(self, client):
|
||||
response = await client.get("/api/audit/price-history")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
|
|
@ -320,18 +148,18 @@ class TestSecurityBypassAttempts:
|
|||
"""
|
||||
# Regular user tries to access audit endpoint
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/counter")
|
||||
response = await client.get("/api/audit/price-history")
|
||||
|
||||
# Should be denied regardless of any manipulation attempts
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_access_counter_with_expired_session(self, client_factory):
|
||||
async def test_cannot_access_with_expired_session(self, client_factory):
|
||||
"""Test that invalid/expired tokens are rejected."""
|
||||
fake_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5OTk5IiwiZXhwIjoxfQ.invalid"
|
||||
|
||||
async with client_factory.create(cookies={"auth_token": fake_token}) as client:
|
||||
response = await client.get("/api/counter")
|
||||
response = await client.get("/api/profile")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
|
@ -348,7 +176,7 @@ class TestSecurityBypassAttempts:
|
|||
async with client_factory.create(
|
||||
cookies={"auth_token": tampered_token}
|
||||
) as client:
|
||||
response = await client.get("/api/counter")
|
||||
response = await client.get("/api/profile")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
|
@ -386,7 +214,7 @@ class TestSecurityBypassAttempts:
|
|||
|
||||
# Try to access audit with this new user
|
||||
async with client_factory.create(cookies=dict(response.cookies)) as client:
|
||||
audit_response = await client.get("/api/audit/counter")
|
||||
audit_response = await client.get("/api/audit/price-history")
|
||||
|
||||
assert audit_response.status_code == 403
|
||||
|
||||
|
|
@ -452,10 +280,10 @@ class TestSecurityBypassAttempts:
|
|||
)
|
||||
cookies = dict(login_response.cookies)
|
||||
|
||||
# Verify can access counter but not audit
|
||||
# Verify can access profile but not audit
|
||||
async with client_factory.create(cookies=cookies) as client:
|
||||
assert (await client.get("/api/counter")).status_code == 200
|
||||
assert (await client.get("/api/audit/counter")).status_code == 403
|
||||
assert (await client.get("/api/profile")).status_code == 200
|
||||
assert (await client.get("/api/audit/price-history")).status_code == 403
|
||||
|
||||
# Change user's role from regular to admin
|
||||
async with client_factory.get_db_session() as db:
|
||||
|
|
@ -468,62 +296,7 @@ class TestSecurityBypassAttempts:
|
|||
user.roles = [admin_role] # Replace roles with admin only
|
||||
await db.commit()
|
||||
|
||||
# Now should have audit access but not counter access
|
||||
# Now should have audit access but not profile access (admin doesn't have MANAGE_OWN_PROFILE)
|
||||
async with client_factory.create(cookies=cookies) as client:
|
||||
assert (await client.get("/api/audit/counter")).status_code == 200
|
||||
assert (await client.get("/api/counter")).status_code == 403
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Audit Record Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAuditRecords:
|
||||
"""Test that actions are properly recorded in audit logs."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counter_increment_creates_audit_record(
|
||||
self, client_factory, regular_user, admin_user, mock_enqueue_job
|
||||
):
|
||||
"""Verify that counter increments are recorded and visible in audit."""
|
||||
# Regular user increments counter
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
await client.post("/api/counter/increment")
|
||||
|
||||
# Admin checks audit
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/counter")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
# Find record for our user
|
||||
records = data["records"]
|
||||
user_records = [r for r in records if r["user_email"] == regular_user["email"]]
|
||||
assert len(user_records) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sum_operation_creates_audit_record(
|
||||
self, client_factory, regular_user, admin_user
|
||||
):
|
||||
"""Verify that sum operations are recorded and visible in audit."""
|
||||
# Regular user uses sum
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
await client.post("/api/sum", json={"a": 10, "b": 20})
|
||||
|
||||
# Admin checks audit
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/audit/sum")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
# Find record with our values
|
||||
records = data["records"]
|
||||
matching = [
|
||||
r for r in records if r["a"] == 10 and r["b"] == 20 and r["result"] == 30
|
||||
]
|
||||
assert len(matching) >= 1
|
||||
assert (await client.get("/api/audit/price-history")).status_code == 200
|
||||
assert (await client.get("/api/profile")).status_code == 403
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Tests for price history feature."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -9,7 +8,6 @@ import pytest
|
|||
|
||||
from models import PriceHistory
|
||||
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||
from worker import process_bitcoin_price_job
|
||||
|
||||
|
||||
def create_mock_httpx_client(
|
||||
|
|
@ -293,76 +291,3 @@ class TestManualFetch:
|
|||
data = response.json()
|
||||
assert data["id"] == existing_id
|
||||
assert data["price"] == 90000.0 # Original price, not the new one
|
||||
|
||||
|
||||
def create_mock_pool(mock_conn: AsyncMock) -> MagicMock:
|
||||
"""Create a mock asyncpg pool with proper async context manager behavior."""
|
||||
mock_pool = MagicMock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_acquire():
|
||||
yield mock_conn
|
||||
|
||||
mock_pool.acquire = mock_acquire
|
||||
return mock_pool
|
||||
|
||||
|
||||
class TestProcessBitcoinPriceJob:
|
||||
"""Tests for the scheduled Bitcoin price job handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_price_on_success(self):
|
||||
"""Verify price is stored in database on successful fetch."""
|
||||
mock_http_client = create_mock_httpx_client(
|
||||
json_response=create_bitfinex_ticker_response(95000.0)
|
||||
)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
|
||||
await process_bitcoin_price_job(mock_pool)
|
||||
|
||||
# Verify execute was called with correct values
|
||||
mock_conn.execute.assert_called_once()
|
||||
call_args = mock_conn.execute.call_args
|
||||
|
||||
# Check the SQL parameters
|
||||
assert call_args[0][1] == SOURCE_BITFINEX # source
|
||||
assert call_args[0][2] == PAIR_BTC_EUR # pair
|
||||
assert call_args[0][3] == 95000.0 # price
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fails_silently_on_api_error(self):
|
||||
"""Verify no exception is raised and no DB insert on API error."""
|
||||
import httpx
|
||||
|
||||
error = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=MagicMock()
|
||||
)
|
||||
mock_http_client = create_mock_httpx_client(raise_for_status_error=error)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
|
||||
# Should not raise an exception
|
||||
await process_bitcoin_price_job(mock_pool)
|
||||
|
||||
# Should not have called execute
|
||||
mock_conn.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fails_silently_on_db_error(self):
|
||||
"""Verify no exception is raised on database error."""
|
||||
mock_http_client = create_mock_httpx_client(
|
||||
json_response=create_bitfinex_ticker_response(95000.0)
|
||||
)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute.side_effect = Exception("Database connection error")
|
||||
mock_pool = create_mock_pool(mock_conn)
|
||||
|
||||
with patch("price_fetcher.httpx.AsyncClient", return_value=mock_http_client):
|
||||
# Should not raise an exception
|
||||
await process_bitcoin_price_job(mock_pool)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue