Files
flaskpaste/tests/test_url_shortener.py
Username 75a9bf56d9
Some checks failed
CI / Lint & Format (push) Failing after 31s
CI / Unit Tests (push) Has been skipped
CI / Memory Leak Check (push) Has been skipped
CI / Fuzz Testing (push) Has been skipped
CI / SBOM Generation (push) Has been skipped
CI / Security Scan (push) Successful in 36s
CI / Security Tests (push) Has been skipped
CI / Advanced Security Tests (push) Has been skipped
CI / Build & Push Image (push) Has been skipped
CI / Harbor Vulnerability Scan (push) Has been skipped
tests: add url shortener test suite
2026-02-16 20:27:02 +01:00

333 lines
12 KiB
Python

"""Tests for URL shortener endpoints."""
import json
import time
def _create_short_url(client, url="https://example.com", headers=None):
"""Helper to create a short URL and return response data."""
payload = json.dumps({"url": url})
return client.post(
"/s",
data=payload,
content_type="application/json",
headers=headers or {},
)
class TestShortURLCreate:
"""Tests for POST /s endpoint."""
def test_create_short_url_json(self, client):
"""Create short URL with JSON body."""
response = _create_short_url(client)
assert response.status_code == 201
data = json.loads(response.data)
assert "id" in data
assert len(data["id"]) == 8
assert data["target_url"] == "https://example.com"
assert data["url"].endswith(f"/s/{data['id']}")
assert "created_at" in data
def test_create_short_url_raw(self, client):
"""Create short URL with raw text body."""
response = client.post(
"/s",
data="https://example.com",
content_type="text/plain",
)
assert response.status_code == 201
data = json.loads(response.data)
assert data["target_url"] == "https://example.com"
def test_create_short_url_no_url(self, client):
"""Reject empty body."""
response = client.post("/s", data="", content_type="text/plain")
assert response.status_code == 400
def test_create_short_url_invalid_scheme(self, client):
"""Reject non-http(s) schemes."""
response = _create_short_url(client, url="ftp://example.com/file")
assert response.status_code == 400
data = json.loads(response.data)
assert "scheme" in data["error"].lower()
def test_create_short_url_no_host(self, client):
"""Reject URL without host."""
response = _create_short_url(client, url="https://")
assert response.status_code == 400
def test_create_short_url_with_expiry(self, client):
"""Create short URL with custom expiry."""
response = client.post(
"/s",
data=json.dumps({"url": "https://example.com"}),
content_type="application/json",
headers={"X-Expiry": "3600"},
)
assert response.status_code == 201
data = json.loads(response.data)
assert "expires_at" in data
assert data["expires_at"] > int(time.time())
def test_create_short_url_with_auth(self, client, auth_header):
"""Authenticated creation tracks owner."""
response = _create_short_url(client, headers=auth_header)
assert response.status_code == 201
data = json.loads(response.data)
assert data["owner"] == "a" * 40
def test_create_short_url_anonymous(self, client):
"""Anonymous creation has no owner."""
response = _create_short_url(client)
assert response.status_code == 201
data = json.loads(response.data)
assert "owner" not in data
def test_create_short_url_http(self, client):
"""HTTP URLs are accepted."""
response = _create_short_url(client, url="http://example.com")
assert response.status_code == 201
def test_create_short_url_https_with_path(self, client):
"""URLs with paths are accepted."""
response = _create_short_url(client, url="https://example.com/some/path?q=1")
assert response.status_code == 201
data = json.loads(response.data)
assert data["target_url"] == "https://example.com/some/path?q=1"
class TestShortURLRedirect:
"""Tests for GET /s/<id> endpoint."""
def test_redirect_302(self, client):
"""GET /s/<id> returns 302 with Location header."""
create = _create_short_url(client, url="https://example.com")
short_id = json.loads(create.data)["id"]
response = client.get(f"/s/{short_id}")
assert response.status_code == 302
assert response.headers["Location"] == "https://example.com"
def test_redirect_increments_counter(self, client):
"""Redirect increments access_count."""
create = _create_short_url(client, url="https://example.com")
short_id = json.loads(create.data)["id"]
# Access twice
client.get(f"/s/{short_id}")
client.get(f"/s/{short_id}")
# Check info
info = client.get(f"/s/{short_id}/info")
data = json.loads(info.data)
assert data["access_count"] == 2
def test_redirect_not_found(self, client):
"""Redirect returns 404 for unknown ID."""
response = client.get("/s/AbCdEfGh")
assert response.status_code == 404
def test_redirect_invalid_id(self, client):
"""Redirect returns 400 for invalid ID format."""
response = client.get("/s/bad!")
assert response.status_code == 400
def test_redirect_expired(self, client, app):
"""Redirect returns 404 for expired short URL."""
# Create with very short expiry
response = client.post(
"/s",
data=json.dumps({"url": "https://example.com"}),
content_type="application/json",
headers={"X-Expiry": "1"},
)
assert response.status_code == 201
short_id = json.loads(response.data)["id"]
# Manually expire it by setting expires_at in the past
with app.app_context():
from app.database import get_db
db = get_db()
db.execute(
"UPDATE short_urls SET expires_at = ? WHERE id = ?",
(int(time.time()) - 10, short_id),
)
db.commit()
result = client.get(f"/s/{short_id}")
assert result.status_code == 404
def test_head_redirect(self, client):
"""HEAD /s/<id> returns 302."""
create = _create_short_url(client, url="https://example.com")
short_id = json.loads(create.data)["id"]
response = client.head(f"/s/{short_id}")
assert response.status_code == 302
assert response.headers["Location"] == "https://example.com"
class TestShortURLInfo:
"""Tests for GET /s/<id>/info endpoint."""
def test_info_returns_metadata(self, client, auth_header):
"""Info endpoint returns full metadata."""
create = _create_short_url(client, url="https://example.com", headers=auth_header)
short_id = json.loads(create.data)["id"]
response = client.get(f"/s/{short_id}/info")
assert response.status_code == 200
data = json.loads(response.data)
assert data["id"] == short_id
assert data["target_url"] == "https://example.com"
assert data["access_count"] == 0
assert "created_at" in data
assert data["owner"] == "a" * 40
def test_info_does_not_increment_counter(self, client):
"""Info endpoint does not increment access_count."""
create = _create_short_url(client, url="https://example.com")
short_id = json.loads(create.data)["id"]
# Access info multiple times
client.get(f"/s/{short_id}/info")
client.get(f"/s/{short_id}/info")
client.get(f"/s/{short_id}/info")
response = client.get(f"/s/{short_id}/info")
data = json.loads(response.data)
assert data["access_count"] == 0
def test_info_not_found(self, client):
"""Info returns 404 for unknown ID."""
response = client.get("/s/AbCdEfGh/info")
assert response.status_code == 404
class TestShortURLDelete:
"""Tests for DELETE /s/<id> endpoint."""
def test_delete_requires_auth(self, client):
"""Delete requires authentication."""
create = _create_short_url(client)
short_id = json.loads(create.data)["id"]
response = client.delete(f"/s/{short_id}")
assert response.status_code == 401
def test_delete_owner_can_delete(self, client, auth_header):
"""Owner can delete their short URL."""
create = _create_short_url(client, headers=auth_header)
short_id = json.loads(create.data)["id"]
response = client.delete(f"/s/{short_id}", headers=auth_header)
assert response.status_code == 200
# Verify deletion
redirect = client.get(f"/s/{short_id}")
assert redirect.status_code == 404
def test_delete_non_owner_rejected(self, client, auth_header, other_auth_header):
"""Non-owner cannot delete."""
create = _create_short_url(client, headers=auth_header)
short_id = json.loads(create.data)["id"]
response = client.delete(f"/s/{short_id}", headers=other_auth_header)
assert response.status_code == 403
def test_delete_not_found(self, client, auth_header):
"""Delete returns 404 for unknown ID."""
response = client.delete("/s/AbCdEfGh", headers=auth_header)
assert response.status_code == 404
class TestShortURLsList:
"""Tests for GET /s endpoint."""
def test_list_requires_auth(self, client):
"""List requires authentication."""
response = client.get("/s")
assert response.status_code == 401
def test_list_own_urls(self, client, auth_header):
"""Lists only URLs owned by authenticated user."""
# Create URLs with different owners
_create_short_url(client, url="https://mine.com", headers=auth_header)
_create_short_url(client, url="https://anon.com") # anonymous
response = client.get("/s", headers=auth_header)
assert response.status_code == 200
data = json.loads(response.data)
assert data["count"] == 1
assert data["urls"][0]["target_url"] == "https://mine.com"
def test_list_pagination(self, client, auth_header):
"""Pagination works correctly."""
for i in range(3):
_create_short_url(client, url=f"https://example.com/{i}", headers=auth_header)
response = client.get("/s?limit=2&offset=0", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 2
assert data["total"] == 3
def test_list_empty(self, client, auth_header):
"""Empty list for user with no URLs."""
response = client.get("/s", headers=auth_header)
data = json.loads(response.data)
assert data["count"] == 0
assert data["urls"] == []
class TestShortURLSecurity:
"""Security-focused tests for URL shortener."""
def test_reject_javascript_scheme(self, client):
"""Reject javascript: URLs."""
response = _create_short_url(client, url="javascript:alert(1)")
assert response.status_code == 400
def test_reject_data_scheme(self, client):
"""Reject data: URLs."""
response = _create_short_url(client, url="data:text/html,<script>alert(1)</script>")
assert response.status_code == 400
def test_reject_ftp_scheme(self, client):
"""Reject ftp: URLs."""
response = _create_short_url(client, url="ftp://files.example.com/secret")
assert response.status_code == 400
def test_reject_file_scheme(self, client):
"""Reject file: URLs."""
response = _create_short_url(client, url="file:///etc/passwd")
assert response.status_code == 400
def test_reject_no_netloc(self, client):
"""Reject URLs without network location."""
response = _create_short_url(client, url="https:///path/only")
assert response.status_code == 400
def test_reject_url_too_long(self, client):
"""Reject URLs exceeding max length."""
long_url = "https://example.com/" + "a" * 2050
response = _create_short_url(client, url=long_url)
assert response.status_code == 400
def test_cache_control_on_redirect(self, client):
"""Redirect includes Cache-Control: no-cache."""
create = _create_short_url(client, url="https://example.com")
short_id = json.loads(create.data)["id"]
response = client.get(f"/s/{short_id}")
assert "no-cache" in response.headers.get("Cache-Control", "")
def test_short_ids_are_base62(self, client):
"""Generated IDs contain only base62 characters."""
for _ in range(5):
create = _create_short_url(client, url="https://example.com")
short_id = json.loads(create.data)["id"]
assert len(short_id) == 8
assert short_id.isalnum()