bmc_hub/tests/test_sag_module.py
2026-08-18 20:57:49 +02:00

270 lines
8.6 KiB
Python

from datetime import datetime
from io import BytesIO
from pathlib import Path
import asyncio
import sys
import types
import pytest
from fastapi import HTTPException, UploadFile, Request
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.utils.safe_html import sanitize_safe_html
# Keep these focused unit tests independent of optional auth runtime packages.
auth_dependencies_stub = types.ModuleType("app.core.auth_dependencies")
def _allow_test_user(*_permissions):
async def dependency(_request: Request):
return {"id": 1, "username": "test", "permissions": []}
return dependency
auth_dependencies_stub.require_any_permission = _allow_test_user
async def _get_test_user(_request: Request):
return {
"id": 1,
"username": "test",
"permissions": ["cases.view", "cases.create", "cases.edit", "cases.delete"],
}
auth_dependencies_stub.get_current_user = _get_test_user
sys.modules.setdefault("app.core.auth_dependencies", auth_dependencies_stub)
from app.modules.sag.backend import router as sag_router
def test_normalize_timestamp_converts_offset_to_utc():
value = sag_router._normalize_optional_timestamp(
"2026-07-27T12:00:00+02:00",
"deadline",
)
assert value == "2026-07-27 10:00:00"
def test_normalize_timestamp_accepts_naive_datetime():
value = sag_router._normalize_optional_timestamp(
datetime(2026, 7, 27, 12, 30),
"deadline",
)
assert value == "2026-07-27 12:30:00"
def test_normalize_timestamp_rejects_invalid_value():
with pytest.raises(HTTPException) as exc_info:
sag_router._normalize_optional_timestamp("not-a-date", "deadline")
assert exc_info.value.status_code == 400
def test_attachment_path_rejects_escape_from_upload_root():
with pytest.raises(HTTPException) as exc_info:
sag_router._resolve_attachment_path("../../outside.txt")
assert exc_info.value.status_code == 400
def test_attachment_path_accepts_case_subdirectory():
path = sag_router._resolve_attachment_path("sag_files/example.txt")
path.relative_to(sag_router.UPLOAD_BASE_PATH)
def test_upload_rejects_disallowed_extension():
upload = UploadFile(filename="payload.html", file=BytesIO(b"<script>alert(1)</script>"))
with pytest.raises(HTTPException) as exc_info:
sag_router._store_upload_file(upload, sag_router.SAG_FILE_SUBDIR)
assert exc_info.value.status_code == 400
assert "not allowed" in str(exc_info.value.detail)
def test_relation_input_normalizes_valid_relation():
target_id, relation_type = sag_router._normalize_relation_input(
10,
{"målsag_id": "11", "relationstype": " Blokkerer "},
)
assert target_id == 11
assert relation_type == "Blokkerer"
@pytest.mark.parametrize(
("raw_type", "expected"),
[
("Relateret til", "Relateret til"),
("Afledt af", "Afledt af"),
("Årsag til", "Årsag til"),
("afledt_af", "Afledt af"),
("afhænger af", "afhænger af"),
("undersag", "undersag"),
("duplikat", "duplikat"),
],
)
def test_relation_input_accepts_ui_and_existing_database_types(raw_type, expected):
_, relation_type = sag_router._normalize_relation_input(
10,
{"målsag_id": 11, "relationstype": raw_type},
)
assert relation_type == expected
def test_relation_quick_task_uses_todo_steps_api():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "fetch(`/api/v1/sag/${caseId}/todo-steps`" in template
assert "fetch(`/api/v1/sag/${caseId}/todos`" not in template
assert "due_date: due" in template
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "openForwardLinkedEmail('latest')" in template
assert "openForwardLinkedEmail('thread')" in template
assert "stripQuotedEmailHistory" in template
assert "caseEmailComposeMode = 'forward'" in template
assert "thread_email_id: isNewThread ? null" in template
assert "linkedEmailsCache\n .filter" in template
def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
source = Path("app/modules/sag/frontend/views.py").read_text()
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
def test_case_create_lists_contacts_for_selected_customer():
template = Path("app/modules/sag/templates/create.html").read_text()
assert 'id="contactSearchContext"' in template
assert "loadSelectedCustomerContacts(id);" in template
assert "`/api/v1/customers/${customerId}/contacts`" in template
assert "if (type === 'contact' && selectedCustomer)" in template
assert "renderCustomerContactResults(contactInput.value);" in template
assert "resetCustomerContactSearch();" in template
def test_time_employee_picker_is_clearly_separate_from_live_tracking():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "Manuel registrering for" in template
assert "Mig · vælg flere" in template
assert "assignment_users|length" in template
assert "#timetracking .time-v1-entry-card" in template
assert "overflow: visible !important" in template
def test_case_email_snippet_removes_embedded_css_and_scripts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "function emailSnippetAsPlainText(email)" in template
assert "style, script, noscript, template, head" in template
assert "const snippet = emailSnippetAsPlainText(e).slice(0, 130);" in template
assert "snippetSource.replace(/<[^>]+>/g" not in template
def test_comment_composer_can_close_case_or_wait_for_customer():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert 'id="commentActionCloseCase"' in template
assert 'id="commentActionAwaitCustomer"' in template
assert "actions.push('close_case')" in template
assert "actions.push('await_customer')" in template
assert "await changeCaseStatusFromComment('lukket')" in template
assert "resolveCommentAwaitCustomerStatus()" in template
assert "awaitCustomerCheckbox.checked = false" in template
assert "closeCaseCheckbox.checked = false" in template
def test_comment_actions_use_styled_accessible_chips():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert 'class="comment-action-chips"' in template
assert template.count('class="comment-action-chip action-') == 5
assert '.comment-action-chip:has(input:checked)' in template
assert 'for="commentActionCloseCase"' in template
assert 'for="commentActionAwaitCustomer"' in template
def test_safe_case_html_renders_formatting_and_drops_executable_code():
result = sanitize_safe_html(
'<style>body{display:none}</style>'
'<p onclick="steal()">Hej <strong>verden</strong></p>'
'<script>alert("xss")</script>'
'<a href="javascript:alert(1)">farligt link</a>'
)
assert result == (
'<p>Hej <strong>verden</strong></p>'
'<a target="_blank" rel="noopener noreferrer">farligt link</a>'
)
assert "display:none" not in result
assert "alert" not in result
assert "onclick" not in result
@pytest.mark.parametrize(
"payload",
[
{"målsag_id": 10, "relationstype": "barn"},
{"målsag_id": 11, "relationstype": "ukendt"},
{"målsag_id": "ikke-et-tal", "relationstype": "barn"},
],
)
def test_relation_input_rejects_invalid_relation(payload):
with pytest.raises(HTTPException) as exc_info:
sag_router._normalize_relation_input(10, payload)
assert exc_info.value.status_code == 400
def _request(method: str, path: str) -> Request:
return Request(
{
"type": "http",
"method": method,
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [],
"scheme": "https",
"server": ("testserver", 443),
"client": ("127.0.0.1", 1234),
}
)
def test_case_route_access_allows_view_permission_for_get():
user = {"permissions": ["cases.view"]}
result = asyncio.run(
sag_router.case_route_access(_request("GET", "/api/v1/sag/10"), user)
)
assert result is user
def test_case_route_access_requires_edit_for_nested_post():
user = {"permissions": ["cases.view", "cases.create"]}
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
sag_router.case_route_access(
_request("POST", "/api/v1/sag/10/tags"),
user,
)
)
assert exc_info.value.status_code == 403