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/__init__.py
Normal file
4
apps/api/plane/tests/unit/__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.
|
||||
|
||||
143
apps/api/plane/tests/unit/bg_tasks/test_cleanup_task.py
Normal file
143
apps/api/plane/tests/unit/bg_tasks/test_cleanup_task.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Unit tests for the log cleanup tasks.
|
||||
|
||||
Verifies that API activity logs past the retention window are hard-deleted
|
||||
(removed from PostgreSQL, not soft-deleted) and that fresh logs are retained.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from plane.bgtasks.cleanup_task import (
|
||||
delete_api_logs,
|
||||
delete_email_notification_logs,
|
||||
delete_webhook_logs,
|
||||
process_cleanup_task,
|
||||
)
|
||||
from plane.db.models import APIActivityLog, EmailNotificationLog, WebhookLog
|
||||
from plane.tests.factories import UserFactory, WorkspaceFactory
|
||||
|
||||
|
||||
def _make_api_log(created_at):
|
||||
log = APIActivityLog.objects.create(
|
||||
token_identifier="hashed-token",
|
||||
path="/api/v1/workspaces/",
|
||||
method="GET",
|
||||
response_code=200,
|
||||
)
|
||||
# created_at is auto-set on insert, so backdate it explicitly afterwards.
|
||||
APIActivityLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
|
||||
return log
|
||||
|
||||
|
||||
def _make_webhook_log(workspace, created_at):
|
||||
log = WebhookLog.objects.create(
|
||||
workspace=workspace,
|
||||
webhook=uuid4(),
|
||||
event_type="issue",
|
||||
request_method="POST",
|
||||
response_status="200",
|
||||
)
|
||||
WebhookLog.all_objects.filter(pk=log.pk).update(created_at=created_at)
|
||||
return log
|
||||
|
||||
|
||||
def _make_email_log(user, sent_at):
|
||||
return EmailNotificationLog.objects.create(
|
||||
receiver=user,
|
||||
triggered_by=user,
|
||||
entity_name="issue",
|
||||
entity="issue",
|
||||
sent_at=sent_at,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
class TestDeleteApiLogs:
|
||||
def test_expired_logs_are_hard_deleted(self):
|
||||
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
|
||||
expired = _make_api_log(timezone.now() - timedelta(days=retention_days + 1))
|
||||
|
||||
delete_api_logs()
|
||||
|
||||
# Hard delete: the row must be gone even from the unfiltered manager.
|
||||
assert not APIActivityLog.all_objects.filter(pk=expired.pk).exists()
|
||||
|
||||
def test_recent_logs_are_retained(self):
|
||||
retention_days = settings.API_ACTIVITY_LOG_RETENTION_DAYS
|
||||
recent = _make_api_log(timezone.now() - timedelta(days=retention_days - 1))
|
||||
|
||||
delete_api_logs()
|
||||
|
||||
assert APIActivityLog.all_objects.filter(pk=recent.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
class TestDeleteWebhookLogs:
|
||||
def test_expired_logs_are_hard_deleted(self):
|
||||
workspace = WorkspaceFactory()
|
||||
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
|
||||
expired = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days + 1))
|
||||
|
||||
delete_webhook_logs()
|
||||
|
||||
assert not WebhookLog.all_objects.filter(pk=expired.pk).exists()
|
||||
|
||||
def test_recent_logs_are_retained(self):
|
||||
workspace = WorkspaceFactory()
|
||||
retention_days = settings.WEBHOOK_LOG_RETENTION_DAYS
|
||||
recent = _make_webhook_log(workspace, timezone.now() - timedelta(days=retention_days - 1))
|
||||
|
||||
delete_webhook_logs()
|
||||
|
||||
assert WebhookLog.all_objects.filter(pk=recent.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.django_db
|
||||
class TestDeleteEmailLogs:
|
||||
def test_expired_logs_are_hard_deleted(self):
|
||||
user = UserFactory()
|
||||
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
|
||||
expired = _make_email_log(user, timezone.now() - timedelta(days=retention_days + 1))
|
||||
|
||||
delete_email_notification_logs()
|
||||
|
||||
assert not EmailNotificationLog.all_objects.filter(pk=expired.pk).exists()
|
||||
|
||||
def test_recent_logs_are_retained(self):
|
||||
user = UserFactory()
|
||||
retention_days = settings.EMAIL_LOG_RETENTION_DAYS
|
||||
recent = _make_email_log(user, timezone.now() - timedelta(days=retention_days - 1))
|
||||
|
||||
delete_email_notification_logs()
|
||||
|
||||
assert EmailNotificationLog.all_objects.filter(pk=recent.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestProcessCleanupTaskErrorHandling:
|
||||
def test_batch_delete_failure_is_swallowed(self):
|
||||
"""A failing batch is logged and skipped; the run does not raise."""
|
||||
|
||||
class _BoomManager:
|
||||
@staticmethod
|
||||
def filter(**kwargs):
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
class _BoomModel:
|
||||
all_objects = _BoomManager()
|
||||
|
||||
# Should not raise even though the delete blows up.
|
||||
process_cleanup_task(lambda: iter([1, 2, 3]), _BoomModel, "Boom")
|
||||
175
apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py
Normal file
175
apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py
Normal file
@@ -0,0 +1,175 @@
|
||||
# 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.db.models import Project, ProjectMember, Issue, FileAsset
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plane.bgtasks.copy_s3_object import (
|
||||
copy_s3_objects_of_description_and_assets,
|
||||
copy_assets,
|
||||
)
|
||||
import base64
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCopyS3Objects:
|
||||
"""Test the copy_s3_objects_of_description_and_assets function"""
|
||||
|
||||
@pytest.fixture
|
||||
def project(self, create_user, workspace):
|
||||
project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace)
|
||||
|
||||
ProjectMember.objects.create(project=project, member=create_user)
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def issue(self, workspace, project):
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project_id=project.id,
|
||||
description_html='<div><image-component src="35e8b958-6ee5-43ce-ae56-fb0e776f421e"></image-component><image-component src="97988198-274f-4dfe-aa7a-4c0ffc684214"></image-component></div>', # noqa: E501
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def file_asset(self, workspace, project, issue):
|
||||
return FileAsset.objects.create(
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
asset="workspace1/test-asset-1.jpg",
|
||||
attributes={
|
||||
"name": "test-asset-1.jpg",
|
||||
"size": 100,
|
||||
"type": "image/jpeg",
|
||||
},
|
||||
id="35e8b958-6ee5-43ce-ae56-fb0e776f421e",
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_s3_objects_of_description_and_assets(
|
||||
self, mock_s3_storage, create_user, workspace, project, issue, file_asset
|
||||
):
|
||||
FileAsset.objects.create(
|
||||
issue=issue,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
asset="workspace1/test-asset-2.pdf",
|
||||
attributes={
|
||||
"name": "test-asset-2.pdf",
|
||||
"size": 100,
|
||||
"type": "application/pdf",
|
||||
},
|
||||
id="97988198-274f-4dfe-aa7a-4c0ffc684214",
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
issue.save()
|
||||
|
||||
# Set up mock S3 storage
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Mock the external service call to avoid actual HTTP requests
|
||||
with patch("plane.bgtasks.copy_s3_object.sync_with_external_service") as mock_sync:
|
||||
mock_sync.return_value = {
|
||||
"description": "test description",
|
||||
"description_binary": base64.b64encode(b"test binary").decode(),
|
||||
"description_json": {"type": "doc", "content": []},
|
||||
}
|
||||
|
||||
# Call the actual function (not .delay())
|
||||
copy_s3_objects_of_description_and_assets("ISSUE", issue.id, project.id, "test-workspace", create_user.id)
|
||||
|
||||
# Assert that copy_object was called for each asset
|
||||
assert mock_storage_instance.copy_object.call_count == 2
|
||||
|
||||
# Get the updated issue and its new assets
|
||||
updated_issue = Issue.objects.get(id=issue.id)
|
||||
new_assets = FileAsset.objects.filter(
|
||||
issue=updated_issue,
|
||||
entity_type="ISSUE_DESCRIPTION",
|
||||
)
|
||||
|
||||
# Verify new assets were created
|
||||
assert new_assets.count() == 4 # 2 original + 2 copied
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_successful(self, mock_s3_storage, workspace, project, issue, file_asset):
|
||||
"""Test successful copying of assets"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[file_asset.id],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Verify S3 copy was called
|
||||
mock_storage_instance.copy_object.assert_called_once()
|
||||
|
||||
# Verify new asset was created
|
||||
assert len(result) == 1
|
||||
new_asset_id = result[0]["new_asset_id"]
|
||||
new_asset = FileAsset.objects.get(id=new_asset_id)
|
||||
|
||||
# Verify asset properties were copied correctly
|
||||
assert new_asset.workspace == workspace
|
||||
assert new_asset.project_id == project.id
|
||||
assert new_asset.entity_type == file_asset.entity_type
|
||||
assert new_asset.attributes == file_asset.attributes
|
||||
assert new_asset.size == file_asset.size
|
||||
assert new_asset.is_uploaded is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_empty_asset_ids(self, mock_s3_storage, workspace, project, issue):
|
||||
"""Test copying with empty asset_ids list"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_storage_instance.copy_object.assert_not_called()
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("plane.bgtasks.copy_s3_object.S3Storage")
|
||||
def test_copy_assets_nonexistent_asset(self, mock_s3_storage, workspace, project, issue):
|
||||
"""Test copying with non-existent asset ID"""
|
||||
# Arrange
|
||||
mock_storage_instance = MagicMock()
|
||||
mock_s3_storage.return_value = mock_storage_instance
|
||||
non_existent_id = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
# Act
|
||||
result = copy_assets(
|
||||
entity=issue,
|
||||
entity_identifier=issue.id,
|
||||
project_id=project.id,
|
||||
asset_ids=[non_existent_id],
|
||||
user_id=issue.created_by_id,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_storage_instance.copy_object.assert_not_called()
|
||||
289
apps/api/plane/tests/unit/bg_tasks/test_ssrf_advisories.py
Normal file
289
apps/api/plane/tests/unit/bg_tasks/test_ssrf_advisories.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Per-advisory SSRF regression tests.
|
||||
|
||||
Each test reproduces a published / reported SSRF advisory scenario and asserts
|
||||
the current code blocks it. This file is the auditable map of "which advisory is
|
||||
covered where"; the lower-level mechanics (IP classification, pinning, redirect
|
||||
re-validation) are exercised in detail in ``test_url_security.py`` and
|
||||
``test_work_item_link_task.py``.
|
||||
|
||||
Advisory coverage
|
||||
-----------------
|
||||
Webhook delivery
|
||||
* GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx
|
||||
webhook URL resolves to a private/metadata/loopback IP -> TestWebhookUrlValidation
|
||||
* GHSA-75fg-f8qg-23wv CGNAT(100.64/10), 6to4, multicast missed -> TestWebhookUrlValidation
|
||||
* GHSA-6485-m23r-fx8q PATCH serializer context-key bypass -> TestWebhookPatchContextGuard
|
||||
* GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 /
|
||||
-9292-pvg4-7hvm / -fgcv-6h3f-xcx9 webhook DNS-rebinding TOCTOU -> TestWebhookRebinding
|
||||
* GHSA-6v37-328w-j2wv / -jw6g-h7h5-rfc6 / -mq87-52pf-hm3h
|
||||
webhook SSRF via HTTP redirect following -> TestWebhookRedirect
|
||||
|
||||
Work-item link unfurling / favicon
|
||||
* GHSA-8wvv-p676-hcw4 / -fv24-3845-646g / -9292-pvg4-7hvm link rebinding
|
||||
* GHSA-9fr2-pprw-pp9j / CVE-2026-39843 favicon redirect SSRF -> TestFaviconRedirect
|
||||
* GHSA-3856-6mgg-rx84 favicon DNS-rebinding -> TestFaviconRebinding
|
||||
|
||||
OAuth avatar (the still-unresolved family this change adds)
|
||||
* GHSA-cv9p-325g-wmv5 OAuth avatar redirect SSRF -> static-asset exfil
|
||||
* GHSA-hx79-5pj5-qh42 Gitea OAuth SSRF (avatar hop) -> TestOAuthAvatarSSRF
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from plane.utils.ip_address import validate_url
|
||||
from plane.bgtasks.work_item_link_task import fetch_and_encode_favicon, DEFAULT_FAVICON
|
||||
from plane.authentication.adapter.base import Adapter
|
||||
|
||||
|
||||
def _addr(ip):
|
||||
family = 6 if ":" in ip else 2
|
||||
return (family, None, None, None, (ip, 0))
|
||||
|
||||
|
||||
def _resp(status_code=200, headers=None, content=b"OK"):
|
||||
resp = MagicMock(spec=requests.Response)
|
||||
resp.status_code = status_code
|
||||
resp.headers = headers or {}
|
||||
resp.content = content
|
||||
return resp
|
||||
|
||||
|
||||
_BLOCKED = "Access to private/internal networks is not allowed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook URL validation (creation/update-time defense in depth)
|
||||
# GHSA-m3f8-q4wj-9grv / CVE-2026-30242 / GHSA-75vf-hh93-h7mx / GHSA-75fg-f8qg-23wv
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestWebhookUrlValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"169.254.169.254", # AWS/GCP metadata (CVE-2026-30242 PoC)
|
||||
"127.0.0.1", # loopback
|
||||
"10.0.0.1", # private
|
||||
"172.16.0.1", # private
|
||||
"192.168.0.1", # private
|
||||
"::1", # IPv6 loopback
|
||||
"100.64.0.1", # CGNAT / RFC 6598 (GHSA-75fg)
|
||||
"2002:7f00:1::", # 6to4 -> 127.0.0.1 (GHSA-75fg)
|
||||
"224.0.0.1", # multicast (GHSA-75fg)
|
||||
"::ffff:169.254.169.254", # IPv4-mapped metadata
|
||||
],
|
||||
)
|
||||
def test_webhook_url_to_internal_is_rejected(self, ip):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr(ip)]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url(
|
||||
"https://attacker.example.com/hook",
|
||||
allowed_ips=[],
|
||||
allowed_hosts=[],
|
||||
)
|
||||
|
||||
def test_legitimate_public_webhook_url_passes(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr("93.184.216.34")]
|
||||
# Should not raise
|
||||
validate_url("https://hooks.example.com/x", allowed_ips=[], allowed_hosts=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GHSA-6485-m23r-fx8q — PATCH serializer context-key bypass
|
||||
# The PATCH view now passes context={"request": request}; with the request in
|
||||
# context the disallowed-domain / request-host loop-back guard runs on update.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestWebhookPatchContextGuard:
|
||||
def _serializer_with_request(self, host):
|
||||
from plane.app.serializers import WebhookSerializer
|
||||
|
||||
request = MagicMock()
|
||||
request.get_host.return_value = host
|
||||
return WebhookSerializer(context={"request": request})
|
||||
|
||||
def test_request_host_is_blocked_when_context_present(self):
|
||||
# A webhook pointed at the instance's own host must be rejected — this
|
||||
# is the guard the PATCH endpoint silently skipped with the wrong key.
|
||||
ser = self._serializer_with_request("myplane.example.com")
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr("93.184.216.34")] # public, so only the host guard can block
|
||||
with pytest.raises(Exception, match="not allowed"):
|
||||
ser._validate_webhook_url("https://myplane.example.com/hook")
|
||||
|
||||
def test_unrelated_public_host_passes_with_context(self):
|
||||
ser = self._serializer_with_request("myplane.example.com")
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr("93.184.216.34")]
|
||||
ser._validate_webhook_url("https://hooks.partner.com/x") # should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook DNS-rebinding TOCTOU
|
||||
# GHSA-whh3-5g95-4qhc / -4mjx-q738-87cf / -6p39-x6q9-h3g5 / -9292 / -fgcv
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestWebhookRebinding:
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_connection_pinned_to_validated_ip(self, mock_resolve, mock_session_cls):
|
||||
from plane.utils.url_security import pinned_fetch
|
||||
|
||||
# The validator resolves to a public IP; the connection must go to THAT
|
||||
# IP literal, so a rebind to an internal IP after validation is moot.
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
|
||||
|
||||
_, url = session.request.call_args.args
|
||||
assert url == "https://93.184.216.34:443/hook" # IP literal -> no 2nd DNS lookup
|
||||
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_rebind_to_internal_is_blocked(self, mock_resolve):
|
||||
from plane.utils.url_security import pinned_fetch
|
||||
|
||||
mock_resolve.side_effect = ValueError(_BLOCKED)
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
pinned_fetch("POST", "https://rebinder.example.com/hook", json={})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook SSRF via HTTP redirect following
|
||||
# GHSA-6v37-328w-j2wv / GHSA-jw6g-h7h5-rfc6 / GHSA-mq87-52pf-hm3h
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestWebhookRedirect:
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_webhook_does_not_follow_redirects(self, mock_resolve, mock_session_cls):
|
||||
from plane.utils.url_security import pinned_fetch
|
||||
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
# The endpoint replies 302 -> internal; the webhook client must NOT follow.
|
||||
session.request.return_value = _resp(
|
||||
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
|
||||
)
|
||||
|
||||
resp = pinned_fetch("POST", "https://hooks.example.com/x", json={})
|
||||
|
||||
# The 3xx is returned as-is and only ONE request was made (no follow).
|
||||
assert resp.status_code == 302
|
||||
assert session.request.call_count == 1
|
||||
assert session.request.call_args.kwargs["allow_redirects"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Favicon redirect SSRF — GHSA-9fr2-pprw-pp9j / CVE-2026-39843
|
||||
# A <link rel=icon> whose href is public but 30x-redirects to a private IP must
|
||||
# NOT exfiltrate internal content; the favicon falls back to the default icon.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestFaviconRedirect:
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
|
||||
def test_favicon_redirect_to_private_returns_default(
|
||||
self, mock_pre_dns, mock_resolve, mock_session_cls
|
||||
):
|
||||
# validate_url_ip pre-check (work_item_link_task.socket) sees a public IP.
|
||||
mock_pre_dns.return_value = [_addr("93.184.216.34")]
|
||||
# safe_get: hop0 public, hop1 (redirect target) blocked.
|
||||
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(
|
||||
302, headers={"Location": "http://192.168.8.14:8081/"}
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(
|
||||
'<link rel="icon" href="https://redirector.example.com/x">',
|
||||
"html.parser",
|
||||
)
|
||||
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
|
||||
|
||||
# Blocked -> default icon, NOT the internal response body.
|
||||
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Favicon DNS rebinding — GHSA-3856-6mgg-rx84
|
||||
# The favicon host passes the pre-check (public) but resolves to a private IP at
|
||||
# fetch time; the pinned client re-resolves+validates and blocks it.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestFaviconRebinding:
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
@patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo")
|
||||
def test_favicon_rebind_to_private_returns_default(
|
||||
self, mock_pre_dns, mock_resolve, mock_session_cls
|
||||
):
|
||||
mock_pre_dns.return_value = [_addr("93.184.216.34")] # pre-check: public
|
||||
mock_resolve.side_effect = ValueError(_BLOCKED) # fetch-time: rebound -> blocked
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
soup = BeautifulSoup(
|
||||
'<link rel="icon" href="http://rebind.example.com:8443/">',
|
||||
"html.parser",
|
||||
)
|
||||
result = fetch_and_encode_favicon({}, soup, "https://attacker.example.com")
|
||||
assert result["favicon_base64"] == f"data:image/svg+xml;base64,{DEFAULT_FAVICON}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth avatar SSRF — GHSA-cv9p-325g-wmv5 / GHSA-hx79-5pj5-qh42 (avatar hop)
|
||||
# download_and_upload_avatar must reject avatar URLs that point at, or redirect
|
||||
# to, internal addresses, returning None (no fetch stored as an asset).
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestOAuthAvatarSSRF:
|
||||
def _adapter(self):
|
||||
return Adapter(request=MagicMock(), provider="gitea")
|
||||
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_avatar_to_internal_ip_is_blocked(self, mock_resolve):
|
||||
mock_resolve.side_effect = ValueError(_BLOCKED)
|
||||
result = self._adapter().download_and_upload_avatar(
|
||||
"http://169.254.169.254/latest/meta-data/", user=MagicMock()
|
||||
)
|
||||
assert result is None
|
||||
mock_resolve.assert_called() # SSRF validation was actually attempted
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_avatar_redirect_to_internal_is_blocked(self, mock_resolve, mock_session_cls):
|
||||
# Public avatar URL that 302-redirects to the metadata service.
|
||||
mock_resolve.side_effect = [["93.184.216.34"], ValueError(_BLOCKED)]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(
|
||||
302, headers={"Location": "http://169.254.169.254/imds"}
|
||||
)
|
||||
result = self._adapter().download_and_upload_avatar(
|
||||
"https://evil.example.com/avatar", user=MagicMock()
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@patch("plane.authentication.adapter.base.pinned_fetch_following_redirects")
|
||||
def test_avatar_uses_ssrf_safe_client(self, mock_fetch):
|
||||
# Wiring guard: the avatar path must go through the pinned client, never
|
||||
# a raw requests.get (which would re-resolve + follow redirects freely).
|
||||
mock_fetch.side_effect = ValueError(_BLOCKED)
|
||||
result = self._adapter().download_and_upload_avatar(
|
||||
"https://cdn.example.com/a.png", user=MagicMock()
|
||||
)
|
||||
assert result is None
|
||||
assert mock_fetch.call_args.args[0] == "GET"
|
||||
assert mock_fetch.call_args.args[1] == "https://cdn.example.com/a.png"
|
||||
395
apps/api/plane/tests/unit/bg_tasks/test_url_security.py
Normal file
395
apps/api/plane/tests/unit/bg_tasks/test_url_security.py
Normal file
@@ -0,0 +1,395 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
SSRF-protection tests for the webhook + link-unfurling clusters (advisories A/B/C):
|
||||
|
||||
A — incomplete private-IP validation -> is_blocked_ip hardening
|
||||
B — DNS-rebinding TOCTOU -> connection pinned to the validated IP
|
||||
C — SSRF via HTTP redirect following -> redirects re-resolved/re-validated/re-pinned
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from plane.utils.ip_address import is_blocked_ip, resolve_and_validate, validate_url
|
||||
from plane.utils.url_security import (
|
||||
PinnedIPAdapter,
|
||||
pinned_fetch,
|
||||
pinned_fetch_following_redirects,
|
||||
)
|
||||
|
||||
|
||||
def _addr(ip):
|
||||
"""Build a single getaddrinfo-style result tuple for an IP string."""
|
||||
family = 6 if ":" in ip else 2
|
||||
return (family, None, None, None, (ip, 0))
|
||||
|
||||
|
||||
def _resp(status_code=200, headers=None, content=b"OK"):
|
||||
resp = MagicMock(spec=requests.Response)
|
||||
resp.status_code = status_code
|
||||
resp.headers = headers or {}
|
||||
resp.content = content
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster A — robust IP classification (verified on Python 3.12 semantics)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestIsBlockedIp:
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"127.0.0.1", # loopback
|
||||
"10.0.0.1", # private
|
||||
"192.168.1.1", # private
|
||||
"172.16.0.1", # private
|
||||
"169.254.169.254", # link-local / cloud metadata
|
||||
"0.0.0.0", # unspecified
|
||||
"100.64.0.1", # CGNAT / shared (NOT is_private on py3.12!)
|
||||
"224.0.0.1", # multicast
|
||||
"239.255.255.250", # SSDP multicast
|
||||
"255.255.255.255", # limited broadcast
|
||||
"::1", # IPv6 loopback
|
||||
"fe80::1", # IPv6 link-local
|
||||
"fc00::1", # IPv6 unique-local
|
||||
"ff02::1", # IPv6 multicast
|
||||
"::ffff:127.0.0.1", # IPv4-mapped loopback
|
||||
"::ffff:169.254.169.254", # IPv4-mapped metadata
|
||||
"::ffff:10.0.0.1", # IPv4-mapped private
|
||||
"64:ff9b::7f00:1", # NAT64 well-known prefix embedding 127.0.0.1
|
||||
"64:ff9b::a9fe:a9fe", # NAT64 well-known prefix embedding 169.254.169.254
|
||||
"64:ff9b:1::7f00:1", # NAT64 local-use prefix (RFC 8215, /48)
|
||||
"64:ff9b:1:0100::1", # NAT64 local-use prefix, outside the /96 subset
|
||||
"2002:7f00:1::", # 6to4 embedding 127.0.0.1
|
||||
"2002:a00:1::", # 6to4 embedding 10.0.0.1
|
||||
],
|
||||
)
|
||||
def test_blocks_internal(self, ip):
|
||||
assert is_blocked_ip(ipaddress.ip_address(ip)) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"8.8.8.8",
|
||||
"93.184.216.34",
|
||||
"1.1.1.1",
|
||||
"2606:4700:4700::1111", # public IPv6 (Cloudflare)
|
||||
"2001:4860:4860::8888", # public IPv6 (Google)
|
||||
],
|
||||
)
|
||||
def test_allows_public(self, ip):
|
||||
assert is_blocked_ip(ipaddress.ip_address(ip)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_and_validate — resolution + validation, returns IPs to pin
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestResolveAndValidate:
|
||||
def test_returns_public_ips(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr("93.184.216.34")]
|
||||
assert resolve_and_validate("example.com") == ["93.184.216.34"]
|
||||
|
||||
def test_raises_on_private(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr("10.0.0.1")]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
resolve_and_validate("internal.example.com")
|
||||
|
||||
def test_raises_if_any_resolved_ip_is_private(self):
|
||||
# A hostname that resolves to BOTH a public and a private IP must fail
|
||||
# closed — an attacker could otherwise steer the connection to the
|
||||
# private one.
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr("93.184.216.34"), _addr("127.0.0.1")]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
resolve_and_validate("rebinder.example.com")
|
||||
|
||||
def test_allowlist_permits_private(self):
|
||||
allowed = [ipaddress.ip_network("10.0.0.0/8")]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr("10.0.0.5")]
|
||||
assert resolve_and_validate("internal", allowed_ips=allowed) == ["10.0.0.5"]
|
||||
|
||||
def test_unresolvable_raises(self):
|
||||
import socket as _socket
|
||||
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.side_effect = _socket.gaierror()
|
||||
with pytest.raises(ValueError, match="could not be resolved"):
|
||||
resolve_and_validate("nope.invalid")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster B — connection pinned to the validated IP (DNS-rebinding TOCTOU)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestPinnedFetch:
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_connects_to_validated_ip_not_hostname(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
pinned_fetch("POST", "https://example.com/hook", json={"a": 1})
|
||||
|
||||
# The socket target is the validated IP literal — there is no second
|
||||
# DNS lookup, so a rebind between validation and connection is
|
||||
# impossible.
|
||||
method, url = session.request.call_args.args
|
||||
kwargs = session.request.call_args.kwargs
|
||||
assert method == "POST"
|
||||
assert url == "https://93.184.216.34:443/hook"
|
||||
# Host header + TLS SNI still target the real hostname.
|
||||
assert kwargs["headers"]["Host"] == "example.com"
|
||||
assert kwargs["allow_redirects"] is False
|
||||
assert kwargs["verify"] is True
|
||||
assert kwargs["json"] == {"a": 1}
|
||||
# Ambient proxy/env must not be honoured (would bypass pinning).
|
||||
assert session.trust_env is False
|
||||
assert kwargs["proxies"] == {"http": None, "https": None}
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_non_default_port_in_host_header(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
pinned_fetch("GET", "http://example.com:8080/x")
|
||||
|
||||
_, url = session.request.call_args.args
|
||||
kwargs = session.request.call_args.kwargs
|
||||
assert url == "http://93.184.216.34:8080/x"
|
||||
assert kwargs["headers"]["Host"] == "example.com:8080"
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_ipv6_validated_ip_is_bracketed(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["2606:4700:4700::1111"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
pinned_fetch("GET", "https://example.com/x")
|
||||
|
||||
_, url = session.request.call_args.args
|
||||
assert url == "https://[2606:4700:4700::1111]:443/x"
|
||||
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_blocked_target_raises_before_any_request(self, mock_resolve):
|
||||
mock_resolve.side_effect = ValueError(
|
||||
"Access to private/internal networks is not allowed"
|
||||
)
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
pinned_fetch("POST", "https://attacker.com/hook")
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_tries_next_ip_on_connection_error(self, mock_resolve, mock_session_cls):
|
||||
# Dual-stack host: first validated IP is unreachable, second works.
|
||||
mock_resolve.return_value = ["93.184.216.34", "93.184.216.35"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.side_effect = [
|
||||
requests.ConnectionError("down"),
|
||||
_resp(200),
|
||||
]
|
||||
resp = pinned_fetch("GET", "https://example.com/x")
|
||||
assert resp.status_code == 200
|
||||
assert session.request.call_count == 2
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_allowed_host_skips_block_check_but_still_pins(self, mock_resolve, mock_session_cls):
|
||||
# Trusted host (e.g. internal docker service) whose IP is private: the
|
||||
# block check is skipped, but the connection is STILL pinned to the
|
||||
# resolved IP so it cannot be rebound to a different internal target.
|
||||
mock_resolve.return_value = ["172.18.0.5"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
pinned_fetch(
|
||||
"POST",
|
||||
"http://silo:3000/hook",
|
||||
allowed_hosts=["silo"],
|
||||
json={"x": 1},
|
||||
)
|
||||
|
||||
# Resolution happens with require_safe=False (trusted, skip block check).
|
||||
assert mock_resolve.call_args.kwargs.get("require_safe") is False
|
||||
# ...but the connection is pinned to the resolved IP literal, Host=silo.
|
||||
_, url = session.request.call_args.args
|
||||
assert url == "http://172.18.0.5:3000/hook"
|
||||
assert session.request.call_args.kwargs["headers"]["Host"] == "silo:3000"
|
||||
assert session.request.call_args.kwargs["allow_redirects"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster C — redirects re-resolved / re-validated / re-pinned each hop
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestPinnedFetchRedirects:
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_no_redirect_returns_response(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
|
||||
assert resp.status_code == 200
|
||||
assert final == "https://example.com/a"
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_follows_and_revalidates_each_hop(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.side_effect = [
|
||||
_resp(301, headers={"Location": "https://other.com/page"}),
|
||||
_resp(200),
|
||||
]
|
||||
|
||||
resp, final = pinned_fetch_following_redirects("GET", "https://example.com/a")
|
||||
assert resp.status_code == 200
|
||||
assert final == "https://other.com/page"
|
||||
# Re-resolved (and thus re-validated + re-pinned) on each hop.
|
||||
assert mock_resolve.call_count == 2
|
||||
assert mock_resolve.call_args_list[0].args[0] == "example.com"
|
||||
assert mock_resolve.call_args_list[1].args[0] == "other.com"
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
|
||||
# First hop resolves public; redirect target resolves private -> blocked
|
||||
mock_resolve.side_effect = [
|
||||
["93.184.216.34"],
|
||||
ValueError("Access to private/internal networks is not allowed"),
|
||||
]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(
|
||||
302, headers={"Location": "http://169.254.169.254/latest/meta-data/"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
pinned_fetch_following_redirects("GET", "https://evil.com/r")
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_too_many_redirects(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(
|
||||
302, headers={"Location": "https://example.com/loop"}
|
||||
)
|
||||
with pytest.raises(requests.TooManyRedirects):
|
||||
pinned_fetch_following_redirects(
|
||||
"GET", "https://example.com/start", max_redirects=3
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PinnedIPAdapter — TLS server_hostname injection (cert verified vs hostname)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestPinnedIPAdapter:
|
||||
def test_injects_server_hostname_into_pool(self):
|
||||
adapter = PinnedIPAdapter(server_hostname="example.com")
|
||||
adapter.build_connection_pool_key_attributes = MagicMock(
|
||||
return_value=({"scheme": "https", "host": "93.184.216.34", "port": 443}, {})
|
||||
)
|
||||
adapter.poolmanager = MagicMock()
|
||||
|
||||
request = MagicMock()
|
||||
adapter.get_connection_with_tls_context(request, verify=True)
|
||||
|
||||
_, kwargs = adapter.poolmanager.connection_from_host.call_args
|
||||
assert kwargs["pool_kwargs"]["server_hostname"] == "example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_url — create/update-time defense in depth still rejects bypasses
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestValidateUrlHardening:
|
||||
@pytest.mark.parametrize("ip", ["100.64.0.1", "224.0.0.1", "0.0.0.0"])
|
||||
def test_rejects_newly_covered_ranges(self, ip):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.return_value = [_addr(ip)]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://attacker.example.com")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Review-feedback fixes (PR #9163)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
class TestReviewFixes:
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_url_embedded_credentials_become_basic_auth(self, mock_resolve, mock_session_cls):
|
||||
# user:pass@host -> Basic Auth preserved as auth=, userinfo stripped from URL
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
pinned_fetch("GET", "https://user:p%40ss@example.com/hook")
|
||||
|
||||
_, url = session.request.call_args.args
|
||||
kwargs = session.request.call_args.kwargs
|
||||
assert url == "https://93.184.216.34:443/hook" # no userinfo in the IP URL
|
||||
assert kwargs["auth"] == ("user", "p@ss") # percent-decoded
|
||||
assert kwargs["headers"]["Host"] == "example.com"
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_no_credentials_passes_auth_none(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
pinned_fetch("GET", "https://example.com/x")
|
||||
assert session.request.call_args.kwargs["auth"] is None
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_ipv6_literal_host_header_is_bracketed(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["2606:4700:4700::1111"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _resp(200)
|
||||
|
||||
pinned_fetch("GET", "https://[2606:4700:4700::1111]/x")
|
||||
|
||||
kwargs = session.request.call_args.kwargs
|
||||
assert kwargs["headers"]["Host"] == "[2606:4700:4700::1111]"
|
||||
|
||||
def test_idna_unicode_error_is_treated_as_unresolvable(self):
|
||||
# getaddrinfo can raise UnicodeError (IDNA) before any lookup; it must
|
||||
# surface as ValueError so webhook_send_task records a URL rejection.
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as dns:
|
||||
dns.side_effect = UnicodeError("label empty or too long")
|
||||
with pytest.raises(ValueError, match="could not be resolved"):
|
||||
resolve_and_validate("xn--bad-name")
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_stream_defers_session_close_until_response_close(self, mock_resolve, mock_session_cls):
|
||||
# With stream=True the size cap can bound memory only if the session
|
||||
# stays open until the body is read; closing the response closes it.
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
resp = _resp(200)
|
||||
session.request.return_value = resp
|
||||
|
||||
out = pinned_fetch("GET", "https://cdn.example.com/a.png", stream=True)
|
||||
|
||||
assert session.request.call_args.kwargs["stream"] is True
|
||||
session.close.assert_not_called() # deferred
|
||||
out.close()
|
||||
session.close.assert_called_once()
|
||||
230
apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py
Normal file
230
apps/api/plane/tests/unit/bg_tasks/test_work_item_link_task.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import ipaddress
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plane.bgtasks.work_item_link_task import safe_get, validate_url_ip
|
||||
from plane.utils.ip_address import validate_url
|
||||
|
||||
|
||||
def _make_response(status_code=200, headers=None, is_redirect=False, content=b""):
|
||||
"""Create a mock requests.Response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.is_redirect = is_redirect
|
||||
resp.headers = headers or {}
|
||||
resp.content = content
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateUrlIp:
|
||||
"""Test validate_url_ip blocks private/internal IPs."""
|
||||
|
||||
def test_rejects_private_ip(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url_ip("http://example.com")
|
||||
|
||||
def test_rejects_loopback(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url_ip("http://example.com")
|
||||
|
||||
def test_rejects_non_http_scheme(self):
|
||||
with pytest.raises(ValueError, match="Only HTTP and HTTPS"):
|
||||
validate_url_ip("file:///etc/passwd")
|
||||
|
||||
def test_allows_public_ip(self):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
|
||||
validate_url_ip("https://example.com") # Should not raise
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"100.64.0.1", # CGNAT / shared address space (not is_private on 3.12)
|
||||
"224.0.0.1", # multicast
|
||||
"0.0.0.0", # unspecified
|
||||
"::ffff:169.254.169.254", # IPv4-mapped cloud metadata
|
||||
"64:ff9b::a9fe:a9fe", # NAT64 embedding 169.254.169.254
|
||||
],
|
||||
)
|
||||
def test_rejects_hardened_bypass_ranges(self, ip):
|
||||
with patch("plane.bgtasks.work_item_link_task.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, (ip, 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url_ip("http://attacker.example.com")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateUrlAllowlist:
|
||||
"""Test validate_url allowlist permits specific private IPs."""
|
||||
|
||||
def test_allowlist_permits_private_ip(self):
|
||||
allowed = [ipaddress.ip_network("192.168.1.0/24")]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
|
||||
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
|
||||
|
||||
def test_allowlist_does_not_permit_other_private_ip(self):
|
||||
allowed = [ipaddress.ip_network("192.168.1.0/24")]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://example.com", allowed_ips=allowed)
|
||||
|
||||
def test_allowlist_permits_loopback_when_explicitly_allowed(self):
|
||||
allowed = [ipaddress.ip_network("127.0.0.0/8")]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("127.0.0.1", 0))]
|
||||
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
|
||||
|
||||
def test_allowlist_permits_matching_ipv4_with_mixed_version_networks(self):
|
||||
allowed = [
|
||||
ipaddress.ip_network("2001:db8::/32"),
|
||||
ipaddress.ip_network("192.168.1.0/24"),
|
||||
]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.50", 0))]
|
||||
validate_url("http://example.com", allowed_ips=allowed) # Should not raise
|
||||
|
||||
def test_allowlist_blocks_non_matching_ipv4_with_mixed_version_networks(self):
|
||||
allowed = [
|
||||
ipaddress.ip_network("2001:db8::/32"),
|
||||
ipaddress.ip_network("192.168.1.0/24"),
|
||||
]
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://example.com", allowed_ips=allowed)
|
||||
|
||||
def test_allowed_hosts_bypasses_private_ip_check(self):
|
||||
"""Hostnames in WEBHOOK_ALLOWED_HOSTS skip IP-based blocking — used for
|
||||
trusted internal services (e.g. Silo) whose IPs are dynamic in
|
||||
containerised deployments."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("172.18.0.5", 0))]
|
||||
validate_url("http://silo:3000/hook", allowed_hosts=["silo"]) # Should not raise
|
||||
|
||||
def test_allowed_hosts_matches_case_insensitively(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
validate_url(
|
||||
"http://Silo.Namespace.Svc.Cluster.Local/x",
|
||||
allowed_hosts=["silo.namespace.svc.cluster.local"],
|
||||
) # Should not raise
|
||||
|
||||
def test_allowed_hosts_skips_dns_lookup(self):
|
||||
"""When the hostname is explicitly trusted we shouldn't even resolve it —
|
||||
protects against operators who allowlist a name that isn't resolvable
|
||||
from the API container."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
validate_url("http://silo/hook", allowed_hosts=["silo"])
|
||||
mock_dns.assert_not_called()
|
||||
|
||||
def test_allowed_hosts_requires_exact_match(self):
|
||||
"""Subdomains of an allowed host must NOT bypass — a hostile
|
||||
``attacker.silo.internal`` should still be blocked when only
|
||||
``silo.internal`` is allowed."""
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("192.168.1.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url(
|
||||
"http://attacker.silo.internal/x",
|
||||
allowed_hosts=["silo.internal"],
|
||||
)
|
||||
|
||||
def test_allowed_hosts_empty_does_not_bypass(self):
|
||||
with patch("plane.utils.ip_address.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("10.0.0.1", 0))]
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
validate_url("http://silo/hook", allowed_hosts=[])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSafeGet:
|
||||
"""safe_get now delegates to the pinned SSRF-safe client; assert it resolves,
|
||||
validates, pins to the validated IP and follows redirects safely. Network is
|
||||
mocked at the requests.Session boundary inside plane.utils.url_security."""
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_returns_response_for_non_redirect(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _make_response(status_code=200, content=b"OK")
|
||||
|
||||
response, final_url = safe_get("https://example.com")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert final_url == "https://example.com"
|
||||
# Pinned to the validated IP literal, not the hostname.
|
||||
_, url = session.request.call_args.args
|
||||
assert url == "https://93.184.216.34:443/"
|
||||
assert session.request.call_args.kwargs["headers"]["Host"] == "example.com"
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_follows_redirect_and_validates_each_hop(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.side_effect = [
|
||||
_make_response(status_code=301, headers={"Location": "https://other.com/page"}),
|
||||
_make_response(status_code=200, content=b"OK"),
|
||||
]
|
||||
|
||||
response, final_url = safe_get("https://example.com")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert final_url == "https://other.com/page"
|
||||
assert mock_resolve.call_count == 2
|
||||
assert mock_resolve.call_args_list[0].args[0] == "example.com"
|
||||
assert mock_resolve.call_args_list[1].args[0] == "other.com"
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_blocks_redirect_to_private_ip(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.side_effect = [
|
||||
["93.184.216.34"],
|
||||
ValueError("Access to private/internal networks is not allowed"),
|
||||
]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _make_response(
|
||||
status_code=302, headers={"Location": "http://192.168.1.1:8080"}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="private/internal"):
|
||||
safe_get("https://evil.com/redirect")
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_raises_on_too_many_redirects(self, mock_resolve, mock_session_cls):
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.return_value = _make_response(
|
||||
status_code=302, headers={"Location": "https://example.com/loop"}
|
||||
)
|
||||
|
||||
with pytest.raises(requests.TooManyRedirects):
|
||||
safe_get("https://example.com/start")
|
||||
|
||||
@patch("plane.utils.url_security.requests.Session")
|
||||
@patch("plane.utils.url_security.resolve_and_validate")
|
||||
def test_succeeds_at_exact_max_redirects(self, mock_resolve, mock_session_cls):
|
||||
"""5 redirects then a 200 must succeed (MAX_REDIRECTS == 5)."""
|
||||
mock_resolve.return_value = ["93.184.216.34"]
|
||||
session = mock_session_cls.return_value
|
||||
session.request.side_effect = [
|
||||
_make_response(status_code=302, headers={"Location": "https://example.com/next"})
|
||||
] * 5 + [_make_response(status_code=200, content=b"OK")]
|
||||
|
||||
response, final_url = safe_get("https://example.com/start")
|
||||
|
||||
assert response.status_code == 200
|
||||
4
apps/api/plane/tests/unit/middleware/__init__.py
Normal file
4
apps/api/plane/tests/unit/middleware/__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,46 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Unit tests for APIKeyAuthentication.
|
||||
|
||||
Covers the access-control guarantees of the external API key authentication:
|
||||
- a valid token belonging to an active user authenticates successfully
|
||||
- a valid token is rejected once the underlying user account is deactivated
|
||||
(prevents an authentication bypass via a disabled account that still holds
|
||||
a previously generated API key)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from plane.api.middleware.api_authentication import APIKeyAuthentication
|
||||
from plane.db.models import APIToken
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAPIKeyAuthentication:
|
||||
@pytest.mark.django_db
|
||||
def test_validate_api_token_authenticates_active_user(self, create_user):
|
||||
token = APIToken.objects.create(
|
||||
user=create_user, label="Active Token", token="active-user-token"
|
||||
)
|
||||
|
||||
user, returned_token = APIKeyAuthentication().validate_api_token(token.token)
|
||||
|
||||
assert user == create_user
|
||||
assert returned_token == token.token
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_api_token_rejects_deactivated_user(self, create_user):
|
||||
token = APIToken.objects.create(
|
||||
user=create_user, label="Stale Token", token="deactivated-user-token"
|
||||
)
|
||||
|
||||
# Account is deactivated by an administrator after the token was issued.
|
||||
create_user.is_active = False
|
||||
create_user.save()
|
||||
|
||||
with pytest.raises(AuthenticationFailed):
|
||||
APIKeyAuthentication().validate_api_token(token.token)
|
||||
423
apps/api/plane/tests/unit/middleware/test_db_routing.py
Normal file
423
apps/api/plane/tests/unit/middleware/test_db_routing.py
Normal file
@@ -0,0 +1,423 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Unit tests for ReadReplicaRoutingMiddleware.
|
||||
This module contains comprehensive tests for the ReadReplicaRoutingMiddleware
|
||||
that handles intelligent database routing to read replicas based on HTTP methods
|
||||
and view configuration.
|
||||
Test Organization:
|
||||
- TestReadReplicaRoutingMiddleware: Core middleware functionality
|
||||
- TestProcessView: process_view method behavior
|
||||
- TestReplicaDecisionLogic: Decision logic for replica usage
|
||||
- TestAttributeDetection: View attribute detection methods
|
||||
- TestExceptionHandling: Exception handling and cleanup
|
||||
- TestRealViewIntegration: Real Django/DRF view integration
|
||||
- TestEdgeCases: Edge cases and error conditions
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.views import View
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from plane.middleware.db_routing import ReadReplicaRoutingMiddleware
|
||||
|
||||
|
||||
# Pytest fixtures
|
||||
@pytest.fixture
|
||||
def mock_get_response():
|
||||
"""Fixture for mocked get_response callable."""
|
||||
return Mock(return_value=HttpResponse())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def middleware(mock_get_response):
|
||||
"""Fixture for ReadReplicaRoutingMiddleware instance."""
|
||||
return ReadReplicaRoutingMiddleware(mock_get_response)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def request_factory():
|
||||
"""Fixture for Django RequestFactory."""
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_view_func():
|
||||
"""Fixture for a basic mocked view function."""
|
||||
view = Mock()
|
||||
view.use_read_replica = True
|
||||
return view
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_request(request_factory):
|
||||
"""Fixture for a GET request."""
|
||||
return request_factory.get("/api/test/")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_request(request_factory):
|
||||
"""Fixture for a POST request."""
|
||||
return request_factory.post("/api/test/")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReadReplicaRoutingMiddleware:
|
||||
"""Test cases for ReadReplicaRoutingMiddleware core functionality."""
|
||||
|
||||
def test_middleware_initialization(self, middleware, mock_get_response):
|
||||
"""Test middleware initializes correctly with expected attributes."""
|
||||
assert middleware.get_response == mock_get_response
|
||||
assert hasattr(middleware, "READ_ONLY_METHODS")
|
||||
assert "GET" in middleware.READ_ONLY_METHODS
|
||||
assert "HEAD" in middleware.READ_ONLY_METHODS
|
||||
assert "OPTIONS" in middleware.READ_ONLY_METHODS
|
||||
|
||||
def test_read_only_methods_constant(self, middleware):
|
||||
"""Test READ_ONLY_METHODS contains expected HTTP methods."""
|
||||
expected_methods = {"GET", "HEAD", "OPTIONS"}
|
||||
assert middleware.READ_ONLY_METHODS == expected_methods
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_routes_write_methods_to_primary(
|
||||
self, mock_clear, mock_set, middleware, post_request, mock_get_response
|
||||
):
|
||||
"""Test __call__ routes write methods to primary database."""
|
||||
response = middleware(post_request)
|
||||
|
||||
mock_set.assert_called_once_with(False) # Primary database
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_with_read_methods_waits_for_process_view(
|
||||
self, mock_clear, middleware, get_request, mock_get_response
|
||||
):
|
||||
"""Test __call__ with read methods waits for process_view."""
|
||||
response = middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_always_cleans_up_context(self, mock_clear, middleware, get_request):
|
||||
"""Test __call__ always cleans up context."""
|
||||
middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_call_cleans_up_context_on_exception(self, mock_clear, middleware, get_request, mock_get_response):
|
||||
"""Test __call__ cleans up context even if get_response raises."""
|
||||
mock_get_response.side_effect = Exception("Test exception")
|
||||
|
||||
with pytest.raises(Exception, match="Test exception"):
|
||||
middleware(get_request)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestProcessView:
|
||||
"""Test cases for process_view method functionality."""
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_replica_true(self, mock_set, middleware, get_request):
|
||||
"""Test process_view with GET request and use_read_replica=True."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
assert result is None
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_replica_false(self, mock_set, middleware, get_request):
|
||||
"""Test process_view with GET request and use_read_replica=False."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = False
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(False)
|
||||
assert result is None
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_read_method_and_no_replica_attribute(self, mock_set, middleware, get_request):
|
||||
"""Test process_view with GET request and no use_read_replica attr."""
|
||||
view_func = Mock(spec=[]) # No use_read_replica attribute
|
||||
|
||||
result = middleware.process_view(get_request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(False) # Default to primary
|
||||
assert result is None
|
||||
|
||||
def test_with_write_method_ignores_view_attributes(self, middleware, post_request):
|
||||
"""Test process_view with write methods ignores view attributes."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True # This should be ignored for POST
|
||||
|
||||
result = middleware.process_view(post_request, view_func, (), {})
|
||||
|
||||
assert result is None # Should not process for write methods
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReplicaDecisionLogic:
|
||||
"""Test cases for replica decision logic methods."""
|
||||
|
||||
def test_should_use_read_replica_with_true_attribute(self, middleware):
|
||||
"""Test _should_use_read_replica returns True for True attribute."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_should_use_read_replica_with_false_attribute(self, middleware):
|
||||
"""Test _should_use_read_replica returns False for False attribute."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = False
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_should_use_read_replica_with_no_attribute_defaults_false(self, middleware):
|
||||
"""Test _should_use_read_replica defaults to False for missing attr."""
|
||||
view_func = Mock(spec=[]) # No use_read_replica attribute
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAttributeDetection:
|
||||
"""Test cases for view attribute detection methods."""
|
||||
|
||||
def test_get_use_replica_attribute_function_based_view(self, middleware):
|
||||
"""Test _get_use_replica_attribute with function-based view."""
|
||||
# Test with True
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is True
|
||||
|
||||
# Test with False
|
||||
view_func.use_read_replica = False
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is False
|
||||
|
||||
# Test with no attribute
|
||||
view_func = Mock(spec=[])
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
assert result is None
|
||||
|
||||
def test_get_use_replica_attribute_django_cbv(self, middleware):
|
||||
"""Test _get_use_replica_attribute with Django CBV wrapper."""
|
||||
view_class = Mock()
|
||||
view_class.use_read_replica = True
|
||||
view_func = Mock()
|
||||
view_func.view_class = view_class
|
||||
# Remove use_read_replica from view_func to ensure it checks view_class
|
||||
del view_func.use_read_replica
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_get_use_replica_attribute_drf_wrapper(self, middleware):
|
||||
"""Test _get_use_replica_attribute with DRF wrapper."""
|
||||
|
||||
# Create a real object to avoid Mock issues
|
||||
class ViewClass:
|
||||
use_read_replica = True
|
||||
|
||||
class ViewFunc:
|
||||
cls = ViewClass()
|
||||
|
||||
view_func = ViewFunc()
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_get_use_replica_attribute_priority_order(self, middleware):
|
||||
"""Test attribute priority: direct > view_class > cls."""
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True # Direct attribute (highest priority)
|
||||
|
||||
# Add conflicting attributes with lower priority
|
||||
view_class = Mock()
|
||||
view_class.use_read_replica = False
|
||||
view_func.view_class = view_class
|
||||
|
||||
cls = Mock()
|
||||
cls.use_read_replica = False
|
||||
view_func.cls = cls
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is True # Should use direct attribute
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
(1, True),
|
||||
(0, False),
|
||||
("yes", True),
|
||||
("", False),
|
||||
([], False),
|
||||
([1], True),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_should_use_read_replica_truthy_falsy_values(self, middleware, value, expected):
|
||||
"""Test _should_use_read_replica with various truthy/falsy values."""
|
||||
|
||||
# Create a real object to test the attribute handling
|
||||
class TestView:
|
||||
pass
|
||||
|
||||
view_func = TestView()
|
||||
view_func.use_read_replica = value
|
||||
|
||||
result = middleware._should_use_read_replica(view_func)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExceptionHandling:
|
||||
"""Test cases for exception handling and cleanup."""
|
||||
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_process_exception_cleans_up_context(self, mock_clear, middleware, request_factory):
|
||||
"""Test process_exception cleans up context."""
|
||||
request = request_factory.get("/api/test/")
|
||||
exception = Exception("Test exception")
|
||||
|
||||
result = middleware.process_exception(request, exception)
|
||||
|
||||
mock_clear.assert_called_once()
|
||||
assert result is None # Don't handle the exception
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
@patch("plane.middleware.db_routing.clear_read_replica_context")
|
||||
def test_integration_full_request_cycle(self, mock_clear, mock_set, middleware, request_factory, mock_get_response):
|
||||
"""Test complete request cycle from __call__ through process_view."""
|
||||
request = request_factory.get("/api/test/")
|
||||
view_func = Mock()
|
||||
view_func.use_read_replica = True
|
||||
|
||||
# Call middleware and process_view manually
|
||||
response = middleware(request)
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
mock_clear.assert_called_once()
|
||||
assert response == mock_get_response.return_value
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRealViewIntegration:
|
||||
"""Test middleware with real Django/DRF view classes."""
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_django_class_based_view(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with actual Django CBV."""
|
||||
|
||||
class TestView(View):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate Django's URL resolver creating a view wrapper
|
||||
view_func = TestView.as_view()
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_drf_api_view(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with DRF APIView."""
|
||||
|
||||
class TestAPIView(APIView):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate DRF's URL pattern creating a view wrapper
|
||||
view_func = TestAPIView.as_view()
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
@patch("plane.middleware.db_routing.set_use_read_replica")
|
||||
def test_with_drf_viewset(self, mock_set, middleware, request_factory):
|
||||
"""Test middleware with DRF ViewSet."""
|
||||
|
||||
class TestViewSet(ViewSet):
|
||||
use_read_replica = True
|
||||
|
||||
# Simulate DRF router creating viewset action
|
||||
view_func = TestViewSet.as_view({"get": "list"})
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
middleware.process_view(request, view_func, (), {})
|
||||
|
||||
mock_set.assert_called_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error conditions."""
|
||||
|
||||
def test_process_view_with_none_view_func(self, middleware, request_factory):
|
||||
"""Test process_view handles None view_func gracefully."""
|
||||
request = request_factory.get("/api/test/")
|
||||
|
||||
result = middleware.process_view(request, None, (), {})
|
||||
|
||||
assert result is None # Should not crash
|
||||
|
||||
def test_get_use_replica_attribute_with_attribute_error(self, middleware):
|
||||
"""Test _get_use_replica_attribute with view that raises AttributeError."""
|
||||
|
||||
# Create a view class that raises AttributeError on access
|
||||
class ProblematicView:
|
||||
def __getattr__(self, name):
|
||||
if name == "use_read_replica":
|
||||
raise AttributeError("Simulated attribute error")
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||
|
||||
view_func = ProblematicView()
|
||||
|
||||
result = middleware._get_use_replica_attribute(view_func)
|
||||
|
||||
assert result is None # Should handle gracefully
|
||||
|
||||
def test_multiple_exception_calls_are_safe(self, middleware, request_factory):
|
||||
"""Test that multiple calls to process_exception don't cause issues."""
|
||||
request = request_factory.get("/api/test/")
|
||||
exception = Exception("Test exception")
|
||||
|
||||
# Call multiple times
|
||||
result1 = middleware.process_exception(request, exception)
|
||||
result2 = middleware.process_exception(request, exception)
|
||||
|
||||
assert result1 is None # Both should return None safely
|
||||
assert result2 is None
|
||||
79
apps/api/plane/tests/unit/middleware/test_logger.py
Normal file
79
apps/api/plane/tests/unit/middleware/test_logger.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Unit tests for APITokenLogMiddleware.
|
||||
|
||||
Covers the credential-hygiene guarantees of the external API request logger:
|
||||
- the raw API key is never persisted (a non-reversible hash is stored instead)
|
||||
- sensitive request headers are redacted before being logged
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
|
||||
from plane.middleware.logger import APITokenLogMiddleware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def request_factory():
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def middleware():
|
||||
return APITokenLogMiddleware(Mock(return_value=HttpResponse(b"{}")))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAPITokenLogMiddleware:
|
||||
API_KEY = "plane_api_supersecretvalue"
|
||||
AUTHORIZATION = "Bearer secret-bearer-token"
|
||||
COOKIE = "sessionid=secret-session-value"
|
||||
|
||||
def _captured_log_data(self, middleware, request_factory):
|
||||
request = request_factory.get(
|
||||
"/api/v1/workspaces/",
|
||||
HTTP_X_API_KEY=self.API_KEY,
|
||||
HTTP_AUTHORIZATION=self.AUTHORIZATION,
|
||||
HTTP_COOKIE=self.COOKIE,
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
response = HttpResponse(b"{}")
|
||||
with patch("plane.middleware.logger.process_logs") as process_logs:
|
||||
middleware.process_request(request, response, request_body=b"")
|
||||
assert process_logs.delay.called
|
||||
return process_logs.delay.call_args.kwargs["log_data"]
|
||||
|
||||
def test_token_identifier_is_hashed_not_plaintext(self, middleware, request_factory):
|
||||
log_data = self._captured_log_data(middleware, request_factory)
|
||||
|
||||
expected_hash = hmac.new(
|
||||
settings.SECRET_KEY.encode(), self.API_KEY.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
assert log_data["token_identifier"] == expected_hash
|
||||
assert self.API_KEY not in log_data["token_identifier"]
|
||||
|
||||
def test_sensitive_headers_are_redacted(self, middleware, request_factory):
|
||||
log_data = self._captured_log_data(middleware, request_factory)
|
||||
|
||||
# None of the sensitive header values may appear in the logged headers.
|
||||
assert self.API_KEY not in log_data["headers"]
|
||||
assert self.AUTHORIZATION not in log_data["headers"]
|
||||
assert self.COOKIE not in log_data["headers"]
|
||||
assert "[REDACTED]" in log_data["headers"]
|
||||
|
||||
def test_no_log_without_api_key(self, middleware, request_factory):
|
||||
request = request_factory.get("/api/v1/workspaces/")
|
||||
request.user = AnonymousUser()
|
||||
with patch("plane.middleware.logger.process_logs") as process_logs:
|
||||
middleware.process_request(request, HttpResponse(b"{}"), request_body=b"")
|
||||
assert not process_logs.delay.called
|
||||
4
apps/api/plane/tests/unit/models/__init__.py
Normal file
4
apps/api/plane/tests/unit/models/__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.
|
||||
|
||||
290
apps/api/plane/tests/unit/models/test_issue_comment_modal.py
Normal file
290
apps/api/plane/tests/unit/models/test_issue_comment_modal.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# 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.db.models import IssueComment, Description, Project, Issue, Workspace, State
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(create_user):
|
||||
"""Create a test workspace"""
|
||||
return Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
owner=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(workspace, create_user):
|
||||
"""Create a test project"""
|
||||
return Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(project):
|
||||
"""Create a test state"""
|
||||
return State.objects.create(
|
||||
name="Todo",
|
||||
project=project,
|
||||
group="backlog",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issue(workspace, project, state, create_user):
|
||||
"""Create a test issue"""
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
state=state,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueCommentModel:
|
||||
"""Test the IssueComment model"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_creation_creates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that creating a comment automatically creates a description"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is a test comment</p>"
|
||||
comment_json = {"type": "doc", "content": [{"type": "paragraph", "text": "This is a test comment"}]}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.id is not None
|
||||
assert issue_comment.comment_stripped == "This is a test comment"
|
||||
assert issue_comment.description_id is not None
|
||||
|
||||
# Verify description was created
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description is not None
|
||||
assert description.description_html == comment_html
|
||||
assert description.description_json == comment_json
|
||||
assert description.description_stripped == "This is a test comment"
|
||||
assert description.workspace_id == workspace.id
|
||||
assert description.project_id == project.id
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_updates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment updates its associated description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
# Refresh from database
|
||||
issue_comment.refresh_from_db()
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify comment was updated
|
||||
assert issue_comment.comment_stripped == "Updated comment"
|
||||
assert issue_comment.description_id == initial_description_id # Same description object
|
||||
|
||||
# Verify description was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_json == updated_json
|
||||
assert updated_description.description_stripped == "Updated comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_only_changed_fields_in_description(self, workspace, project, issue, create_user):
|
||||
"""Test that only changed fields are updated in description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update only the HTML (not JSON)
|
||||
updated_html = "<p>Updated comment only HTML</p>"
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
# comment_json remains the same
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify HTML was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_stripped == "Updated comment only HTML"
|
||||
|
||||
# Verify JSON remained the same
|
||||
assert updated_description.description_json == initial_json
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_no_update_when_content_unchanged(self, workspace, project, issue, create_user):
|
||||
"""Test that description is not updated when comment content doesn't change"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Test comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Test comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Save without changing content
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify description was not updated (updated_at should be the same)
|
||||
# Note: This test assumes updated_at is not changed when no fields change
|
||||
assert updated_description.description_html == initial_html
|
||||
assert updated_description.description_json == initial_json
|
||||
assert updated_description.description_stripped == "Test comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_creates_description_if_missing(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment creates description if it doesn't exist (legacy data)"""
|
||||
# Arrange - Create comment and manually remove description (simulating legacy data)
|
||||
initial_html = "<p>Legacy comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Legacy comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Simulate legacy data by removing the description
|
||||
if issue_comment.description_id:
|
||||
Description.objects.filter(pk=issue_comment.description_id).delete()
|
||||
IssueComment.objects.filter(pk=issue_comment.pk).update(description_id=None)
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
assert issue_comment.description_id is None
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated legacy comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated legacy comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
# Verify description was created
|
||||
assert issue_comment.description_id is not None
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_html == updated_html
|
||||
assert description.description_json == updated_json
|
||||
assert description.description_stripped == "Updated legacy comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_strips_html_tags(self, workspace, project, issue, create_user):
|
||||
"""Test that HTML tags are properly stripped from comment_html"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == "This is bold and italic text"
|
||||
|
||||
# Verify description has the same stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_stripped == "This is bold and italic text"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_empty_html_creates_empty_stripped(self, workspace, project, issue, create_user):
|
||||
"""Test that empty HTML results in empty comment_stripped"""
|
||||
# Arrange
|
||||
comment_html = ""
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == ""
|
||||
|
||||
# Verify description was created with empty stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
|
||||
assert description.description_stripped is None
|
||||
48
apps/api/plane/tests/unit/models/test_workspace_model.py
Normal file
48
apps/api/plane/tests/unit/models/test_workspace_model.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# 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 uuid import uuid4
|
||||
|
||||
from plane.db.models import Workspace, WorkspaceMember
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkspaceModel:
|
||||
"""Test the Workspace model"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workspace_creation(self, create_user):
|
||||
"""Test creating a workspace"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
assert workspace.id is not None
|
||||
assert workspace.name == "Test Workspace"
|
||||
assert workspace.slug == "test-workspace"
|
||||
assert workspace.owner == create_user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workspace_member_creation(self, create_user):
|
||||
"""Test creating a workspace member"""
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user
|
||||
)
|
||||
|
||||
# Create a workspace member
|
||||
workspace_member = WorkspaceMember.objects.create(
|
||||
workspace=workspace,
|
||||
member=create_user,
|
||||
role=20, # Admin role
|
||||
)
|
||||
|
||||
# Verify it was created
|
||||
assert workspace_member.id is not None
|
||||
assert workspace_member.workspace == workspace
|
||||
assert workspace_member.member == create_user
|
||||
assert workspace_member.role == 20
|
||||
4
apps/api/plane/tests/unit/serializers/__init__.py
Normal file
4
apps/api/plane/tests/unit/serializers/__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,73 @@
|
||||
# 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.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
User,
|
||||
IssueAssignee,
|
||||
WorkspaceMember,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers.workspace import IssueRecentVisitSerializer
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueRecentVisitSerializer:
|
||||
"""Test the IssueRecentVisitSerializer"""
|
||||
|
||||
def test_issue_recent_visit_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
|
||||
test_user_1 = User.objects.create(email="test_user_1@example.com", first_name="Test", last_name="User")
|
||||
|
||||
# To test for deleted issue assignee
|
||||
test_user_2 = User.objects.create(
|
||||
email="test_user_2@example.com",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
username="some user name",
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", owner=test_user_1)
|
||||
|
||||
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
|
||||
|
||||
project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace)
|
||||
ProjectMember.objects.create(project=project, member=test_user_2)
|
||||
|
||||
issue = Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
)
|
||||
|
||||
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
|
||||
|
||||
# Deleted issue assignee
|
||||
IssueAssignee.objects.create(
|
||||
issue=issue,
|
||||
assignee=test_user_2,
|
||||
project=project,
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
|
||||
serialized_data = IssueRecentVisitSerializer(
|
||||
issue,
|
||||
).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "assignees" in serialized_data
|
||||
assert "project_identifier" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Issue"
|
||||
assert serialized_data["project_identifier"] == "TEST-PROJECT"
|
||||
|
||||
# Only including non-deleted issue assignees
|
||||
assert serialized_data["assignees"] == [test_user_1.id]
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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.api.serializers.issue import IssueSerializer
|
||||
from plane.db.models import Project, ProjectMember, Label, User
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueSerializerAssigneeAndLabelValidation:
|
||||
"""Test that IssueSerializer rejects invalid assignees/labels instead of silently dropping them"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rejects_assignee_who_is_not_an_active_project_member(self, db, workspace, create_user):
|
||||
"""An assignee id that isn't an active project member (role >= 15) must raise a validation error"""
|
||||
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
|
||||
|
||||
outsider = User.objects.create(
|
||||
email="outsider@example.com", first_name="Out", last_name="Sider", username="outsider"
|
||||
)
|
||||
# Not a member of the project at all
|
||||
|
||||
serializer = IssueSerializer(
|
||||
data={"name": "Test Issue", "assignees": [str(outsider.id)]},
|
||||
context={"project_id": project.id, "workspace_id": workspace.id},
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "assignees" in str(serializer.errors).lower() or "assignee" in str(serializer.errors).lower()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rejects_label_that_does_not_belong_to_project(self, db, workspace, create_user):
|
||||
"""A label id that belongs to a different project must raise a validation error"""
|
||||
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
|
||||
other_project = Project.objects.create(name="Other Project", identifier="OTHER", workspace=workspace)
|
||||
|
||||
foreign_label = Label.objects.create(name="Foreign Label", project=other_project)
|
||||
|
||||
serializer = IssueSerializer(
|
||||
data={"name": "Test Issue", "labels": [str(foreign_label.id)]},
|
||||
context={"project_id": project.id, "workspace_id": workspace.id},
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "labels" in str(serializer.errors).lower() or "label" in str(serializer.errors).lower()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_accepts_assignee_who_is_an_active_project_member(self, db, workspace, create_user):
|
||||
"""A valid active project member id should still be accepted"""
|
||||
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
|
||||
ProjectMember.objects.create(project=project, member=create_user, role=15, is_active=True)
|
||||
|
||||
serializer = IssueSerializer(
|
||||
data={"name": "Test Issue", "assignees": [str(create_user.id)]},
|
||||
context={"project_id": project.id, "workspace_id": workspace.id},
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert list(serializer.validated_data["assignees"]) == [create_user.id]
|
||||
41
apps/api/plane/tests/unit/serializers/test_label.py
Normal file
41
apps/api/plane/tests/unit/serializers/test_label.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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.app.serializers import LabelSerializer
|
||||
from plane.db.models import Project, Label
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLabelSerializer:
|
||||
"""Test the LabelSerializer"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_label_serializer_create_valid_data(self, db, workspace):
|
||||
"""Test creating a label with valid data"""
|
||||
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
|
||||
|
||||
serializer = LabelSerializer(
|
||||
data={"name": "Test Label"},
|
||||
context={"project_id": project.id},
|
||||
)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.errors == {}
|
||||
serializer.save(project_id=project.id)
|
||||
|
||||
label = Label.objects.all().first()
|
||||
assert label.name == "Test Label"
|
||||
assert label.project == project
|
||||
assert label
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_label_serializer_create_duplicate_name(self, db, workspace):
|
||||
"""Test creating a label with a duplicate name"""
|
||||
project = Project.objects.create(name="Test Project", identifier="TEST", workspace=workspace)
|
||||
|
||||
Label.objects.create(name="Test Label", project=project)
|
||||
|
||||
serializer = LabelSerializer(data={"name": "Test Label"}, context={"project_id": project.id})
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors == {"name": ["LABEL_NAME_ALREADY_EXISTS"]}
|
||||
129
apps/api/plane/tests/unit/serializers/test_workspace.py
Normal file
129
apps/api/plane/tests/unit/serializers/test_workspace.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# 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 uuid import uuid4
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from plane.api.serializers import WorkspaceLiteSerializer
|
||||
from plane.app.serializers import WorkSpaceSerializer
|
||||
from plane.license.api.serializers import WorkspaceSerializer as InstanceWorkspaceSerializer
|
||||
from plane.db.models import Workspace, User
|
||||
|
||||
# Names with no letter or digit — must be rejected (issue #9255)
|
||||
SYMBOL_ONLY_NAMES = ["-_________-", "---", "___", "- - -", " ", " -_ "]
|
||||
|
||||
# Names containing at least one letter or digit — must be accepted, including
|
||||
# international scripts, so the rule does not regress non-Latin workspace names
|
||||
VALID_NAMES = [
|
||||
"Acme Corp",
|
||||
"Acme_Corp-123",
|
||||
"123",
|
||||
"a",
|
||||
"R&D",
|
||||
"José",
|
||||
"Müller GmbH",
|
||||
"日本語",
|
||||
"株式会社",
|
||||
"محمد",
|
||||
]
|
||||
|
||||
# Names embedding a URL — must be rejected on both create paths
|
||||
URL_NAMES = ["https://evil.com", "www.example.com", "example.com"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkspaceLiteSerializer:
|
||||
"""Test the WorkspaceLiteSerializer"""
|
||||
|
||||
def test_workspace_lite_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(email="test@example.com", first_name="Test", last_name="User")
|
||||
|
||||
# Create a workspace with explicit ID to test serialization
|
||||
workspace_id = uuid4()
|
||||
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner)
|
||||
|
||||
# Serialize the workspace
|
||||
serialized_data = WorkspaceLiteSerializer(workspace).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "slug" in serialized_data
|
||||
assert "id" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Workspace"
|
||||
assert serialized_data["slug"] == "test-workspace"
|
||||
assert str(serialized_data["id"]) == str(workspace_id)
|
||||
|
||||
def test_workspace_lite_serializer_read_only(self, db):
|
||||
"""Test that the serializer fields are read-only"""
|
||||
# Create a user to be the owner
|
||||
owner = User.objects.create(email="test2@example.com", first_name="Test", last_name="User")
|
||||
|
||||
# Create a workspace
|
||||
workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner)
|
||||
|
||||
# Try to update via serializer
|
||||
serializer = WorkspaceLiteSerializer(workspace, data={"name": "Updated Name", "slug": "updated-slug"})
|
||||
|
||||
# Serializer should be valid (since read-only fields are ignored)
|
||||
assert serializer.is_valid()
|
||||
|
||||
# Save should not update the read-only fields
|
||||
updated_workspace = serializer.save()
|
||||
assert updated_workspace.name == "Test Workspace"
|
||||
assert updated_workspace.slug == "test-workspace"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWorkSpaceSerializerNameValidation:
|
||||
"""validate_name must reject symbol-only workspace names (issue #9255).
|
||||
|
||||
Frontend validation is bypassable via a direct API call, so the rule is
|
||||
enforced server-side on both the create and rename (partial_update) paths,
|
||||
which share this serializer's field-level validation.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("name", SYMBOL_ONLY_NAMES)
|
||||
def test_rejects_symbol_only_names(self, name):
|
||||
serializer = WorkSpaceSerializer()
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
serializer.validate_name(name)
|
||||
|
||||
@pytest.mark.parametrize("name", VALID_NAMES)
|
||||
def test_accepts_names_with_alphanumeric(self, name):
|
||||
serializer = WorkSpaceSerializer()
|
||||
assert serializer.validate_name(name) == name
|
||||
|
||||
@pytest.mark.parametrize("name", URL_NAMES)
|
||||
def test_rejects_names_containing_urls(self, name):
|
||||
serializer = WorkSpaceSerializer()
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
serializer.validate_name(name)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestInstanceWorkspaceSerializerNameValidation:
|
||||
"""The instance/license workspace create path must enforce the same rules
|
||||
as the app serializer (symbol-only rejection AND URL rejection)."""
|
||||
|
||||
@pytest.mark.parametrize("name", SYMBOL_ONLY_NAMES)
|
||||
def test_rejects_symbol_only_names(self, name):
|
||||
serializer = InstanceWorkspaceSerializer()
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
serializer.validate_name(name)
|
||||
|
||||
@pytest.mark.parametrize("name", VALID_NAMES)
|
||||
def test_accepts_names_with_alphanumeric(self, name):
|
||||
serializer = InstanceWorkspaceSerializer()
|
||||
assert serializer.validate_name(name) == name
|
||||
|
||||
@pytest.mark.parametrize("name", URL_NAMES)
|
||||
def test_rejects_names_containing_urls(self, name):
|
||||
serializer = InstanceWorkspaceSerializer()
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
serializer.validate_name(name)
|
||||
4
apps/api/plane/tests/unit/settings/__init__.py
Normal file
4
apps/api/plane/tests/unit/settings/__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.
|
||||
|
||||
34
apps/api/plane/tests/unit/settings/test_retention.py
Normal file
34
apps/api/plane/tests/unit/settings/test_retention.py
Normal 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.
|
||||
|
||||
"""Unit tests for the log-retention env parsing helper."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.settings.common import _retention_days
|
||||
|
||||
ENV_VAR = "TEST_RETENTION_DAYS"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRetentionDays:
|
||||
def test_uses_default_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv(ENV_VAR, raising=False)
|
||||
assert _retention_days(ENV_VAR, 14) == 14
|
||||
|
||||
def test_uses_env_value_when_valid(self, monkeypatch):
|
||||
monkeypatch.setenv(ENV_VAR, "30")
|
||||
assert _retention_days(ENV_VAR, 14) == 30
|
||||
|
||||
def test_zero_is_allowed(self, monkeypatch):
|
||||
monkeypatch.setenv(ENV_VAR, "0")
|
||||
assert _retention_days(ENV_VAR, 14) == 0
|
||||
|
||||
def test_negative_falls_back_to_default(self, monkeypatch):
|
||||
monkeypatch.setenv(ENV_VAR, "-5")
|
||||
assert _retention_days(ENV_VAR, 14) == 14
|
||||
|
||||
def test_unparseable_falls_back_to_default(self, monkeypatch):
|
||||
monkeypatch.setenv(ENV_VAR, "abc")
|
||||
assert _retention_days(ENV_VAR, 7) == 7
|
||||
206
apps/api/plane/tests/unit/settings/test_storage.py
Normal file
206
apps/api/plane/tests/unit/settings/test_storage.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestS3StorageSignedURLExpiration:
|
||||
"""Test the configurable signed URL expiration in S3Storage"""
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_default_expiration_without_env_variable(self, mock_boto3):
|
||||
"""Test that default expiration is 3600 seconds when env variable is not set"""
|
||||
# Mock the boto3 client
|
||||
mock_boto3.client.return_value = Mock()
|
||||
|
||||
# Create S3Storage instance without SIGNED_URL_EXPIRATION env variable
|
||||
storage = S3Storage()
|
||||
|
||||
# Assert default expiration is 3600
|
||||
assert storage.signed_url_expiration == 3600
|
||||
|
||||
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "30"}, clear=True)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_custom_expiration_with_env_variable(self, mock_boto3):
|
||||
"""Test that expiration is read from SIGNED_URL_EXPIRATION env variable"""
|
||||
# Mock the boto3 client
|
||||
mock_boto3.client.return_value = Mock()
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
|
||||
storage = S3Storage()
|
||||
|
||||
# Assert expiration is 30
|
||||
assert storage.signed_url_expiration == 30
|
||||
|
||||
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "300"}, clear=True)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_custom_expiration_multiple_values(self, mock_boto3):
|
||||
"""Test that expiration works with different custom values"""
|
||||
# Mock the boto3 client
|
||||
mock_boto3.client.return_value = Mock()
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=300
|
||||
storage = S3Storage()
|
||||
|
||||
# Assert expiration is 300
|
||||
assert storage.signed_url_expiration == 300
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_post_uses_default_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_post uses the configured default expiration"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_post.return_value = {
|
||||
"url": "https://test-url.com",
|
||||
"fields": {},
|
||||
}
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_post without explicit expiration
|
||||
storage.generate_presigned_post("test-object", "image/png", 1024)
|
||||
|
||||
# Assert that the boto3 method was called with the default expiration (3600)
|
||||
mock_s3_client.generate_presigned_post.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 3600
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"SIGNED_URL_EXPIRATION": "60",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_post uses custom expiration from env variable"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_post.return_value = {
|
||||
"url": "https://test-url.com",
|
||||
"fields": {},
|
||||
}
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=60
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_post without explicit expiration
|
||||
storage.generate_presigned_post("test-object", "image/png", 1024)
|
||||
|
||||
# Assert that the boto3 method was called with custom expiration (60)
|
||||
mock_s3_client.generate_presigned_post.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 60
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_url_uses_default_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_url uses the configured default expiration"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_url without explicit expiration
|
||||
storage.generate_presigned_url("test-object")
|
||||
|
||||
# Assert that the boto3 method was called with the default expiration (3600)
|
||||
mock_s3_client.generate_presigned_url.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 3600
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"SIGNED_URL_EXPIRATION": "30",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_generate_presigned_url_uses_custom_expiration(self, mock_boto3):
|
||||
"""Test that generate_presigned_url uses custom expiration from env variable"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_url without explicit expiration
|
||||
storage.generate_presigned_url("test-object")
|
||||
|
||||
# Assert that the boto3 method was called with custom expiration (30)
|
||||
mock_s3_client.generate_presigned_url.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 30
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"SIGNED_URL_EXPIRATION": "30",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_explicit_expiration_overrides_default(self, mock_boto3):
|
||||
"""Test that explicit expiration parameter overrides the default"""
|
||||
# Mock the boto3 client and its response
|
||||
mock_s3_client = Mock()
|
||||
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
|
||||
mock_boto3.client.return_value = mock_s3_client
|
||||
|
||||
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
|
||||
storage = S3Storage()
|
||||
|
||||
# Call generate_presigned_url with explicit expiration=120
|
||||
storage.generate_presigned_url("test-object", expiration=120)
|
||||
|
||||
# Assert that the boto3 method was called with explicit expiration (120)
|
||||
mock_s3_client.generate_presigned_url.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 120
|
||||
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"
|
||||
3
apps/api/plane/tests/unit/views/__init__.py
Normal file
3
apps/api/plane/tests/unit/views/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
69
apps/api/plane/tests/unit/views/test_base_dispatch.py
Normal file
69
apps/api/plane/tests/unit/views/test_base_dispatch.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# 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 the ``dispatch()`` exception handling on the shared
|
||||
``BaseAPIView`` / ``BaseViewSet`` classes.
|
||||
|
||||
When ``super().dispatch()`` raises an unhandled exception, ``dispatch()`` must
|
||||
return the HTTP ``Response`` produced by ``handle_exception()`` -- not the raw
|
||||
exception object. Returning the exception causes Django's response pipeline to
|
||||
fail with ``TypeError: 'Exception' object is not a valid HTTP response``.
|
||||
|
||||
See: https://github.com/makeplane/plane/issues/9157
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from plane.api.views.base import BaseAPIView as ApiBaseAPIView, BaseViewSet as ApiBaseViewSet
|
||||
from plane.app.views.base import BaseAPIView as AppBaseAPIView, BaseViewSet as AppBaseViewSet
|
||||
from plane.license.api.views.base import BaseAPIView as LicenseBaseAPIView
|
||||
from plane.space.views.base import BaseAPIView as SpaceBaseAPIView, BaseViewSet as SpaceBaseViewSet
|
||||
|
||||
|
||||
# Every shared base view that wraps ``super().dispatch()`` in a try/except.
|
||||
VIEW_CLASSES = [
|
||||
ApiBaseAPIView,
|
||||
ApiBaseViewSet,
|
||||
AppBaseAPIView,
|
||||
AppBaseViewSet,
|
||||
LicenseBaseAPIView,
|
||||
SpaceBaseAPIView,
|
||||
SpaceBaseViewSet,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"view_class",
|
||||
VIEW_CLASSES,
|
||||
ids=lambda c: f"{c.__module__}.{c.__name__}",
|
||||
)
|
||||
def test_dispatch_returns_response_when_super_dispatch_raises(view_class):
|
||||
"""dispatch() must return handle_exception()'s Response, not the exception."""
|
||||
request = APIRequestFactory().get("/api/test/")
|
||||
view = view_class()
|
||||
|
||||
sentinel = Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("rest_framework.views.APIView.dispatch", side_effect=RuntimeError("boom")),
|
||||
patch.object(view_class, "handle_exception", return_value=sentinel) as mock_handle,
|
||||
):
|
||||
result = view.dispatch(request)
|
||||
|
||||
mock_handle.assert_called_once()
|
||||
assert isinstance(result, Response), (
|
||||
f"{view_class.__module__}.{view_class.__name__}.dispatch() returned "
|
||||
f"{type(result).__name__} instead of an HTTP Response"
|
||||
)
|
||||
assert result is sentinel
|
||||
212
apps/api/plane/tests/unit/views/test_issue_link.py
Normal file
212
apps/api/plane/tests/unit/views/test_issue_link.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""
|
||||
Unit regression tests for IssueLinkViewSet.partial_update.
|
||||
|
||||
Verifies that crawl_work_item_link_title is only scheduled when the URL
|
||||
actually changes, preventing overwriting custom link metadata and unnecessary crawls.
|
||||
See: https://github.com/makeplane/plane/issues/9674
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from plane.app.views.issue.link import IssueLinkViewSet
|
||||
from plane.api.views.issue import IssueLinkDetailAPIEndpoint
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueLinkPartialUpdate:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_view(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.view = IssueLinkViewSet()
|
||||
self.workspace_slug = "test-workspace"
|
||||
self.project_id = str(uuid.uuid4())
|
||||
self.issue_id = str(uuid.uuid4())
|
||||
self.link_id = str(uuid.uuid4())
|
||||
self.user_id = str(uuid.uuid4())
|
||||
|
||||
def _create_mock_request(self, data):
|
||||
wsgi_request = self.factory.patch("/api/test/", data=data, format="json")
|
||||
request = Request(wsgi_request, parsers=[JSONParser()])
|
||||
request.user = MagicMock()
|
||||
request.user.id = self.user_id
|
||||
return request
|
||||
|
||||
@patch("plane.app.views.issue.link.base_host", return_value="https://app.plane.so")
|
||||
@patch("plane.app.views.issue.link.issue_activity")
|
||||
@patch("plane.app.views.issue.link.crawl_work_item_link_title")
|
||||
@patch("plane.app.views.issue.link.IssueLinkSerializer")
|
||||
@patch("plane.app.views.issue.link.IssueLink.objects.get")
|
||||
def test_partial_update_does_not_recrawl_when_url_unchanged(
|
||||
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity, mock_base_host
|
||||
):
|
||||
"""Updating link title or metadata without changing url must NOT trigger crawler."""
|
||||
initial_url = "https://github.com/makeplane/plane"
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.id = self.link_id
|
||||
mock_instance.url = initial_url
|
||||
mock_get_link.return_value = mock_instance
|
||||
|
||||
# Mock serializer behavior
|
||||
mock_serializer_instance = MagicMock()
|
||||
mock_serializer_instance.is_valid.return_value = True
|
||||
mock_serializer_instance.data = {
|
||||
"id": self.link_id,
|
||||
"title": "Custom Renamed Title",
|
||||
"url": initial_url,
|
||||
"metadata": {"custom": "data"},
|
||||
}
|
||||
mock_serializer_cls.return_value = mock_serializer_instance
|
||||
|
||||
# Mock get_queryset for self.get_queryset().get(id=...)
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.get.return_value = mock_instance
|
||||
self.view.get_queryset = MagicMock(return_value=mock_queryset)
|
||||
|
||||
request = self._create_mock_request({"title": "Custom Renamed Title"})
|
||||
response = self.view.partial_update(
|
||||
request,
|
||||
slug=self.workspace_slug,
|
||||
project_id=self.project_id,
|
||||
issue_id=self.issue_id,
|
||||
pk=self.link_id,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_crawl.delay.assert_not_called()
|
||||
mock_activity.delay.assert_called_once()
|
||||
|
||||
@patch("plane.app.views.issue.link.base_host", return_value="https://app.plane.so")
|
||||
@patch("plane.app.views.issue.link.issue_activity")
|
||||
@patch("plane.app.views.issue.link.crawl_work_item_link_title")
|
||||
@patch("plane.app.views.issue.link.IssueLinkSerializer")
|
||||
@patch("plane.app.views.issue.link.IssueLink.objects.get")
|
||||
def test_partial_update_triggers_crawl_when_url_changed(
|
||||
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity, mock_base_host
|
||||
):
|
||||
"""Updating url to a new value MUST trigger crawl_work_item_link_title."""
|
||||
initial_url = "https://github.com/makeplane/plane"
|
||||
new_url = "https://github.com/makeplane/plane/pull/123"
|
||||
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.id = self.link_id
|
||||
mock_instance.url = initial_url
|
||||
mock_get_link.return_value = mock_instance
|
||||
|
||||
mock_serializer_instance = MagicMock()
|
||||
mock_serializer_instance.is_valid.return_value = True
|
||||
mock_serializer_instance.data = {
|
||||
"id": self.link_id,
|
||||
"title": "Old Title",
|
||||
"url": new_url,
|
||||
"metadata": {},
|
||||
}
|
||||
mock_serializer_cls.return_value = mock_serializer_instance
|
||||
|
||||
mock_queryset = MagicMock()
|
||||
mock_queryset.get.return_value = mock_instance
|
||||
self.view.get_queryset = MagicMock(return_value=mock_queryset)
|
||||
|
||||
request = self._create_mock_request({"url": new_url})
|
||||
response = self.view.partial_update(
|
||||
request,
|
||||
slug=self.workspace_slug,
|
||||
project_id=self.project_id,
|
||||
issue_id=self.issue_id,
|
||||
pk=self.link_id,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_crawl.delay.assert_called_once_with(self.link_id, new_url)
|
||||
mock_activity.delay.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueLinkDetailPatch:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_view(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.view = IssueLinkDetailAPIEndpoint()
|
||||
self.workspace_slug = "test-workspace"
|
||||
self.project_id = str(uuid.uuid4())
|
||||
self.issue_id = str(uuid.uuid4())
|
||||
self.link_id = str(uuid.uuid4())
|
||||
self.user_id = str(uuid.uuid4())
|
||||
|
||||
def _create_mock_request(self, data):
|
||||
wsgi_request = self.factory.patch("/api/test/", data=data, format="json")
|
||||
request = Request(wsgi_request, parsers=[JSONParser()])
|
||||
request.user = MagicMock()
|
||||
request.user.id = self.user_id
|
||||
return request
|
||||
|
||||
@patch("plane.api.views.issue.issue_activity")
|
||||
@patch("plane.api.views.issue.crawl_work_item_link_title")
|
||||
@patch("plane.api.views.issue.IssueLinkSerializer")
|
||||
@patch("plane.api.views.issue.IssueLink.objects.get")
|
||||
def test_patch_does_not_recrawl_when_url_unchanged(
|
||||
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity
|
||||
):
|
||||
initial_url = "https://github.com/makeplane/plane"
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.id = self.link_id
|
||||
mock_instance.url = initial_url
|
||||
mock_get_link.return_value = mock_instance
|
||||
|
||||
mock_serializer_instance = MagicMock()
|
||||
mock_serializer_instance.is_valid.return_value = True
|
||||
mock_serializer_instance.data = {"id": self.link_id, "url": initial_url}
|
||||
mock_serializer_cls.return_value = mock_serializer_instance
|
||||
|
||||
response = self.view.patch(
|
||||
self._create_mock_request({"title": "Custom Renamed Title"}),
|
||||
slug=self.workspace_slug,
|
||||
project_id=self.project_id,
|
||||
issue_id=self.issue_id,
|
||||
pk=self.link_id,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_crawl.delay.assert_not_called()
|
||||
mock_activity.delay.assert_called_once()
|
||||
|
||||
@patch("plane.api.views.issue.issue_activity")
|
||||
@patch("plane.api.views.issue.crawl_work_item_link_title")
|
||||
@patch("plane.api.views.issue.IssueLinkSerializer")
|
||||
@patch("plane.api.views.issue.IssueLink.objects.get")
|
||||
def test_patch_triggers_crawl_when_url_changed(
|
||||
self, mock_get_link, mock_serializer_cls, mock_crawl, mock_activity
|
||||
):
|
||||
initial_url = "https://github.com/makeplane/plane"
|
||||
new_url = "https://github.com/makeplane/plane/pull/123"
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.id = self.link_id
|
||||
mock_instance.url = initial_url
|
||||
mock_get_link.return_value = mock_instance
|
||||
|
||||
mock_serializer_instance = MagicMock()
|
||||
mock_serializer_instance.is_valid.return_value = True
|
||||
mock_serializer_instance.data = {"id": self.link_id, "url": new_url}
|
||||
mock_serializer_cls.return_value = mock_serializer_instance
|
||||
|
||||
response = self.view.patch(
|
||||
self._create_mock_request({"url": new_url}),
|
||||
slug=self.workspace_slug,
|
||||
project_id=self.project_id,
|
||||
issue_id=self.issue_id,
|
||||
pk=self.link_id,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_crawl.delay.assert_called_once_with(self.link_id, new_url)
|
||||
mock_activity.delay.assert_called_once()
|
||||
Reference in New Issue
Block a user