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
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:
4
apps/api/plane/tests/unit/utils/__init__.py
Normal file
4
apps/api/plane/tests/unit/utils/__init__.py
Normal 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.
|
||||
|
||||
@@ -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"
|
||||
105
apps/api/plane/tests/unit/utils/test_issue_datetime_filters.py
Normal file
105
apps/api/plane/tests/unit/utils/test_issue_datetime_filters.py
Normal 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"}
|
||||
53
apps/api/plane/tests/unit/utils/test_issue_filters.py
Normal file
53
apps/api/plane/tests/unit/utils/test_issue_filters.py
Normal 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 == {}
|
||||
132
apps/api/plane/tests/unit/utils/test_order_by_sanitize.py
Normal file
132
apps/api/plane/tests/unit/utils/test_order_by_sanitize.py
Normal 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
|
||||
)
|
||||
65
apps/api/plane/tests/unit/utils/test_order_queryset.py
Normal file
65
apps/api/plane/tests/unit/utils/test_order_queryset.py
Normal 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"
|
||||
123
apps/api/plane/tests/unit/utils/test_paginator.py
Normal file
123
apps/api/plane/tests/unit/utils/test_paginator.py
Normal 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
|
||||
265
apps/api/plane/tests/unit/utils/test_url.py
Normal file
265
apps/api/plane/tests/unit/utils/test_url.py
Normal 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"
|
||||
53
apps/api/plane/tests/unit/utils/test_uuid.py
Normal file
53
apps/api/plane/tests/unit/utils/test_uuid.py
Normal 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)
|
||||
115
apps/api/plane/tests/unit/utils/test_xlsx_export_sanitization.py
Normal file
115
apps/api/plane/tests/unit/utils/test_xlsx_export_sanitization.py
Normal 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"
|
||||
Reference in New Issue
Block a user