353 lines
12 KiB
Python
353 lines
12 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_case_history_shows_actor_timestamp_and_all_case_activity_filters():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "<strong>Hvornår:</strong>" in template
|
|
assert "<strong>Hvem:</strong>" in template
|
|
assert 'id="historyFilterCaseActivity"' in template
|
|
assert "['case', 'tag', 'todo', 'relation']" in template
|
|
|
|
|
|
def test_case_timeline_uses_real_actors_and_action_timestamps():
|
|
source = Path("app/modules/sag/backend/router.py").read_text()
|
|
|
|
assert '"title": "Sag oprettet"' in source
|
|
assert 'r.created_at' in source
|
|
assert 'employee.user_id = t.medarbejder_id' in source
|
|
assert 'LOWER(recorder.username) = LOWER(t.user_name)' in source
|
|
assert 'Registreret for: {employee_name}' in source
|
|
assert '"forfatter": str(row.get("actor_name") or "System")' in source
|
|
assert '"event_type": "tag"' in source
|
|
assert '"event_type": "todo"' in source
|
|
assert '"event_type": "relation"' in source
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_case_sms_listener_does_not_break_click_to_call_script():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
|
|
assert "console.warn('Kunne ikke logge sendt SMS i kommentarer', err);\n }\n });" in template
|
|
assert "async function ringOutFromCase(number, opts = {})" in template
|
|
assert "alert(error?.message" not in template
|
|
|
|
|
|
def test_linked_calls_and_anydesk_sessions_are_normalized_for_case_activity(monkeypatch):
|
|
monkeypatch.setattr(sag_router, "_table_exists", lambda name: name in {"telefoni_opkald", "anydesk_sessions"})
|
|
|
|
def fake_execute_query(query, params=None):
|
|
if "FROM telefoni_opkald" in query:
|
|
return [{
|
|
"id": 11,
|
|
"sag_id": 42,
|
|
"direction": "outbound",
|
|
"ekstern_nummer": "+4530480748",
|
|
"intern_extension": "204",
|
|
"started_at": datetime(2026, 8, 25, 10, 0),
|
|
"ended_at": datetime(2026, 8, 25, 10, 5),
|
|
"duration_sec": 300,
|
|
"actor_name": "Christian",
|
|
"contact_name": "Thomas",
|
|
}]
|
|
if "FROM anydesk_sessions" in query:
|
|
return [{
|
|
"id": 12,
|
|
"anydesk_session_id": "ad-12",
|
|
"sag_id": 42,
|
|
"started_at": datetime(2026, 8, 25, 11, 0),
|
|
"ended_at": datetime(2026, 8, 25, 11, 30),
|
|
"duration_minutes": 30,
|
|
"status": "completed",
|
|
"actor_name": "Christian",
|
|
"contact_name": "Thomas",
|
|
}]
|
|
return []
|
|
|
|
monkeypatch.setattr(sag_router, "execute_query", fake_execute_query)
|
|
|
|
activities = sag_router._get_case_linked_activities([42])
|
|
|
|
assert [item["activity_type"] for item in activities] == ["anydesk", "call"]
|
|
assert activities[0]["duration_minutes"] == 30
|
|
assert activities[1]["duration_minutes"] == 5
|
|
assert activities[1]["title"] == "Udgående opkald"
|
|
|
|
|
|
def test_time_tab_and_history_include_linked_case_activities():
|
|
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
|
|
source = Path("app/modules/sag/backend/router.py").read_text()
|
|
|
|
assert 'id="caseLinkedActivitiesList"' in template
|
|
assert "/linked-activities" in template
|
|
assert "renderCaseLinkedActivities(linkedActivities)" in template
|
|
assert 'event_type": activity_type' in source
|
|
assert 'source": "call" if activity_type == "call" else "anydesk"' in source
|