snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,34 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import csv
from io import StringIO
import pytest
from plane.utils.porters.formatters import CSVFormatter
def _read_rows(content):
return list(csv.reader(StringIO(content)))
@pytest.mark.unit
class TestPorterCSVFormatterSanitization:
"""CSV exports must not emit formula-triggering values, including in header rows."""
def test_data_values_are_sanitized(self):
content = CSVFormatter().encode([{"name": "=1+2"}])
rows = _read_rows(content)
assert rows[1][0] == "'=1+2"
def test_prettified_headers_are_sanitized(self):
content = CSVFormatter().encode([{"=evil_header": "value"}])
rows = _read_rows(content)
assert rows[0][0] == "'=Evil Header"
def test_raw_headers_are_sanitized(self):
content = CSVFormatter(prettify_headers=False).encode([{"=evil": "value"}])
rows = _read_rows(content)
assert rows[0][0] == "'=evil"

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Regression tests for created_at / updated_at rich-filter lookups (WEB-8477 Bug 5).
``created_at`` and ``updated_at`` are DateTimeFields, but the UI sends a bare calendar
date. With the default ``exact`` / ``range`` lookups django-filter coerced those dates to
midnight, so:
* ``created_at__exact=<date>`` matched only rows stamped exactly 00:00:00 -> always empty.
* ``created_at__range=<a>,<b>`` capped the upper bound at ``b`` 00:00:00 -> silently
dropped every row created during the final day of the range.
Both surfaced as "filter returns no work items". The filters now compare the date
component (``date`` / ``date__range``) so a calendar date means the whole day.
"""
import datetime
import pytest
from plane.db.models import Issue, Project, ProjectMember
from plane.utils.filters.filterset import IssueFilterSet
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Filter Project",
identifier="FP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
return project
def _issue_at(project, create_user, name, created, updated):
"""Create an issue then force created_at/updated_at (both are auto-managed)."""
issue = Issue.objects.create(name=name, project=project, workspace=project.workspace, created_by=create_user)
Issue.objects.filter(pk=issue.pk).update(created_at=created, updated_at=updated)
issue.refresh_from_db()
return issue
@pytest.fixture
def issues(db, project, create_user):
tz = datetime.timezone.utc
return {
# created mid-day, NOT at midnight — the case `exact` used to miss
"midday": _issue_at(
project,
create_user,
"midday",
datetime.datetime(2026, 7, 30, 13, 45, tzinfo=tz),
datetime.datetime(2026, 7, 30, 13, 45, tzinfo=tz),
),
# created on an earlier day, but updated on 2026-07-30
"old": _issue_at(
project,
create_user,
"old",
datetime.datetime(2020, 1, 1, 9, 0, tzinfo=tz),
datetime.datetime(2026, 7, 30, 10, 0, tzinfo=tz),
),
}
def _filter(project, data):
qs = Issue.objects.filter(project=project)
fs = IssueFilterSet(data=data, queryset=qs)
assert fs.is_valid(), fs.errors
return set(fs.qs.values_list("name", flat=True))
@pytest.mark.unit
class TestCreatedAtFilter:
def test_exact_matches_whole_day_not_just_midnight(self, project, issues):
# Previously returned nothing because 13:45 != 00:00:00.
assert _filter(project, {"created_at__exact": "2026-07-30"}) == {"midday"}
def test_exact_excludes_other_days(self, project, issues):
assert _filter(project, {"created_at__exact": "2020-01-01"}) == {"old"}
assert _filter(project, {"created_at__exact": "2019-05-05"}) == set()
def test_range_includes_the_final_day(self, project, issues):
# Previously returned nothing: the upper bound became 2026-07-30 00:00:00.
assert _filter(project, {"created_at__range": "2026-07-28,2026-07-30"}) == {"midday"}
def test_single_day_range(self, project, issues):
assert _filter(project, {"created_at__range": "2026-07-30,2026-07-30"}) == {"midday"}
def test_range_excludes_outside_days(self, project, issues):
assert _filter(project, {"created_at__range": "2019-01-01,2019-12-31"}) == set()
@pytest.mark.unit
class TestUpdatedAtFilter:
def test_exact_uses_updated_at_not_created_at(self, project, issues):
# "old" was created in 2020 but updated on 2026-07-30, so it must be included.
assert _filter(project, {"updated_at__exact": "2026-07-30"}) == {"midday", "old"}
def test_range_includes_the_final_day(self, project, issues):
assert _filter(project, {"updated_at__range": "2026-07-28,2026-07-30"}) == {"midday", "old"}

View File

@@ -0,0 +1,53 @@
# 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.issue_filters import filter_updated_at
@pytest.mark.unit
class TestFilterUpdatedAt:
"""Regression test for the "Updated At" filter bug — the filter was
applying to the `created_at` column instead of `updated_at`, so work
items updated today but created earlier never showed up under
`Updated At -> is -> today`. See issue #9316."""
def test_get_method_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": "2026-06-26"}
filter_updated_at(params, issue_filter, method="GET")
# The filter must key on updated_at, not created_at. A bare date goes through
# date_filter's single-value branch, which appends a lookup suffix
# (updated_at__date__contains), so match on the prefix rather than the exact key.
assert any(key.startswith("updated_at__date") for key in issue_filter)
assert not any("created_at" in key for key in issue_filter)
def test_get_method_with_csv_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": "2026-06-25,2026-06-26"}
filter_updated_at(params, issue_filter, method="GET")
assert all("updated_at" in key for key in issue_filter)
assert not any("created_at" in key for key in issue_filter)
def test_post_method_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": ["2026-06-26"]}
filter_updated_at(params, issue_filter, method="POST")
assert all("updated_at" in key for key in issue_filter)
assert not any("created_at" in key for key in issue_filter)
def test_get_method_with_prefix_targets_updated_at_column(self):
issue_filter = {}
params = {"updated_at": "2026-06-26"}
filter_updated_at(params, issue_filter, method="GET", prefix="cycle_issue__")
keys = list(issue_filter)
assert any("cycle_issue__updated_at" in k for k in keys)
assert not any("created_at" in k for k in keys)
def test_empty_get_filter_is_noop(self):
issue_filter = {}
params = {"updated_at": ""}
filter_updated_at(params, issue_filter, method="GET")
assert issue_filter == {}

View File

@@ -0,0 +1,132 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Regression tests for order_by injection on the external REST API.
Covers GHSA-p885-6jpg-cr2p: the external-API project list and work-item
list endpoints passed a raw ``order_by`` query parameter to Django's
``.order_by()``. Because Django resolves ``__``-separated relational paths,
an attacker could order by sensitive columns on related tables
(``created_by__password``, ``created_by__token``, ``created_by__email``,
``workspace__owner__password`` ...) to build a blind ordering oracle, or
crash the endpoint (HTTP 500) with an unknown field.
The fix routes both endpoints through ``sanitize_order_by()`` with the
appropriate allowlist. These tests assert that the two allowlists used by
those endpoints neutralise the disclosed attack strings and preserve every
legitimate ordering value.
"""
import pytest
from plane.utils.order_queryset import (
ISSUE_ORDER_BY_ALLOWLIST,
PROJECT_ORDER_BY_ALLOWLIST,
sanitize_order_by,
)
# Relational-traversal payloads from the advisory PoC plus common variants.
INJECTION_PAYLOADS = [
"created_by__password",
"created_by__token",
"created_by__email",
"-created_by__password",
"workspace__owner__password",
"updated_by__password",
"not_a_field",
"id; drop table",
"--created_at", # malformed double-dash prefix
]
@pytest.mark.unit
class TestProjectOrderBySanitization:
"""order_by sanitization for GET /api/v1/workspaces/<slug>/projects/."""
DEFAULT = "sort_order"
@pytest.mark.parametrize("payload", INJECTION_PAYLOADS)
def test_injection_payload_falls_back_to_default(self, payload):
"""Any non-allowlisted / relational value is replaced with the
endpoint's safe default instead of reaching .order_by()."""
assert (
sanitize_order_by(payload, PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)
@pytest.mark.parametrize(
"value",
["created_at", "updated_at", "name", "network", "sort_order"],
)
def test_legitimate_ascending_values_pass_through(self, value):
assert (
sanitize_order_by(value, PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== value
)
@pytest.mark.parametrize(
"value",
["-created_at", "-updated_at", "-name", "-network", "-sort_order"],
)
def test_legitimate_descending_values_pass_through(self, value):
assert (
sanitize_order_by(value, PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== value
)
def test_empty_value_uses_default(self):
assert (
sanitize_order_by("", PROJECT_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)
@pytest.mark.unit
class TestIssueOrderBySanitization:
"""order_by sanitization for
GET /api/v1/workspaces/<slug>/projects/<project_id>/issues/."""
DEFAULT = "-created_at"
@pytest.mark.parametrize("payload", INJECTION_PAYLOADS)
def test_injection_payload_falls_back_to_default(self, payload):
assert (
sanitize_order_by(payload, ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)
@pytest.mark.parametrize(
"value",
[
"created_at",
"updated_at",
"sequence_id",
"sort_order",
"target_date",
"start_date",
"priority",
"state__name",
"state__group",
"assignees__first_name",
"labels__name",
],
)
def test_legitimate_values_pass_through(self, value):
"""Every value the endpoint's branch logic special-cases must survive
sanitization, otherwise legitimate ordering would silently break."""
assert (
sanitize_order_by(value, ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== value
)
# Descending variant is equally valid.
assert (
sanitize_order_by(f"-{value}", ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== f"-{value}"
)
def test_default_is_preserved_for_missing_param(self):
assert (
sanitize_order_by(None, ISSUE_ORDER_BY_ALLOWLIST, default=self.DEFAULT)
== self.DEFAULT
)

View File

@@ -0,0 +1,65 @@
# 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.order_queryset import ISSUE_GROUP_BY_ALLOWLIST, sanitize_order_by
@pytest.mark.unit
class TestIssueGroupByAllowlist:
"""Regression tests for GHSA-wwgj-929g-42cm.
ISSUE_GROUP_BY_ALLOWLIST must contain exactly the field names that
`issue_group_values()` (plane/utils/grouper.py and
plane/space/utils/grouper.py) knows how to resolve safely. Anything
outside this set must never reach the ORM as a raw field name via
GroupedOffsetPaginator/SubGroupedOffsetPaginator.
"""
def test_allowlist_matches_known_safe_group_fields(self):
expected = {
"state_id",
"state__group",
"priority",
"labels__id",
"assignees__id",
"issue_module__module_id",
"cycle_id",
"project_id",
"created_by",
"target_date",
"start_date",
}
assert ISSUE_GROUP_BY_ALLOWLIST == frozenset(expected)
def test_allowlist_rejects_injection_style_fields(self):
# These are exactly the kind of values GHSA-wwgj-929g-42cm's PoC used
# to trigger a 500 / force a blind relational-traversal oracle.
dangerous_values = [
"not_a_field",
"created_by__password",
"workspace__secret_key",
"assignees__password",
"id",
"",
None,
]
for value in dangerous_values:
assert value not in ISSUE_GROUP_BY_ALLOWLIST
@pytest.mark.unit
class TestSanitizeOrderByStillWorks:
"""Sanity check that the pre-existing order_by sanitizer (GHSA-2r95 /
GHSA-w45q) is untouched by this change."""
def test_valid_field_passes_through(self):
allowed = frozenset({"created_at", "priority"})
assert sanitize_order_by("priority", allowed) == "priority"
assert sanitize_order_by("-created_at", allowed) == "-created_at"
def test_invalid_field_falls_back_to_default(self):
allowed = frozenset({"created_at"})
assert sanitize_order_by("not_a_field", allowed, default="-created_at") == "-created_at"

View File

@@ -0,0 +1,123 @@
# 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 django.test import RequestFactory
from rest_framework.exceptions import ParseError
from rest_framework.request import Request
from plane.utils.paginator import BasePaginator, Cursor, CursorResult
class _StubGroupedPaginator:
"""Stand-in for GroupedOffsetPaginator/SubGroupedOffsetPaginator that
records its constructor kwargs without touching the DB/ORM. The security
property under test lives entirely in BasePaginator.paginate() — by the
time a real paginator class would run a query, the field name has
already been validated (or rejected)."""
def __init__(self, **kwargs):
self.kwargs = kwargs
def get_result(self, limit, cursor):
return CursorResult(
results=[],
next=Cursor(limit, 1, False, False),
prev=Cursor(limit, -1, True, False),
hits=0,
max_hits=0,
)
def process_results(self, results):
return results
def _make_request(**params):
django_request = RequestFactory().get("/fake-url/", data=params)
return Request(django_request)
@pytest.mark.unit
class TestPaginateGroupByValidation:
"""Regression tests for GHSA-wwgj-929g-42cm.
BasePaginator.paginate() is the single chokepoint all group_by/sub_group_by
call sites (public Spaces endpoint + 5 authenticated issue/cycle/module/
workspace endpoints) funnel through. It must reject any field name outside
ISSUE_GROUP_BY_ALLOWLIST with a 400 (ParseError), before the value ever
reaches a real paginator's F()/.values()/.order_by()/Window partition_by.
"""
def test_invalid_group_by_raises_parse_error(self):
request = _make_request(group_by="created_by__password")
with pytest.raises(ParseError):
BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
group_by_field_name="created_by__password",
group_by_fields=[],
count_filter=None,
)
def test_invalid_sub_group_by_raises_parse_error(self):
# A valid group_by paired with an invalid sub_group_by must still be
# rejected — the PoC in the advisory used exactly this combination
# (group_by=state_id&sub_group_by=created_by__password).
request = _make_request(group_by="priority", sub_group_by="workspace__secret_key")
with pytest.raises(ParseError):
BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
group_by_field_name="priority",
group_by_fields=[],
sub_group_by_field_name="workspace__secret_key",
sub_group_by_fields=[],
count_filter=None,
)
def test_unrecognised_field_never_reaches_paginator_constructor(self):
# Belt-and-braces: assert the stub paginator is never even
# instantiated for a rejected field name.
request = _make_request(group_by="not_a_field")
class _ExplodingPaginator:
def __init__(self, **kwargs):
raise AssertionError("paginator_cls must not be constructed for an invalid group_by field")
with pytest.raises(ParseError):
BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_ExplodingPaginator,
group_by_field_name="not_a_field",
group_by_fields=[],
count_filter=None,
)
def test_valid_group_by_and_sub_group_by_pass_through(self):
request = _make_request(group_by="priority", sub_group_by="state_id")
response = BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
group_by_field_name="priority",
group_by_fields=[],
sub_group_by_field_name="state_id",
sub_group_by_fields=[],
count_filter=None,
)
assert response.data["grouped_by"] == "priority"
assert response.data["sub_grouped_by"] == "state_id"
def test_no_group_by_is_unaffected(self):
# Plain (non-grouped) pagination must not be touched by this fix.
request = _make_request()
response = BasePaginator().paginate(
request=request,
queryset=None,
paginator_cls=_StubGroupedPaginator,
)
assert response.data["grouped_by"] is None

View File

@@ -0,0 +1,265 @@
# 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"

View File

@@ -0,0 +1,53 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import uuid
import pytest
from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer
@pytest.mark.unit
class TestUUIDUtils:
"""Test the UUID utilities"""
def test_is_valid_uuid_with_valid_uuid(self):
"""Test is_valid_uuid with a valid UUID"""
# Generate a valid UUID
valid_uuid = str(uuid.uuid4())
assert is_valid_uuid(valid_uuid) is True
def test_is_valid_uuid_with_invalid_uuid(self):
"""Test is_valid_uuid with invalid UUID strings"""
# Test with different invalid formats
assert is_valid_uuid("not-a-uuid") is False
assert is_valid_uuid("123456789") is False
assert is_valid_uuid("") is False
assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1
def test_convert_uuid_to_integer(self):
"""Test convert_uuid_to_integer function"""
# Create a known UUID
test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
# Convert to integer
result = convert_uuid_to_integer(test_uuid)
# Check that the result is an integer
assert isinstance(result, int)
# Ensure consistent results with the same input
assert convert_uuid_to_integer(test_uuid) == result
# Different UUIDs should produce different integers
different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
assert convert_uuid_to_integer(different_uuid) != result
def test_convert_uuid_to_integer_string_input(self):
"""Test convert_uuid_to_integer handles string UUID"""
# Test with a UUID string
test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
test_uuid = uuid.UUID(test_uuid_str)
# Should get the same result whether passing UUID or string
assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str)

View File

@@ -0,0 +1,115 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from io import BytesIO
import pytest
from openpyxl import load_workbook
from plane.utils.exporters.formatters import XLSXFormatter as SchemaXLSXFormatter
from plane.utils.porters.formatters import XLSXFormatter as PorterXLSXFormatter
# Characters that trigger formula evaluation in spreadsheet applications.
# See: https://owasp.org/www-community/attacks/CSV_Injection
FORMULA_TRIGGERS = ["=", "+", "-", "@", "\t", "\r", "\n"]
HYPERLINK_PAYLOAD = '=HYPERLINK("https://example.com/poc","click")'
def _load_cells(xlsx_bytes):
"""Load XLSX bytes and return the active worksheet's cells (formulas not evaluated)."""
wb = load_workbook(filename=BytesIO(xlsx_bytes))
return wb.active
@pytest.mark.unit
class TestPorterXLSXFormatterSanitization:
"""XLSX issue exports must not store user-controlled values as formula cells."""
def test_formula_payload_is_stored_as_text(self):
content = PorterXLSXFormatter().encode([{"name": HYPERLINK_PAYLOAD}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + HYPERLINK_PAYLOAD
@pytest.mark.parametrize("trigger", FORMULA_TRIGGERS)
def test_all_formula_trigger_characters_are_escaped(self, trigger):
payload = trigger + "1+2"
content = PorterXLSXFormatter().encode([{"name": payload}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + payload
def test_safe_string_is_unchanged(self):
content = PorterXLSXFormatter().encode([{"name": "Fix login bug"}])
ws = _load_cells(content)
assert ws.cell(row=2, column=1).value == "Fix login bug"
def test_non_string_values_are_preserved(self):
content = PorterXLSXFormatter().encode([{"estimate": 5}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.value == 5
assert cell.data_type == "n"
def test_list_value_joining_to_formula_is_escaped(self):
content = PorterXLSXFormatter().encode([{"labels": ["=cmd", "bug"]}])
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'=cmd, bug"
def test_headers_are_sanitized(self):
content = PorterXLSXFormatter().encode([{"=evil_header": "value"}])
ws = _load_cells(content)
header = ws.cell(row=1, column=1)
assert header.data_type != "f"
assert header.value == "'=Evil Header"
class _FakeField:
def __init__(self, label=None):
self.label = label
class _FakeSchema:
_declared_fields = {
"name": _FakeField("Name"),
"estimate": _FakeField("=Estimate"),
}
@pytest.mark.unit
class TestSchemaXLSXFormatterSanitization:
"""Schema-based XLSX exports must not store user-controlled values as formula cells."""
def test_formula_payload_is_stored_as_text(self):
_, content = SchemaXLSXFormatter().format("export", [{"name": HYPERLINK_PAYLOAD, "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + HYPERLINK_PAYLOAD
@pytest.mark.parametrize("trigger", FORMULA_TRIGGERS)
def test_all_formula_trigger_characters_are_escaped(self, trigger):
payload = trigger + "1+2"
_, content = SchemaXLSXFormatter().format("export", [{"name": payload, "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
cell = ws.cell(row=2, column=1)
assert cell.data_type != "f"
assert cell.value == "'" + payload
def test_safe_string_is_unchanged(self):
_, content = SchemaXLSXFormatter().format("export", [{"name": "Fix login bug", "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
assert ws.cell(row=2, column=1).value == "Fix login bug"
def test_headers_are_sanitized(self):
_, content = SchemaXLSXFormatter().format("export", [{"name": "ok", "estimate": 5}], _FakeSchema)
ws = _load_cells(content)
header = ws.cell(row=1, column=2)
assert header.data_type != "f"
assert header.value == "'=Estimate"