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.
430 lines
16 KiB
Python
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()
|