add CLI enhancements and scheduled cleanup

CLI commands:
- list: show user's pastes with pagination
- search: filter by type (glob), after/before timestamps
- update: modify content, password, or extend expiry
- export: save pastes to directory with optional decryption

API changes:
- PUT /<id>: update paste content and metadata
- GET /pastes: add type, after, before query params

Scheduled tasks:
- Thread-safe cleanup with per-task intervals
- Activate cleanup_expired_hashes (15min)
- Activate cleanup_rate_limits (5min)

Tests: 205 passing
This commit is contained in:
Username
2025-12-20 20:13:00 +01:00
parent cf31eab678
commit bfc238b5cf
9 changed files with 1826 additions and 18 deletions

View File

@@ -2,14 +2,37 @@
import pytest
import app.database as db_module
from app import create_app
from app.api.routes import reset_rate_limits
def _clear_database():
"""Clear all data from database tables for test isolation."""
if db_module._memory_db_holder is not None:
db_module._memory_db_holder.execute("DELETE FROM pastes")
db_module._memory_db_holder.execute("DELETE FROM content_hashes")
db_module._memory_db_holder.execute("DELETE FROM issued_certificates")
db_module._memory_db_holder.execute("DELETE FROM certificate_authority")
db_module._memory_db_holder.commit()
@pytest.fixture
def app():
"""Create application for testing."""
app = create_app("testing")
yield app
# Reset global state for test isolation
reset_rate_limits()
_clear_database()
test_app = create_app("testing")
# Clear database again after app init (in case init added anything)
_clear_database()
yield test_app
# Cleanup after test
reset_rate_limits()
@pytest.fixture

335
tests/test_paste_listing.py Normal file
View File

@@ -0,0 +1,335 @@
"""Tests for paste listing endpoint (GET /pastes)."""
import json
class TestPastesListEndpoint:
"""Tests for GET /pastes endpoint."""
def test_list_pastes_requires_auth(self, client):
"""List pastes requires authentication."""
response = client.get("/pastes")
assert response.status_code == 401
data = json.loads(response.data)
assert "error" in data
def test_list_pastes_empty(self, client, auth_header):
"""List pastes returns empty when user has no pastes."""
response = client.get("/pastes", headers=auth_header)
assert response.status_code == 200
data = json.loads(response.data)
assert data["pastes"] == []
assert data["count"] == 0
assert data["total"] == 0
def test_list_pastes_returns_own_pastes(self, client, sample_text, auth_header):
"""List pastes returns only user's own pastes."""
# Create a paste
create = client.post(
"/",
data=sample_text,
content_type="text/plain",
headers=auth_header,
)
paste_id = json.loads(create.data)["id"]
# List pastes
response = client.get("/pastes", headers=auth_header)
assert response.status_code == 200
data = json.loads(response.data)
assert data["count"] == 1
assert data["total"] == 1
assert data["pastes"][0]["id"] == paste_id
def test_list_pastes_excludes_others(self, client, sample_text, auth_header, other_auth_header):
"""List pastes does not include other users' pastes."""
# Create paste as user A
client.post(
"/",
data=sample_text,
content_type="text/plain",
headers=auth_header,
)
# List pastes as user B
response = client.get("/pastes", headers=other_auth_header)
assert response.status_code == 200
data = json.loads(response.data)
assert data["count"] == 0
assert data["total"] == 0
def test_list_pastes_excludes_anonymous(self, client, sample_text, auth_header):
"""List pastes does not include anonymous pastes."""
# Create anonymous paste
client.post("/", data=sample_text, content_type="text/plain")
# List pastes as authenticated user
response = client.get("/pastes", headers=auth_header)
assert response.status_code == 200
data = json.loads(response.data)
assert data["count"] == 0
def test_list_pastes_metadata_only(self, client, sample_text, auth_header):
"""List pastes returns metadata, not content."""
# Create a paste
client.post(
"/",
data=sample_text,
content_type="text/plain",
headers=auth_header,
)
# List pastes
response = client.get("/pastes", headers=auth_header)
data = json.loads(response.data)
paste = data["pastes"][0]
# Verify metadata fields
assert "id" in paste
assert "mime_type" in paste
assert "size" in paste
assert "created_at" in paste
assert "last_accessed" in paste
assert "url" in paste
assert "raw" in paste
# Verify content is NOT included
assert "content" not in paste
def test_list_pastes_pagination(self, client, auth_header):
"""List pastes supports pagination."""
# Create multiple pastes
for i in range(5):
client.post(
"/",
data=f"paste {i}",
content_type="text/plain",
headers=auth_header,
)
# Get first page
response = client.get("/pastes?limit=2&offset=0", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 2
assert data["total"] == 5
assert data["limit"] == 2
assert data["offset"] == 0
# Get second page
response = client.get("/pastes?limit=2&offset=2", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 2
assert data["offset"] == 2
def test_list_pastes_max_limit(self, client, auth_header):
"""List pastes enforces maximum limit."""
response = client.get("/pastes?limit=500", headers=auth_header)
data = json.loads(response.data)
assert data["limit"] == 200 # Max limit enforced
def test_list_pastes_invalid_pagination(self, client, auth_header):
"""List pastes handles invalid pagination gracefully."""
response = client.get("/pastes?limit=abc&offset=-1", headers=auth_header)
assert response.status_code == 200
data = json.loads(response.data)
# Should use defaults
assert data["limit"] == 50
assert data["offset"] == 0
def test_list_pastes_includes_special_fields(self, client, auth_header):
"""List pastes includes burn_after_read, expires_at, password_protected."""
# Create paste with burn-after-read
client.post(
"/",
data="burn test",
content_type="text/plain",
headers={**auth_header, "X-Burn-After-Read": "true"},
)
response = client.get("/pastes", headers=auth_header)
data = json.loads(response.data)
paste = data["pastes"][0]
assert paste.get("burn_after_read") is True
def test_list_pastes_ordered_by_created_at(self, client, auth_header):
"""List pastes returns all created pastes ordered by created_at DESC."""
# Create pastes
ids = set()
for i in range(3):
create = client.post(
"/",
data=f"paste {i}",
content_type="text/plain",
headers=auth_header,
)
ids.add(json.loads(create.data)["id"])
response = client.get("/pastes", headers=auth_header)
data = json.loads(response.data)
# All created pastes should be present
returned_ids = {p["id"] for p in data["pastes"]}
assert returned_ids == ids
assert data["count"] == 3
class TestPastesPrivacy:
"""Privacy-focused tests for paste listing."""
def test_cannot_see_other_user_pastes(
self, client, sample_text, auth_header, other_auth_header
):
"""Users cannot see pastes owned by others."""
# User A creates paste
create = client.post(
"/",
data=sample_text,
content_type="text/plain",
headers=auth_header,
)
paste_id = json.loads(create.data)["id"]
# User B lists pastes - should not see A's paste
response = client.get("/pastes", headers=other_auth_header)
data = json.loads(response.data)
paste_ids = [p["id"] for p in data["pastes"]]
assert paste_id not in paste_ids
def test_no_admin_bypass(self, client, sample_text, auth_header):
"""No special admin access to list all pastes."""
# Create paste as regular user
client.post(
"/",
data=sample_text,
content_type="text/plain",
headers=auth_header,
)
# Different user cannot see it, regardless of auth header format
admin_header = {"X-SSL-Client-SHA1": "0" * 40}
response = client.get("/pastes", headers=admin_header)
data = json.loads(response.data)
assert data["count"] == 0
def test_content_never_exposed(self, client, auth_header):
"""Paste content is never exposed in listing."""
secret = "super secret content that should never be exposed"
client.post(
"/",
data=secret,
content_type="text/plain",
headers=auth_header,
)
response = client.get("/pastes", headers=auth_header)
# Content should not appear anywhere in response
assert secret.encode() not in response.data
class TestPastesSearch:
"""Tests for paste search parameters."""
def test_search_by_type_exact(self, client, auth_header, png_bytes):
"""Search pastes by exact MIME type."""
# Create text paste
client.post("/", data="text content", content_type="text/plain", headers=auth_header)
# Create image paste
create = client.post("/", data=png_bytes, content_type="image/png", headers=auth_header)
png_id = json.loads(create.data)["id"]
# Search for image/png
response = client.get("/pastes?type=image/png", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 1
assert data["pastes"][0]["id"] == png_id
def test_search_by_type_glob(self, client, auth_header, png_bytes, jpeg_bytes):
"""Search pastes by MIME type glob pattern."""
# Create text paste
client.post("/", data="text content", content_type="text/plain", headers=auth_header)
# Create image pastes
client.post("/", data=png_bytes, content_type="image/png", headers=auth_header)
client.post("/", data=jpeg_bytes, content_type="image/jpeg", headers=auth_header)
# Search for all images
response = client.get("/pastes?type=image/*", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 2
for paste in data["pastes"]:
assert paste["mime_type"].startswith("image/")
def test_search_by_after_timestamp(self, client, auth_header):
"""Search pastes created after timestamp."""
# Create paste
create = client.post("/", data="test", content_type="text/plain", headers=auth_header)
paste_data = json.loads(create.data)
created_at = paste_data["created_at"]
# Search for pastes after creation time (should find it)
response = client.get(f"/pastes?after={created_at - 1}", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 1
# Search for pastes after creation time + 1 (should not find it)
response = client.get(f"/pastes?after={created_at + 1}", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 0
def test_search_by_before_timestamp(self, client, auth_header):
"""Search pastes created before timestamp."""
# Create paste
create = client.post("/", data="test", content_type="text/plain", headers=auth_header)
paste_data = json.loads(create.data)
created_at = paste_data["created_at"]
# Search for pastes before creation time + 1 (should find it)
response = client.get(f"/pastes?before={created_at + 1}", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 1
# Search for pastes before creation time (should not find it)
response = client.get(f"/pastes?before={created_at - 1}", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 0
def test_search_combined_filters(self, client, auth_header, png_bytes):
"""Search with multiple filters combined."""
# Create text paste
client.post("/", data="text", content_type="text/plain", headers=auth_header)
# Create image paste
create = client.post("/", data=png_bytes, content_type="image/png", headers=auth_header)
png_data = json.loads(create.data)
created_at = png_data["created_at"]
# Search for images after a certain time
response = client.get(
f"/pastes?type=image/*&after={created_at - 1}",
headers=auth_header,
)
data = json.loads(response.data)
assert data["count"] == 1
assert data["pastes"][0]["mime_type"] == "image/png"
def test_search_no_matches(self, client, auth_header):
"""Search with no matching results."""
# Create text paste
client.post("/", data="text", content_type="text/plain", headers=auth_header)
# Search for video (no matches)
response = client.get("/pastes?type=video/*", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 0
assert data["pastes"] == []
def test_search_invalid_timestamp(self, client, auth_header):
"""Search with invalid timestamp uses default."""
client.post("/", data="test", content_type="text/plain", headers=auth_header)
# Invalid timestamp should be ignored
response = client.get("/pastes?after=invalid", headers=auth_header)
assert response.status_code == 200
data = json.loads(response.data)
assert data["count"] == 1

242
tests/test_paste_update.py Normal file
View File

@@ -0,0 +1,242 @@
"""Tests for paste update endpoint (PUT /<id>)."""
import json
class TestPasteUpdateEndpoint:
"""Tests for PUT /<id> endpoint."""
def test_update_requires_auth(self, client, sample_text, auth_header):
"""Update requires authentication."""
# Create paste
create = client.post("/", data=sample_text, content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Try to update without auth
response = client.put(f"/{paste_id}", data="updated")
assert response.status_code == 401
def test_update_requires_ownership(self, client, sample_text, auth_header, other_auth_header):
"""Update requires paste ownership."""
# Create paste as user A
create = client.post("/", data=sample_text, content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Try to update as user B
response = client.put(
f"/{paste_id}",
data="updated content",
content_type="text/plain",
headers=other_auth_header,
)
assert response.status_code == 403
def test_update_content(self, client, auth_header):
"""Update paste content."""
# Create paste
create = client.post("/", data="original", content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Update content
response = client.put(
f"/{paste_id}",
data="updated content",
content_type="text/plain",
headers=auth_header,
)
assert response.status_code == 200
data = json.loads(response.data)
assert data["size"] == len("updated content")
# Verify content changed
raw = client.get(f"/{paste_id}/raw")
assert raw.data == b"updated content"
def test_update_password_set(self, client, auth_header):
"""Set password on paste."""
# Create paste without password
create = client.post("/", data="content", content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Add password
response = client.put(
f"/{paste_id}",
data="",
headers={**auth_header, "X-Paste-Password": "secret123"},
)
assert response.status_code == 200
data = json.loads(response.data)
assert data.get("password_protected") is True
# Verify password required
raw = client.get(f"/{paste_id}/raw")
assert raw.status_code == 401
# Verify correct password works
raw = client.get(f"/{paste_id}/raw", headers={"X-Paste-Password": "secret123"})
assert raw.status_code == 200
def test_update_password_remove(self, client, auth_header):
"""Remove password from paste."""
# Create paste with password
create = client.post(
"/",
data="content",
content_type="text/plain",
headers={**auth_header, "X-Paste-Password": "secret123"},
)
paste_id = json.loads(create.data)["id"]
# Remove password
response = client.put(
f"/{paste_id}",
data="",
headers={**auth_header, "X-Remove-Password": "true"},
)
assert response.status_code == 200
data = json.loads(response.data)
assert data.get("password_protected") is not True
# Verify no password required
raw = client.get(f"/{paste_id}/raw")
assert raw.status_code == 200
def test_update_extend_expiry(self, client, auth_header):
"""Extend paste expiry."""
# Create paste with expiry
create = client.post(
"/",
data="content",
content_type="text/plain",
headers={**auth_header, "X-Expiry": "3600"},
)
paste_id = json.loads(create.data)["id"]
original_expiry = json.loads(create.data)["expires_at"]
# Extend expiry
response = client.put(
f"/{paste_id}",
data="",
headers={**auth_header, "X-Extend-Expiry": "7200"},
)
assert response.status_code == 200
data = json.loads(response.data)
assert data["expires_at"] == original_expiry + 7200
def test_update_add_expiry(self, client, auth_header):
"""Add expiry to paste without one."""
# Create paste without expiry
create = client.post("/", data="content", content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Add expiry
response = client.put(
f"/{paste_id}",
data="",
headers={**auth_header, "X-Extend-Expiry": "3600"},
)
assert response.status_code == 200
data = json.loads(response.data)
assert "expires_at" in data
def test_update_burn_after_read_forbidden(self, client, auth_header):
"""Cannot update burn-after-read pastes."""
# Create burn-after-read paste
create = client.post(
"/",
data="content",
content_type="text/plain",
headers={**auth_header, "X-Burn-After-Read": "true"},
)
paste_id = json.loads(create.data)["id"]
# Try to update
response = client.put(
f"/{paste_id}",
data="updated",
content_type="text/plain",
headers=auth_header,
)
assert response.status_code == 400
data = json.loads(response.data)
assert "burn" in data["error"].lower()
def test_update_not_found(self, client, auth_header):
"""Update non-existent paste returns 404."""
response = client.put(
"/000000000000",
data="updated",
content_type="text/plain",
headers=auth_header,
)
assert response.status_code == 404
def test_update_no_changes(self, client, auth_header):
"""Update with no changes returns error."""
# Create paste
create = client.post("/", data="content", content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Try to update with empty request
response = client.put(f"/{paste_id}", data="", headers=auth_header)
assert response.status_code == 400
data = json.loads(response.data)
assert "no updates" in data["error"].lower()
def test_update_combined(self, client, auth_header):
"""Update content and metadata together."""
# Create paste
create = client.post("/", data="original", content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Update content and add password
response = client.put(
f"/{paste_id}",
data="new content",
content_type="text/plain",
headers={**auth_header, "X-Paste-Password": "secret"},
)
assert response.status_code == 200
data = json.loads(response.data)
assert data["size"] == len("new content")
assert data.get("password_protected") is True
class TestPasteUpdatePrivacy:
"""Privacy-focused tests for paste update."""
def test_cannot_update_anonymous_paste(self, client, sample_text, auth_header):
"""Cannot update paste without owner."""
# Create anonymous paste
create = client.post("/", data=sample_text, content_type="text/plain")
paste_id = json.loads(create.data)["id"]
# Try to update
response = client.put(
f"/{paste_id}",
data="updated",
content_type="text/plain",
headers=auth_header,
)
assert response.status_code == 403
def test_cannot_update_other_user_paste(
self, client, sample_text, auth_header, other_auth_header
):
"""Cannot update paste owned by another user."""
# Create paste as user A
create = client.post("/", data=sample_text, content_type="text/plain", headers=auth_header)
paste_id = json.loads(create.data)["id"]
# Try to update as user B
response = client.put(
f"/{paste_id}",
data="hijacked",
content_type="text/plain",
headers=other_auth_header,
)
assert response.status_code == 403
# Verify original content unchanged
raw = client.get(f"/{paste_id}/raw")
assert raw.data == sample_text.encode()

168
tests/test_rate_limiting.py Normal file
View File

@@ -0,0 +1,168 @@
"""Tests for IP-based rate limiting."""
import json
class TestRateLimiting:
"""Tests for rate limiting on paste creation."""
def test_rate_limit_allows_normal_usage(self, client, sample_text):
"""Normal usage within rate limit succeeds."""
# TestingConfig has RATE_LIMIT_MAX=100
for i in range(5):
response = client.post(
"/",
data=f"paste {i}",
content_type="text/plain",
)
assert response.status_code == 201
def test_rate_limit_exceeded_returns_429(self, client, app):
"""Exceeding rate limit returns 429."""
# Temporarily lower rate limit for test
original_max = app.config["RATE_LIMIT_MAX"]
app.config["RATE_LIMIT_MAX"] = 3
try:
# Make requests up to limit
for i in range(3):
response = client.post(
"/",
data=f"paste {i}",
content_type="text/plain",
)
assert response.status_code == 201
# Next request should be rate limited
response = client.post(
"/",
data="one more",
content_type="text/plain",
)
assert response.status_code == 429
data = json.loads(response.data)
assert "error" in data
assert "Rate limit" in data["error"]
assert "retry_after" in data
finally:
app.config["RATE_LIMIT_MAX"] = original_max
def test_rate_limit_headers(self, client, app):
"""Rate limit response includes proper headers."""
original_max = app.config["RATE_LIMIT_MAX"]
app.config["RATE_LIMIT_MAX"] = 1
try:
# First request succeeds
client.post("/", data="first", content_type="text/plain")
# Second request is rate limited
response = client.post("/", data="second", content_type="text/plain")
assert response.status_code == 429
assert "Retry-After" in response.headers
assert "X-RateLimit-Remaining" in response.headers
assert response.headers["X-RateLimit-Remaining"] == "0"
finally:
app.config["RATE_LIMIT_MAX"] = original_max
def test_rate_limit_auth_multiplier(self, client, app, auth_header):
"""Authenticated users get higher rate limits."""
original_max = app.config["RATE_LIMIT_MAX"]
original_mult = app.config["RATE_LIMIT_AUTH_MULTIPLIER"]
app.config["RATE_LIMIT_MAX"] = 2
app.config["RATE_LIMIT_AUTH_MULTIPLIER"] = 3 # 2 * 3 = 6 for auth users
try:
# Authenticated user can make more requests than base limit
for i in range(5):
response = client.post(
"/",
data=f"auth {i}",
content_type="text/plain",
headers=auth_header,
)
assert response.status_code == 201
# 6th request should succeed (limit is 2*3=6)
response = client.post(
"/",
data="auth 6",
content_type="text/plain",
headers=auth_header,
)
assert response.status_code == 201
# 7th should fail
response = client.post(
"/",
data="auth 7",
content_type="text/plain",
headers=auth_header,
)
assert response.status_code == 429
finally:
app.config["RATE_LIMIT_MAX"] = original_max
app.config["RATE_LIMIT_AUTH_MULTIPLIER"] = original_mult
def test_rate_limit_can_be_disabled(self, client, app):
"""Rate limiting can be disabled via config."""
original_enabled = app.config["RATE_LIMIT_ENABLED"]
original_max = app.config["RATE_LIMIT_MAX"]
app.config["RATE_LIMIT_ENABLED"] = False
app.config["RATE_LIMIT_MAX"] = 1
try:
# Should be able to make many requests
for i in range(5):
response = client.post(
"/",
data=f"paste {i}",
content_type="text/plain",
)
assert response.status_code == 201
finally:
app.config["RATE_LIMIT_ENABLED"] = original_enabled
app.config["RATE_LIMIT_MAX"] = original_max
def test_rate_limit_only_affects_paste_creation(self, client, app, sample_text):
"""Rate limiting only affects POST /, not GET endpoints."""
original_max = app.config["RATE_LIMIT_MAX"]
app.config["RATE_LIMIT_MAX"] = 2
try:
# Create paste
create = client.post("/", data=sample_text, content_type="text/plain")
assert create.status_code == 201
paste_id = json.loads(create.data)["id"]
# Use up rate limit
client.post("/", data="second", content_type="text/plain")
# Should be rate limited for creation
response = client.post("/", data="third", content_type="text/plain")
assert response.status_code == 429
# But GET should still work
response = client.get(f"/{paste_id}")
assert response.status_code == 200
response = client.get(f"/{paste_id}/raw")
assert response.status_code == 200
response = client.get("/health")
assert response.status_code == 200
finally:
app.config["RATE_LIMIT_MAX"] = original_max
class TestRateLimitCleanup:
"""Tests for rate limit cleanup."""
def test_rate_limit_window_expiry(self, app):
"""Rate limit cleanup works with explicit window."""
from app.api.routes import cleanup_rate_limits
# Should work without app context when window is explicit
cleaned = cleanup_rate_limits(window=60)
assert isinstance(cleaned, int)
assert cleaned >= 0

View File

@@ -0,0 +1,169 @@
"""Tests for scheduled cleanup functionality."""
import time
class TestScheduledCleanup:
"""Tests for scheduled cleanup in before_request hook."""
def test_cleanup_times_reset(self, app, client):
"""Verify cleanup times can be reset for testing."""
from app.api import _cleanup_times, reset_cleanup_times
# Set some cleanup times
with app.app_context():
reset_cleanup_times()
for key in _cleanup_times:
assert _cleanup_times[key] == 0
def test_cleanup_runs_on_request(self, app, client, auth_header):
"""Verify cleanup runs during request handling."""
from app.api import _cleanup_times, reset_cleanup_times
with app.app_context():
reset_cleanup_times()
# Make a request to trigger cleanup
client.get("/health")
# Cleanup times should be set
with app.app_context():
for key in _cleanup_times:
assert _cleanup_times[key] > 0
def test_cleanup_respects_intervals(self, app, client):
"""Verify cleanup respects configured intervals."""
from app.api import _cleanup_times, reset_cleanup_times
with app.app_context():
reset_cleanup_times()
# First request triggers cleanup
client.get("/health")
with app.app_context():
first_times = {k: v for k, v in _cleanup_times.items()}
# Immediate second request should not reset times
client.get("/health")
with app.app_context():
for key in _cleanup_times:
assert _cleanup_times[key] == first_times[key]
def test_expired_paste_cleanup(self, app, client, auth_header):
"""Test that expired pastes are cleaned up."""
import json
from app.database import get_db
# Create paste with short expiry
response = client.post(
"/",
data="test content",
content_type="text/plain",
headers={**auth_header, "X-Expiry": "1"}, # 1 second
)
assert response.status_code == 201
paste_id = json.loads(response.data)["id"]
# Wait for expiry
time.sleep(1.5)
# Trigger cleanup via database function
with app.app_context():
from app.database import cleanup_expired_pastes
count = cleanup_expired_pastes()
assert count >= 1
# Verify paste is gone
db = get_db()
row = db.execute("SELECT id FROM pastes WHERE id = ?", (paste_id,)).fetchone()
assert row is None
def test_rate_limit_cleanup(self, app, client):
"""Test that rate limit entries are cleaned up."""
from app.api.routes import (
_rate_limit_requests,
check_rate_limit,
cleanup_rate_limits,
reset_rate_limits,
)
with app.app_context():
reset_rate_limits()
# Add some rate limit entries
check_rate_limit("192.168.1.1", authenticated=False)
check_rate_limit("192.168.1.2", authenticated=False)
assert len(_rate_limit_requests) == 2
# Cleanup with very large window (nothing should be removed)
count = cleanup_rate_limits(window=3600)
assert count == 0
# Wait a bit and cleanup with tiny window
time.sleep(0.1)
count = cleanup_rate_limits(window=0) # Immediate cleanup
assert count == 2
assert len(_rate_limit_requests) == 0
class TestCleanupThreadSafety:
"""Tests for thread-safety of cleanup operations."""
def test_cleanup_lock_exists(self, app):
"""Verify cleanup lock exists."""
import threading
from app.api import _cleanup_lock
assert isinstance(_cleanup_lock, type(threading.Lock()))
def test_concurrent_cleanup_access(self, app, client):
"""Test that concurrent requests don't corrupt cleanup state."""
import threading
from app.api import reset_cleanup_times
with app.app_context():
reset_cleanup_times()
errors = []
results = []
def make_request():
try:
resp = client.get("/health")
results.append(resp.status_code)
except Exception as e:
errors.append(str(e))
# Simulate concurrent requests
threads = [threading.Thread(target=make_request) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
assert all(r == 200 for r in results)
class TestCleanupConfiguration:
"""Tests for cleanup configuration."""
def test_cleanup_intervals_configured(self, app):
"""Verify cleanup intervals are properly configured."""
from app.api import _CLEANUP_INTERVALS
assert "pastes" in _CLEANUP_INTERVALS
assert "hashes" in _CLEANUP_INTERVALS
assert "rate_limits" in _CLEANUP_INTERVALS
# Verify reasonable intervals
assert _CLEANUP_INTERVALS["pastes"] >= 60 # At least 1 minute
assert _CLEANUP_INTERVALS["hashes"] >= 60
assert _CLEANUP_INTERVALS["rate_limits"] >= 60