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.
This commit is contained in:
@@ -42,6 +42,7 @@ PASTE_ID_PATTERN = re.compile(r"^[a-f0-9]+$")
|
|||||||
CLIENT_ID_PATTERN = re.compile(r"^[a-f0-9]{40}$")
|
CLIENT_ID_PATTERN = re.compile(r"^[a-f0-9]{40}$")
|
||||||
MIME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9!#$&\-^_.+]*/[a-z0-9][a-z0-9!#$&\-^_.+]*$")
|
MIME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9!#$&\-^_.+]*/[a-z0-9][a-z0-9!#$&\-^_.+]*$")
|
||||||
SHORT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9]+$")
|
SHORT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9]+$")
|
||||||
|
CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
|
||||||
SHORT_ID_ALPHABET = string.ascii_letters + string.digits
|
SHORT_ID_ALPHABET = string.ascii_letters + string.digits
|
||||||
ALLOWED_URL_SCHEMES = frozenset({"http", "https"})
|
ALLOWED_URL_SCHEMES = frozenset({"http", "https"})
|
||||||
|
|
||||||
@@ -450,6 +451,7 @@ def build_paste_metadata(
|
|||||||
password_protected: bool = False,
|
password_protected: bool = False,
|
||||||
include_owner: bool = False,
|
include_owner: bool = False,
|
||||||
last_accessed: int | None = None,
|
last_accessed: int | None = None,
|
||||||
|
display_name: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build standardized paste metadata response dict.
|
"""Build standardized paste metadata response dict.
|
||||||
|
|
||||||
@@ -464,6 +466,7 @@ def build_paste_metadata(
|
|||||||
password_protected: Whether paste has password
|
password_protected: Whether paste has password
|
||||||
include_owner: Whether to include owner in response
|
include_owner: Whether to include owner in response
|
||||||
last_accessed: Last access timestamp (optional)
|
last_accessed: Last access timestamp (optional)
|
||||||
|
display_name: Human-readable label (optional)
|
||||||
"""
|
"""
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
"id": paste_id,
|
"id": paste_id,
|
||||||
@@ -483,6 +486,8 @@ def build_paste_metadata(
|
|||||||
data["expires_at"] = expires_at
|
data["expires_at"] = expires_at
|
||||||
if password_protected:
|
if password_protected:
|
||||||
data["password_protected"] = True
|
data["password_protected"] = True
|
||||||
|
if display_name:
|
||||||
|
data["display_name"] = display_name
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -523,7 +528,8 @@ def fetch_paste(paste_id: str, check_password: bool = True) -> Response | None:
|
|||||||
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"""SELECT id, content, mime_type, owner, created_at,
|
"""SELECT id, content, mime_type, owner, created_at,
|
||||||
length(content) as size, burn_after_read, expires_at, password_hash
|
length(content) as size, burn_after_read, expires_at,
|
||||||
|
password_hash, display_name
|
||||||
FROM pastes WHERE id = ?""",
|
FROM pastes WHERE id = ?""",
|
||||||
(paste_id,),
|
(paste_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -1242,6 +1248,16 @@ class IndexView(MethodView):
|
|||||||
return error_response("Password too long (max 1024 chars)", 400)
|
return error_response("Password too long (max 1024 chars)", 400)
|
||||||
password_hash = hash_password(password_header)
|
password_hash = hash_password(password_header)
|
||||||
|
|
||||||
|
# Display name (authenticated users only, silently ignored for anonymous)
|
||||||
|
display_name: str | None = None
|
||||||
|
display_name_header = request.headers.get("X-Display-Name", "").strip()
|
||||||
|
if display_name_header and owner:
|
||||||
|
if len(display_name_header) > 128:
|
||||||
|
return error_response("Display name too long (max 128 chars)", 400)
|
||||||
|
if CONTROL_CHAR_PATTERN.search(display_name_header):
|
||||||
|
return error_response("Display name contains invalid characters", 400)
|
||||||
|
display_name = display_name_header
|
||||||
|
|
||||||
# Insert paste
|
# Insert paste
|
||||||
paste_id = generate_paste_id(content)
|
paste_id = generate_paste_id(content)
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
@@ -1250,8 +1266,8 @@ class IndexView(MethodView):
|
|||||||
db.execute(
|
db.execute(
|
||||||
"""INSERT INTO pastes
|
"""INSERT INTO pastes
|
||||||
(id, content, mime_type, owner, created_at, last_accessed,
|
(id, content, mime_type, owner, created_at, last_accessed,
|
||||||
burn_after_read, expires_at, password_hash)
|
burn_after_read, expires_at, password_hash, display_name)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
paste_id,
|
paste_id,
|
||||||
content,
|
content,
|
||||||
@@ -1262,6 +1278,7 @@ class IndexView(MethodView):
|
|||||||
1 if burn_after_read else 0,
|
1 if burn_after_read else 0,
|
||||||
expires_at,
|
expires_at,
|
||||||
password_hash,
|
password_hash,
|
||||||
|
display_name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -1282,6 +1299,8 @@ class IndexView(MethodView):
|
|||||||
response_data["expires_at"] = expires_at
|
response_data["expires_at"] = expires_at
|
||||||
if password_hash:
|
if password_hash:
|
||||||
response_data["password_protected"] = True
|
response_data["password_protected"] = True
|
||||||
|
if display_name:
|
||||||
|
response_data["display_name"] = display_name
|
||||||
|
|
||||||
# Record successful paste for anti-flood tracking
|
# Record successful paste for anti-flood tracking
|
||||||
record_antiflood_request()
|
record_antiflood_request()
|
||||||
@@ -1562,6 +1581,7 @@ class PasteView(MethodView):
|
|||||||
burn_after_read=bool(row["burn_after_read"]),
|
burn_after_read=bool(row["burn_after_read"]),
|
||||||
expires_at=row["expires_at"],
|
expires_at=row["expires_at"],
|
||||||
password_protected=bool(row["password_hash"]),
|
password_protected=bool(row["password_hash"]),
|
||||||
|
display_name=row["display_name"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1581,6 +1601,8 @@ class PasteView(MethodView):
|
|||||||
- X-Paste-Password: Set/change password
|
- X-Paste-Password: Set/change password
|
||||||
- X-Remove-Password: true to remove password
|
- X-Remove-Password: true to remove password
|
||||||
- X-Extend-Expiry: Seconds to add to current expiry
|
- X-Extend-Expiry: Seconds to add to current expiry
|
||||||
|
- X-Display-Name: Set/change display name
|
||||||
|
- X-Remove-Display-Name: true to remove display name
|
||||||
"""
|
"""
|
||||||
# Validate paste ID format
|
# Validate paste ID format
|
||||||
if err := validate_paste_id(paste_id):
|
if err := validate_paste_id(paste_id):
|
||||||
@@ -1659,6 +1681,23 @@ class PasteView(MethodView):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return error_response("Invalid X-Extend-Expiry value", 400)
|
return error_response("Invalid X-Extend-Expiry value", 400)
|
||||||
|
|
||||||
|
# Display name update
|
||||||
|
remove_display_name = request.headers.get("X-Remove-Display-Name", "").lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
|
)
|
||||||
|
new_display_name = request.headers.get("X-Display-Name", "").strip()
|
||||||
|
if remove_display_name:
|
||||||
|
update_fields.append("display_name = NULL")
|
||||||
|
elif new_display_name:
|
||||||
|
if len(new_display_name) > 128:
|
||||||
|
return error_response("Display name too long (max 128 chars)", 400)
|
||||||
|
if CONTROL_CHAR_PATTERN.search(new_display_name):
|
||||||
|
return error_response("Display name contains invalid characters", 400)
|
||||||
|
update_fields.append("display_name = ?")
|
||||||
|
update_params.append(new_display_name)
|
||||||
|
|
||||||
if not update_fields:
|
if not update_fields:
|
||||||
return error_response("No updates provided", 400)
|
return error_response("No updates provided", 400)
|
||||||
|
|
||||||
@@ -1684,6 +1723,7 @@ class PasteView(MethodView):
|
|||||||
# Fetch updated paste for response
|
# Fetch updated paste for response
|
||||||
updated = db.execute(
|
updated = db.execute(
|
||||||
"""SELECT id, mime_type, length(content) as size, expires_at,
|
"""SELECT id, mime_type, length(content) as size, expires_at,
|
||||||
|
display_name,
|
||||||
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected
|
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected
|
||||||
FROM pastes WHERE id = ?""",
|
FROM pastes WHERE id = ?""",
|
||||||
(paste_id,),
|
(paste_id,),
|
||||||
@@ -1698,6 +1738,8 @@ class PasteView(MethodView):
|
|||||||
response_data["expires_at"] = updated["expires_at"]
|
response_data["expires_at"] = updated["expires_at"]
|
||||||
if updated["password_protected"]:
|
if updated["password_protected"]:
|
||||||
response_data["password_protected"] = True
|
response_data["password_protected"] = True
|
||||||
|
if updated["display_name"]:
|
||||||
|
response_data["display_name"] = updated["display_name"]
|
||||||
|
|
||||||
return json_response(response_data)
|
return json_response(response_data)
|
||||||
|
|
||||||
@@ -1915,7 +1957,7 @@ class PastesListView(MethodView):
|
|||||||
# Include owner for admin view
|
# Include owner for admin view
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
f"""SELECT id, owner, mime_type, length(content) as size, created_at,
|
f"""SELECT id, owner, mime_type, length(content) as size, created_at,
|
||||||
last_accessed, burn_after_read, expires_at,
|
last_accessed, burn_after_read, expires_at, display_name,
|
||||||
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected
|
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as password_protected
|
||||||
FROM pastes
|
FROM pastes
|
||||||
WHERE {where_sql}
|
WHERE {where_sql}
|
||||||
@@ -1940,6 +1982,7 @@ class PastesListView(MethodView):
|
|||||||
password_protected=bool(row["password_protected"]),
|
password_protected=bool(row["password_protected"]),
|
||||||
include_owner=show_all,
|
include_owner=show_all,
|
||||||
last_accessed=row["last_accessed"],
|
last_accessed=row["last_accessed"],
|
||||||
|
display_name=row["display_name"],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ CREATE TABLE IF NOT EXISTS pastes (
|
|||||||
last_accessed INTEGER NOT NULL,
|
last_accessed INTEGER NOT NULL,
|
||||||
burn_after_read INTEGER NOT NULL DEFAULT 0,
|
burn_after_read INTEGER NOT NULL DEFAULT 0,
|
||||||
expires_at INTEGER,
|
expires_at INTEGER,
|
||||||
password_hash TEXT
|
password_hash TEXT,
|
||||||
|
display_name TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pastes_created_at ON pastes(created_at);
|
CREATE INDEX IF NOT EXISTS idx_pastes_created_at ON pastes(created_at);
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
|||||||
"expires_at": "2024-12-25T10:30:00Z",
|
"expires_at": "2024-12-25T10:30:00Z",
|
||||||
"burn_after_read": false,
|
"burn_after_read": false,
|
||||||
"password_protected": false,
|
"password_protected": false,
|
||||||
|
"display_name": "my notes",
|
||||||
"owner": "a1b2c3d4..."
|
"owner": "a1b2c3d4..."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -280,6 +281,20 @@ X-Paste-Password: secretpassword
|
|||||||
|
|
||||||
```http
|
```http
|
||||||
POST / HTTP/1.1
|
POST / HTTP/1.1
|
||||||
|
Host: localhost:5000
|
||||||
|
Content-Type: text/plain
|
||||||
|
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||||
|
X-Display-Name: project notes
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201 Created):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "abc12345",
|
||||||
|
"url": "/abc12345",
|
||||||
|
"raw": "/abc12345/raw",
|
||||||
|
"mime_type": "text/plain",
|
||||||
"created_at": 1700000000,
|
"created_at": 1700000000,
|
||||||
"owner": "a1b2c3...", // Only present if authenticated
|
"owner": "a1b2c3...", // Only present if authenticated
|
||||||
"burn_after_read": true, // Only present if enabled
|
"burn_after_read": true, // Only present if enabled
|
||||||
@@ -291,7 +306,8 @@ Password protected content
|
|||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| 400 | No content provided |
|
||||||
| 400 | Password too long (max 1024 chars) |
|
| 400 | Password too long (max 1024 chars) |
|
||||||
| 400 | Display name too long (max 128 chars) |
|
| 400 | Display name too long (max 128 chars) |
|
||||||
| 400 | Display name contains invalid characters |
|
| 400 | Display name contains invalid characters |
|
||||||
@@ -300,6 +316,8 @@ Password protected content
|
|||||||
| 400 | Paste too small (below minimum size) |
|
| 400 | Paste too small (below minimum size) |
|
||||||
| 413 | Paste too large |
|
| 413 | Paste too large |
|
||||||
| 429 | Duplicate content rate limit exceeded |
|
| 429 | Duplicate content rate limit exceeded |
|
||||||
|
| 429 | Rate limit exceeded (per-IP throttling) |
|
||||||
|
|
||||||
**Size Limits:**
|
**Size Limits:**
|
||||||
- Minimum: disabled by default (`FLASKPASTE_MIN_SIZE`, e.g. 64 bytes for encryption enforcement)
|
- Minimum: disabled by default (`FLASKPASTE_MIN_SIZE`, e.g. 64 bytes for encryption enforcement)
|
||||||
- Anonymous: 3 MiB (configurable via `FLASKPASTE_MAX_ANON`)
|
- Anonymous: 3 MiB (configurable via `FLASKPASTE_MAX_ANON`)
|
||||||
@@ -427,6 +445,59 @@ Content-Disposition: inline
|
|||||||
**Request:**
|
**Request:**
|
||||||
```http
|
```http
|
||||||
PUT /abc12345 HTTP/1.1
|
PUT /abc12345 HTTP/1.1
|
||||||
|
Host: localhost:5000
|
||||||
|
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
| Header | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `X-Paste-Password` | Set or change paste password |
|
||||||
|
| `X-Remove-Password` | `true` to remove password protection |
|
||||||
|
| `X-Extend-Expiry` | Seconds to add to current expiry |
|
||||||
|
| `X-Display-Name` | Set or change display name (max 128 chars) |
|
||||||
|
| `X-Remove-Display-Name` | `true` to remove display name |
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "abc12345",
|
||||||
|
"size": 15,
|
||||||
|
"mime_type": "text/plain",
|
||||||
|
"expires_at": 1700086400,
|
||||||
|
"display_name": "my notes"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| 400 | Invalid paste ID format |
|
||||||
|
| 400 | No updates provided |
|
||||||
|
| 400 | Cannot update burn-after-read paste |
|
||||||
|
| 400 | Display name too long (max 128 chars) |
|
||||||
|
| 400 | Display name contains invalid characters |
|
||||||
|
| 401 | Authentication required |
|
||||||
|
| 403 | Permission denied (not owner) |
|
||||||
|
| 404 | Paste not found |
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Send content in the body to update paste content
|
||||||
|
- Use headers with empty body for metadata-only updates
|
||||||
|
- Display name only accepts printable characters (no control chars)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /{id}
|
||||||
|
|
||||||
|
Delete a paste. Requires authentication and ownership (or admin rights).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```http
|
||||||
|
DELETE /abc12345 HTTP/1.1
|
||||||
|
Host: localhost:5000
|
||||||
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
X-SSL-Client-SHA1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -333,3 +333,97 @@ class TestPastesSearch:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data["count"] == 1
|
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()
|
||||||
|
|||||||
@@ -202,6 +202,86 @@ class TestPasteUpdateEndpoint:
|
|||||||
assert data.get("password_protected") is True
|
assert data.get("password_protected") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasteUpdateDisplayName:
|
||||||
|
"""Tests for display_name update via PUT."""
|
||||||
|
|
||||||
|
def test_update_set_display_name(self, client, auth_header):
|
||||||
|
"""Set display_name on existing paste."""
|
||||||
|
create = client.post("/", data="content", content_type="text/plain", headers=auth_header)
|
||||||
|
paste_id = json.loads(create.data)["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/{paste_id}",
|
||||||
|
data="",
|
||||||
|
headers={**auth_header, "X-Display-Name": "my label"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data["display_name"] == "my label"
|
||||||
|
|
||||||
|
def test_update_change_display_name(self, client, auth_header):
|
||||||
|
"""Change existing display_name."""
|
||||||
|
create = client.post(
|
||||||
|
"/",
|
||||||
|
data="content",
|
||||||
|
content_type="text/plain",
|
||||||
|
headers={**auth_header, "X-Display-Name": "old name"},
|
||||||
|
)
|
||||||
|
paste_id = json.loads(create.data)["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/{paste_id}",
|
||||||
|
data="",
|
||||||
|
headers={**auth_header, "X-Display-Name": "new name"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data["display_name"] == "new name"
|
||||||
|
|
||||||
|
def test_update_remove_display_name(self, client, auth_header):
|
||||||
|
"""Remove display_name via X-Remove-Display-Name header."""
|
||||||
|
create = client.post(
|
||||||
|
"/",
|
||||||
|
data="content",
|
||||||
|
content_type="text/plain",
|
||||||
|
headers={**auth_header, "X-Display-Name": "to remove"},
|
||||||
|
)
|
||||||
|
paste_id = json.loads(create.data)["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/{paste_id}",
|
||||||
|
data="",
|
||||||
|
headers={**auth_header, "X-Remove-Display-Name": "true"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert "display_name" not in data
|
||||||
|
|
||||||
|
def test_update_display_name_too_long(self, client, auth_header):
|
||||||
|
"""Reject display_name exceeding 128 chars in update."""
|
||||||
|
create = client.post("/", data="content", content_type="text/plain", headers=auth_header)
|
||||||
|
paste_id = json.loads(create.data)["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/{paste_id}",
|
||||||
|
data="",
|
||||||
|
headers={**auth_header, "X-Display-Name": "x" * 129},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_update_display_name_control_chars(self, client, auth_header):
|
||||||
|
"""Reject display_name with control characters in update."""
|
||||||
|
create = client.post("/", data="content", content_type="text/plain", headers=auth_header)
|
||||||
|
paste_id = json.loads(create.data)["id"]
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/{paste_id}",
|
||||||
|
data="",
|
||||||
|
headers={**auth_header, "X-Display-Name": "bad\x01name"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
class TestPasteUpdatePrivacy:
|
class TestPasteUpdatePrivacy:
|
||||||
"""Privacy-focused tests for paste update."""
|
"""Privacy-focused tests for paste update."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user