* chore(api): add docker compose test runner
Adds docker-compose-test.yml at the repo root that boots an isolated
postgres / valkey / rabbitmq / minio stack with health checks and tmpfs
data dirs, then runs pytest against it and exits. Includes a usage doc
under apps/api/tests/RUNNING_TESTS.md and a pointer in AGENTS.md.
Prereq: ./setup.sh (generates apps/api/.env).
Usage:
docker compose -f docker-compose-test.yml up --build \
--abort-on-container-exit --exit-code-from api-tests
docker compose -f docker-compose-test.yml down -v
* fix(api): correct bugs surfaced by the pytest suite
Five small bugs caught by enabling the pytest contract suite end-to-end.
Each is independently justifiable:
- api/serializers/cycle.py + api/views/cycle.py: CycleCreateSerializer.validate
required project_id in the request body, but the view only ever passes
it through the URL kwarg. Cycle create/update via the public API was
returning 400 "Project ID is required". Read project_id from
serializer context (passed by the view) in addition to body/instance.
- app/views/api.py: ApiTokenEndpoint.get(pk) and patch(pk) did not filter
out is_service=True tokens, so a user could read and modify service
tokens through the user token endpoint. The list mode and delete
already filter is_service=False; aligned the other two.
- bgtasks/work_item_link_task.py: validate_url_ip checked hostname before
scheme, so file:///etc/passwd raised "No hostname found" instead of
the documented "Only HTTP and HTTPS" error. Swapped the order so the
scheme guard matches the docstring intent.
- utils/path_validator.py: get_allowed_hosts used `WEB_URL or APP_BASE_URL`
so when both are configured to different hosts (the standard local
setup: WEB_URL=:8000, APP_BASE_URL=:3000), only one was added to the
allow-list. Redirects to APP_BASE_URL then had their next_path stripped
because the host wasn't allowed. Include every configured base URL.
* chore(api): align pytest tests with current behavior, clear warnings
Test-side fixes paired with the product fixes in the previous commit, plus
deprecation cleanup that drops the test run from 104 warnings to 0.
Tests:
- tests/contract/api/test_cycles.py: project fixture sets cycle_view=True;
the Project model defaults the flag to False, so cycle create/update
always tripped "Cycles are not enabled for this project".
- tests/contract/app/test_authentication.py: next_path uses "/workspaces"
(validate_next_path rejects values without a leading slash and returns
empty, which dropped the path from the redirect URL).
- tests/unit/bg_tasks/test_copy_s3_objects.py: mocked sync_with_external_service
now returns description_json; the task unconditionally writes the value
back to the Issue, and Issue.description_json is NOT NULL on UPDATE.
- tests/unit/utils/test_url.py: three length-limit tests placed the URL at
char 970+ on a single line, which contains_url truncates away as ReDoS
defense (500-char per-line cap). Restructured to keep test intent intact
while staying inside the per-line window.
Warning cleanup (104 → 0):
- settings/common.py: removed USE_L10N=True (deprecated in Django 4.0,
removed in 5.0; default is True).
- celery.py, settings/local.py, settings/production.py: pythonjsonlogger
moved jsonlogger → json; update the import / formatter path.
265 lines
12 KiB
Python
265 lines
12 KiB
Python
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# See the LICENSE file for details.
|
|
|
|
import pytest
|
|
from plane.utils.url import (
|
|
contains_url,
|
|
is_valid_url,
|
|
normalize_url_path,
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestContainsURL:
|
|
"""Test the contains_url function"""
|
|
|
|
def test_contains_url_with_http_protocol(self):
|
|
"""Test contains_url with HTTP protocol URLs"""
|
|
assert contains_url("Check out http://example.com") is True
|
|
assert contains_url("Visit http://google.com/search") is True
|
|
assert contains_url("http://localhost:8000") is True
|
|
|
|
def test_contains_url_with_https_protocol(self):
|
|
"""Test contains_url with HTTPS protocol URLs"""
|
|
assert contains_url("Check out https://example.com") is True
|
|
assert contains_url("Visit https://google.com/search") is True
|
|
assert contains_url("https://secure.example.com") is True
|
|
|
|
def test_contains_url_with_www_prefix(self):
|
|
"""Test contains_url with www prefix"""
|
|
assert contains_url("Visit www.example.com") is True
|
|
assert contains_url("Check www.google.com") is True
|
|
assert contains_url("Go to www.test-site.org") is True
|
|
|
|
def test_contains_url_with_domain_patterns(self):
|
|
"""Test contains_url with domain patterns"""
|
|
assert contains_url("Visit example.com") is True
|
|
assert contains_url("Check google.org") is True
|
|
assert contains_url("Go to test-site.co.uk") is True
|
|
assert contains_url("Visit sub.domain.com") is True
|
|
|
|
def test_contains_url_with_ip_addresses(self):
|
|
"""Test contains_url with IP addresses"""
|
|
assert contains_url("Connect to 192.168.1.1") is True
|
|
assert contains_url("Visit 10.0.0.1") is True
|
|
assert contains_url("Check 127.0.0.1") is True
|
|
assert contains_url("Go to 8.8.8.8") is True
|
|
|
|
def test_contains_url_case_insensitive(self):
|
|
"""Test contains_url is case insensitive"""
|
|
assert contains_url("Check HTTP://EXAMPLE.COM") is True
|
|
assert contains_url("Visit WWW.GOOGLE.COM") is True
|
|
assert contains_url("Go to Https://Test.Com") is True
|
|
|
|
def test_contains_url_with_no_urls(self):
|
|
"""Test contains_url with text that doesn't contain URLs"""
|
|
assert contains_url("This is just plain text") is False
|
|
assert contains_url("No URLs here!") is False
|
|
assert contains_url("com org net") is False # Just TLD words
|
|
assert contains_url("192.168") is False # Incomplete IP
|
|
assert contains_url("") is False # Empty string
|
|
|
|
def test_contains_url_edge_cases(self):
|
|
"""Test contains_url with edge cases"""
|
|
assert contains_url("example.c") is False # TLD too short
|
|
assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255)
|
|
assert contains_url("just-a-hyphen") is False # No domain
|
|
assert contains_url("www.") is False # Incomplete www - needs at least one char after dot
|
|
|
|
def test_contains_url_length_limit_under_1000(self):
|
|
"""Test contains_url with input under 1000 characters containing URLs.
|
|
|
|
Note: contains_url also truncates each line to 500 chars (ReDoS protection),
|
|
so URLs must fall within the first 500 chars of their line.
|
|
"""
|
|
# Single line under 500 chars with URL at the end
|
|
text_with_url = "a" * 470 + " https://example.com" # 490 chars total
|
|
assert len(text_with_url) < 1000
|
|
assert contains_url(text_with_url) is True
|
|
|
|
# Multi-line input under 1000 chars total; URL on its own short line
|
|
text_multiline = "a" * 480 + "\nhttps://example.com\n" + "b" * 480
|
|
assert len(text_multiline) < 1000
|
|
assert contains_url(text_multiline) is True
|
|
|
|
def test_contains_url_length_limit_over_1000(self):
|
|
"""Test contains_url with input over 1000 characters returns False"""
|
|
# Create a string over 1000 characters with a URL
|
|
text_with_url = "a" * 982 + "https://example.com" # 982 + 19 = 1001 chars
|
|
assert len(text_with_url) > 1000
|
|
assert contains_url(text_with_url) is False
|
|
|
|
# Test with much longer input
|
|
long_text_with_url = "a" * 5000 + " https://example.com"
|
|
assert contains_url(long_text_with_url) is False
|
|
|
|
def test_contains_url_length_limit_exactly_1000(self):
|
|
"""Test contains_url with input exactly 1000 characters.
|
|
|
|
URLs must fall within the first 500 chars of their line (ReDoS protection).
|
|
"""
|
|
# Test with exactly 1000 characters without URL
|
|
text_no_url = "a" * 1000
|
|
assert len(text_no_url) == 1000
|
|
assert contains_url(text_no_url) is False
|
|
|
|
# Multi-line totalling exactly 1000 chars; URL on a short line
|
|
text_with_url = "a" * 480 + "\nhttps://example.com\n" + "b" * 499 # 480+1+19+1+499 = 1000
|
|
assert len(text_with_url) == 1000
|
|
assert contains_url(text_with_url) is True
|
|
|
|
def test_contains_url_line_length_scenarios(self):
|
|
"""Test contains_url with realistic line length scenarios"""
|
|
# Test with multiline input where total is under 1000 but we test line processing
|
|
# Short lines with URL
|
|
multiline_short = "Line 1\nLine 2 with https://example.com\nLine 3"
|
|
assert contains_url(multiline_short) is True
|
|
|
|
# Multiple lines under total limit
|
|
multiline_text = "a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200
|
|
assert len(multiline_text) < 1000
|
|
assert contains_url(multiline_text) is True
|
|
|
|
def test_contains_url_total_length_vs_line_length(self):
|
|
"""Test the interaction between total length limit and line processing"""
|
|
# Test that total length limit takes precedence
|
|
# Even if individual lines would be processed, total > 1000 means immediate False
|
|
over_limit_text = "a" * 1001 # No URL, but over total limit
|
|
assert contains_url(over_limit_text) is False
|
|
|
|
# Test that under total limit, line processing works normally.
|
|
# URL must be within first 500 chars of its line (ReDoS protection).
|
|
under_limit_with_url = "a" * 400 + "https://example.com" # 419 chars total, fits in 500
|
|
assert len(under_limit_with_url) < 1000
|
|
assert contains_url(under_limit_with_url) is True
|
|
|
|
def test_contains_url_multiline_mixed_lengths(self):
|
|
"""Test contains_url with multiple lines of different lengths"""
|
|
# Test realistic multiline scenario under 1000 chars total
|
|
multiline_text = (
|
|
"Short line\n"
|
|
+ "a" * 400
|
|
+ "https://example.com\n" # Line with URL
|
|
+ "b" * 300 # Another line
|
|
)
|
|
assert len(multiline_text) < 1000
|
|
assert contains_url(multiline_text) is True
|
|
|
|
# Test multiline without URLs
|
|
multiline_no_url = "Short line\n" + "a" * 400 + "\n" + "b" * 300
|
|
assert len(multiline_no_url) < 1000
|
|
assert contains_url(multiline_no_url) is False
|
|
|
|
def test_contains_url_edge_cases_with_length_limits(self):
|
|
"""Test contains_url edge cases related to length limits"""
|
|
# Empty string
|
|
assert contains_url("") is False
|
|
|
|
# Very short string with URL
|
|
assert contains_url("http://a.co") is True
|
|
|
|
# String with newlines and mixed content
|
|
mixed_content = "Line 1\nLine 2 with https://example.com\nLine 3"
|
|
assert contains_url(mixed_content) is True
|
|
|
|
# String with many newlines under total limit
|
|
many_newlines = "\n" * 500 + "https://example.com"
|
|
assert len(many_newlines) < 1000
|
|
assert contains_url(many_newlines) is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIsValidURL:
|
|
"""Test the is_valid_url function"""
|
|
|
|
def test_is_valid_url_with_valid_urls(self):
|
|
"""Test is_valid_url with valid URLs"""
|
|
assert is_valid_url("https://example.com") is True
|
|
assert is_valid_url("http://google.com") is True
|
|
assert is_valid_url("https://sub.domain.com/path") is True
|
|
assert is_valid_url("http://localhost:8000") is True
|
|
assert is_valid_url("https://example.com/path?query=1") is True
|
|
assert is_valid_url("ftp://files.example.com") is True
|
|
|
|
def test_is_valid_url_with_invalid_urls(self):
|
|
"""Test is_valid_url with invalid URLs"""
|
|
assert is_valid_url("not a url") is False
|
|
assert is_valid_url("example.com") is False # No scheme
|
|
assert is_valid_url("https://") is False # No netloc
|
|
assert is_valid_url("") is False # Empty string
|
|
assert is_valid_url("://example.com") is False # No scheme
|
|
assert is_valid_url("https:/example.com") is False # Malformed
|
|
|
|
def test_is_valid_url_with_non_string_input(self):
|
|
"""Test is_valid_url with non-string input"""
|
|
assert is_valid_url(None) is False
|
|
assert is_valid_url([]) is False
|
|
assert is_valid_url({}) is False
|
|
|
|
def test_is_valid_url_with_special_schemes(self):
|
|
"""Test is_valid_url with special URL schemes"""
|
|
assert is_valid_url("ftp://ftp.example.com") is True
|
|
assert is_valid_url("mailto:user@example.com") is False
|
|
assert is_valid_url("file:///path/to/file") is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNormalizeURLPath:
|
|
"""Test the normalize_url_path function"""
|
|
|
|
def test_normalize_url_path_with_multiple_slashes(self):
|
|
"""Test normalize_url_path with multiple consecutive slashes"""
|
|
result = normalize_url_path("https://example.com//foo///bar//baz")
|
|
assert result == "https://example.com/foo/bar/baz"
|
|
|
|
def test_normalize_url_path_with_query_and_fragment(self):
|
|
"""Test normalize_url_path preserves query and fragment"""
|
|
result = normalize_url_path("https://example.com//foo///bar//baz?x=1&y=2#fragment")
|
|
assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment"
|
|
|
|
def test_normalize_url_path_with_no_redundant_slashes(self):
|
|
"""Test normalize_url_path with already normalized URL"""
|
|
url = "https://example.com/foo/bar/baz?x=1#fragment"
|
|
result = normalize_url_path(url)
|
|
assert result == url
|
|
|
|
def test_normalize_url_path_with_root_path(self):
|
|
"""Test normalize_url_path with root path"""
|
|
result = normalize_url_path("https://example.com//")
|
|
assert result == "https://example.com/"
|
|
|
|
def test_normalize_url_path_with_empty_path(self):
|
|
"""Test normalize_url_path with empty path"""
|
|
result = normalize_url_path("https://example.com")
|
|
assert result == "https://example.com"
|
|
|
|
def test_normalize_url_path_with_complex_path(self):
|
|
"""Test normalize_url_path with complex path structure"""
|
|
result = normalize_url_path("https://example.com///api//v1///users//123//profile")
|
|
assert result == "https://example.com/api/v1/users/123/profile"
|
|
|
|
def test_normalize_url_path_with_different_schemes(self):
|
|
"""Test normalize_url_path with different URL schemes"""
|
|
# HTTP
|
|
result = normalize_url_path("http://example.com//path")
|
|
assert result == "http://example.com/path"
|
|
|
|
# FTP
|
|
result = normalize_url_path("ftp://ftp.example.com//files//document.txt")
|
|
assert result == "ftp://ftp.example.com/files/document.txt"
|
|
|
|
def test_normalize_url_path_with_port(self):
|
|
"""Test normalize_url_path with port number"""
|
|
result = normalize_url_path("https://example.com:8080//api//v1")
|
|
assert result == "https://example.com:8080/api/v1"
|
|
|
|
def test_normalize_url_path_edge_cases(self):
|
|
"""Test normalize_url_path with edge cases"""
|
|
# Many consecutive slashes
|
|
result = normalize_url_path("https://example.com///////path")
|
|
assert result == "https://example.com/path"
|
|
|
|
# Mixed single and multiple slashes
|
|
result = normalize_url_path("https://example.com/a//b/c///d")
|
|
assert result == "https://example.com/a/b/c/d"
|