forked from username/flaskpaste
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:
@@ -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
335
tests/test_paste_listing.py
Normal 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
242
tests/test_paste_update.py
Normal 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
168
tests/test_rate_limiting.py
Normal 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
|
||||
169
tests/test_scheduled_cleanup.py
Normal file
169
tests/test_scheduled_cleanup.py
Normal 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
|
||||
Reference in New Issue
Block a user