Files
flaskpaste/tests/test_paste_listing.py
Username 8ebabfe102 pastes: add display_name field
Authenticated users can tag pastes with a human-readable label
via X-Display-Name header. Supports create, update, remove, and
listing. Max 128 chars, control characters rejected.
2026-02-24 12:55:44 +01:00

430 lines
16 KiB
Python

"""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
class TestPastesDisplayName:
"""Tests for display_name field in paste listing."""
def test_create_with_display_name(self, client, auth_header):
"""Create paste with display_name returns it in response."""
response = client.post(
"/",
data="test content",
content_type="text/plain",
headers={**auth_header, "X-Display-Name": "my notes"},
)
assert response.status_code == 201
data = json.loads(response.data)
assert data["display_name"] == "my notes"
def test_display_name_in_listing(self, client, auth_header):
"""Display name appears in paste listing."""
client.post(
"/",
data="test content",
content_type="text/plain",
headers={**auth_header, "X-Display-Name": "project docs"},
)
response = client.get("/pastes", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 1
assert data["pastes"][0]["display_name"] == "project docs"
def test_display_name_in_metadata(self, client, auth_header):
"""Display name appears in single-paste GET."""
create = client.post(
"/",
data="test content",
content_type="text/plain",
headers={**auth_header, "X-Display-Name": "readme"},
)
paste_id = json.loads(create.data)["id"]
response = client.get(f"/{paste_id}")
data = json.loads(response.data)
assert data["display_name"] == "readme"
def test_display_name_absent_when_unset(self, client, auth_header):
"""Display name is omitted from response when not set."""
create = client.post(
"/",
data="test content",
content_type="text/plain",
headers=auth_header,
)
paste_id = json.loads(create.data)["id"]
response = client.get(f"/{paste_id}")
data = json.loads(response.data)
assert "display_name" not in data
def test_anonymous_display_name_ignored(self, client):
"""Anonymous user's display_name header is silently ignored."""
create = client.post(
"/",
data="test content",
content_type="text/plain",
headers={"X-Display-Name": "sneaky"},
)
assert create.status_code == 201
data = json.loads(create.data)
assert "display_name" not in data
def test_display_name_too_long(self, client, auth_header):
"""Display name exceeding 128 chars is rejected."""
response = client.post(
"/",
data="test content",
content_type="text/plain",
headers={**auth_header, "X-Display-Name": "a" * 129},
)
assert response.status_code == 400
data = json.loads(response.data)
assert "too long" in data["error"].lower()
def test_display_name_control_chars_rejected(self, client, auth_header):
"""Display name with control characters is rejected."""
response = client.post(
"/",
data="test content",
content_type="text/plain",
headers={**auth_header, "X-Display-Name": "bad\x00name"},
)
assert response.status_code == 400
data = json.loads(response.data)
assert "invalid" in data["error"].lower()