Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.main import (
|
||||
app,
|
||||
client_ip,
|
||||
request_trace_id,
|
||||
required_user_audit_context,
|
||||
user_agent_hash,
|
||||
)
|
||||
from app.schemas import Device
|
||||
from app.services import AuditContext, DomainError, audit, device_snapshot
|
||||
|
||||
|
||||
def request(
|
||||
*,
|
||||
peer: str = "172.18.0.5",
|
||||
forwarded: str | None = None,
|
||||
traceparent: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
ux_session_id: str | None = None,
|
||||
trusted: str = "172.16.0.0/12",
|
||||
) -> Request:
|
||||
headers: list[tuple[bytes, bytes]] = []
|
||||
for name, value in (
|
||||
("x-forwarded-for", forwarded),
|
||||
("traceparent", traceparent),
|
||||
("user-agent", user_agent),
|
||||
("x-ux-session-id", ux_session_id),
|
||||
):
|
||||
if value is not None:
|
||||
headers.append((name.encode(), value.encode()))
|
||||
settings = SimpleNamespace(trusted_proxy_cidrs=trusted)
|
||||
app = SimpleNamespace(state=SimpleNamespace(settings=settings))
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": headers,
|
||||
"client": (peer, 12345),
|
||||
"server": ("test", 443),
|
||||
"scheme": "https",
|
||||
"query_string": b"",
|
||||
"app": app,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_client_ip_only_trusts_forwarded_header_from_configured_proxy() -> None:
|
||||
assert client_ip(request(forwarded="203.0.113.10")) == "203.0.113.10"
|
||||
assert (
|
||||
client_ip(request(peer="198.51.100.7", forwarded="203.0.113.10"))
|
||||
== "198.51.100.7"
|
||||
)
|
||||
assert client_ip(request(peer="not-an-ip")) is None
|
||||
|
||||
|
||||
def test_trace_id_uses_valid_w3c_header_and_generates_fallback() -> None:
|
||||
trace_id = "1" * 32
|
||||
assert request_trace_id(request(traceparent=f"00-{trace_id}-{'2' * 16}-01")) == trace_id
|
||||
fallback = request_trace_id(request(traceparent="invalid"))
|
||||
assert len(fallback) == 32
|
||||
int(fallback, 16)
|
||||
|
||||
|
||||
def test_user_agent_is_hashed_and_device_parameters_are_preserved() -> None:
|
||||
agent = "Example Browser/1.0"
|
||||
assert user_agent_hash(request(user_agent=agent)) == hashlib.sha256(agent.encode()).hexdigest()
|
||||
snapshot = device_snapshot(
|
||||
Device(platform="web", app_version="1.2.3", device_id="raw-device-id")
|
||||
)
|
||||
assert snapshot == {
|
||||
"platform": "web",
|
||||
"app_version": "1.2.3",
|
||||
"device_id": "raw-device-id",
|
||||
}
|
||||
|
||||
|
||||
def test_audit_copies_request_context_and_bounded_metadata() -> None:
|
||||
session_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
context = AuditContext(
|
||||
request_id=str(uuid.uuid4()),
|
||||
trace_id="a" * 32,
|
||||
ux_session_id=session_id,
|
||||
user_agent_hash="b" * 64,
|
||||
client_ip="203.0.113.10",
|
||||
)
|
||||
|
||||
event = audit(
|
||||
"dialog.created",
|
||||
context,
|
||||
user_id,
|
||||
"dialog",
|
||||
uuid.uuid4(),
|
||||
metadata={"status": "open"},
|
||||
)
|
||||
|
||||
assert event.user_id == user_id
|
||||
assert event.ux_session_id == session_id
|
||||
assert event.trace_id == "a" * 32
|
||||
assert event.user_agent_hash == "b" * 64
|
||||
assert event.metadata_json == {"status": "open"}
|
||||
assert event.outcome == "success"
|
||||
assert not hasattr(event, "ip")
|
||||
|
||||
failed = audit("message.failed", context, user_id, outcome="failed")
|
||||
assert failed.outcome == "failed"
|
||||
|
||||
|
||||
def test_post_session_routes_publish_required_ux_header_in_openapi() -> None:
|
||||
operation = app.openapi()["paths"]["/api/v1/dialogs"]["post"]
|
||||
ux_header = next(
|
||||
parameter
|
||||
for parameter in operation["parameters"]
|
||||
if parameter["name"] == "X-Ux-Session-Id"
|
||||
)
|
||||
assert ux_header["in"] == "header"
|
||||
assert ux_header["required"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_audit_context_requires_existing_owned_session() -> None:
|
||||
session_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4())
|
||||
incoming = request(
|
||||
forwarded="203.0.113.10",
|
||||
ux_session_id=str(session_id),
|
||||
user_agent="Example Browser/1.0",
|
||||
)
|
||||
incoming.state.request_id = str(uuid.uuid4())
|
||||
incoming.state.trace_id = "c" * 32
|
||||
incoming.state.user_agent_hash = user_agent_hash(incoming)
|
||||
|
||||
class Db:
|
||||
async def scalar(self, _query):
|
||||
return session_id
|
||||
|
||||
context = await required_user_audit_context(incoming, Db(), user, str(session_id))
|
||||
assert context.ux_session_id == session_id
|
||||
assert context.client_ip == "203.0.113.10"
|
||||
|
||||
missing = request(forwarded="203.0.113.10")
|
||||
missing.state.request_id = str(uuid.uuid4())
|
||||
missing.state.trace_id = "d" * 32
|
||||
missing.state.user_agent_hash = None
|
||||
with pytest.raises(DomainError, match="X-Ux-Session-Id is required"):
|
||||
await required_user_audit_context(missing, Db(), user, "")
|
||||
@@ -0,0 +1,78 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.cli.seed_settings import load_seed
|
||||
from app.services import REQUIRED_SETTINGS
|
||||
|
||||
|
||||
def test_production_like_seed_contains_all_mandatory_settings() -> None:
|
||||
path = Path(__file__).resolve().parents[3] / "deployment/app-settings.production-like.yaml"
|
||||
rows = load_seed(path)
|
||||
|
||||
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
|
||||
assert all(row["record_status"] == "A" for row in rows)
|
||||
values = {row["setting_key"]: row["setting_value"] for row in rows}
|
||||
assert values["otp.phone.code_length"] == "6"
|
||||
assert values["otp.phone.ttl_seconds"] == "60"
|
||||
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
|
||||
assert values["chat.message.max_length"] == "4000"
|
||||
|
||||
|
||||
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
" bad.integer: {type: integer, value: nope, public: false}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="integer value expected"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value", "message"),
|
||||
[
|
||||
("otp.phone.code_length", 3, "between 4 and 10"),
|
||||
("otp.phone.ttl_seconds", 61, "divisible by 60"),
|
||||
("otp.phone.sms_order_timeout_ms", 0, "must be positive"),
|
||||
],
|
||||
)
|
||||
def test_seed_rejects_invalid_otp_settings(
|
||||
tmp_path: Path, key: str, value: int, message: str
|
||||
) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
f" {key}: {{type: integer, value: {value}, public: false}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
" otp.phone.code_length: {type: integer, value: 6, public: true}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must not be public"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 10001])
|
||||
def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
f" chat.message.max_length: {{type: integer, value: {value}, public: true}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
|
||||
load_seed(path)
|
||||
@@ -0,0 +1,104 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.auth import canonical_phone
|
||||
from app.integrations import CircuitBreaker, RateLimiter
|
||||
from app.postgres import asyncpg_dsn
|
||||
from app.schemas import (
|
||||
FileMessageRequest,
|
||||
MessageRequest,
|
||||
TextMessageRequest,
|
||||
canonical_fingerprint,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message
|
||||
|
||||
|
||||
def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None:
|
||||
url = (
|
||||
"postgresql+asyncpg://user:password@db:5433/han_chat"
|
||||
"?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem"
|
||||
)
|
||||
|
||||
assert asyncpg_dsn(url) == url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||
|
||||
|
||||
def test_phone_claim_priority_and_e164_validation() -> None:
|
||||
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
|
||||
assert canonical_phone(claims) == "+74999591007"
|
||||
assert canonical_phone({"phone_number": "89999999999"}) is None
|
||||
|
||||
|
||||
def test_message_discriminated_union() -> None:
|
||||
adapter = TypeAdapter(MessageRequest)
|
||||
assert isinstance(
|
||||
adapter.validate_python({"content_kind": "text", "text": "Здравствуйте"}),
|
||||
TextMessageRequest,
|
||||
)
|
||||
assert isinstance(
|
||||
adapter.validate_python(
|
||||
{
|
||||
"content_kind": "file",
|
||||
"attachment_id": str(uuid.uuid4()),
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
}
|
||||
),
|
||||
FileMessageRequest,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
adapter.validate_python(
|
||||
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
|
||||
)
|
||||
longest = adapter.validate_python({"content_kind": "text", "text": "а" * 10_000})
|
||||
assert len(longest.text) == 10_000
|
||||
with pytest.raises(ValidationError):
|
||||
adapter.validate_python({"content_kind": "text", "text": "а" * 10_001})
|
||||
|
||||
|
||||
def test_message_safety_business_replies_are_content_specific() -> None:
|
||||
assert "переформулировать" in MESSAGE_SAFETY_REPLIES["text"]
|
||||
assert "документ" in MESSAGE_SAFETY_REPLIES["file"]
|
||||
reply = safety_reply_message(uuid.uuid4(), "text")
|
||||
assert reply.sender_type == "company"
|
||||
assert reply.safety_status == "allowed"
|
||||
assert reply.delivery_status == "delivered"
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical_and_user_scoped() -> None:
|
||||
user = uuid.uuid4()
|
||||
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
|
||||
second = canonical_fingerprint("POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, user)
|
||||
assert first == second
|
||||
assert first != canonical_fingerprint(
|
||||
"POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, uuid.uuid4()
|
||||
)
|
||||
|
||||
|
||||
def test_cursor_roundtrip_and_tamper_rejection() -> None:
|
||||
secret = b"test-secret" * 4
|
||||
cursor = encode_cursor({"id": str(uuid.uuid4()), "created_at": "2026-01-01"}, secret)
|
||||
assert decode_cursor(cursor, secret)["created_at"] == "2026-01-01"
|
||||
with pytest.raises(ValueError, match="invalid cursor"):
|
||||
decode_cursor(cursor[:-2] + "aa", secret)
|
||||
|
||||
|
||||
def test_rate_limit_keys_do_not_expose_identity() -> None:
|
||||
key = RateLimiter.key("ip", "203.0.113.7", "public", 60)
|
||||
assert "203.0.113.7" not in key
|
||||
assert key.startswith("han:api:rl:ip:")
|
||||
|
||||
|
||||
def test_circuit_breaker_opens_and_half_opens(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clock = [10.0]
|
||||
monkeypatch.setattr("app.integrations.time.monotonic", lambda: clock[0])
|
||||
breaker = CircuitBreaker(2, 30)
|
||||
breaker.failure()
|
||||
breaker.failure()
|
||||
assert not breaker.allow()
|
||||
clock[0] = 41.0
|
||||
assert breaker.allow()
|
||||
breaker.success()
|
||||
assert breaker.allow()
|
||||
@@ -0,0 +1,31 @@
|
||||
from app.logging_security import REDACTED, redact_event, sanitize_text
|
||||
|
||||
|
||||
def test_redacts_sensitive_fields_recursively() -> None:
|
||||
event = {
|
||||
"authorization": "Bearer top-secret",
|
||||
"nested": {
|
||||
"database_url": "postgresql://user:password@db/app",
|
||||
"safe": "kept",
|
||||
},
|
||||
}
|
||||
|
||||
redacted = redact_event(None, "info", event)
|
||||
|
||||
assert redacted["authorization"] == REDACTED
|
||||
assert redacted["nested"]["database_url"] == REDACTED
|
||||
assert redacted["nested"]["safe"] == "kept"
|
||||
|
||||
|
||||
def test_redacts_credentials_embedded_in_text() -> None:
|
||||
value = (
|
||||
"POST https://callback-user:callback-password@example.test/cb"
|
||||
"?token=query-secret Authorization=Bearer header-secret"
|
||||
)
|
||||
|
||||
redacted = sanitize_text(value)
|
||||
|
||||
assert "callback-password" not in redacted
|
||||
assert "query-secret" not in redacted
|
||||
assert "header-secret" not in redacted
|
||||
assert redacted.count(REDACTED) == 3
|
||||
@@ -0,0 +1,589 @@
|
||||
import ast
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.db import Document
|
||||
from app.notification_models import (
|
||||
ClientUploadDraft,
|
||||
Notification,
|
||||
NotificationButton,
|
||||
NotificationDocument,
|
||||
NotificationSource,
|
||||
NotificationType,
|
||||
uuid7,
|
||||
)
|
||||
from app.notification_schemas import NotificationCreateRequest
|
||||
from app.notification_service import (
|
||||
_apply_hidden_ttl,
|
||||
apply_read_or_hide,
|
||||
authenticate_source,
|
||||
catalog,
|
||||
create_notification,
|
||||
document_download,
|
||||
expire_notifications,
|
||||
fingerprint,
|
||||
invoke_cta_state,
|
||||
press_button,
|
||||
source_token_hash,
|
||||
validate_create,
|
||||
)
|
||||
from app.realtime import USER_CHANNEL_PREFIX, RealtimeFanout
|
||||
from app.services import AuditContext, DomainError, SettingsSnapshot
|
||||
from app.workers import notification_draft_cleanup_once
|
||||
|
||||
|
||||
def test_notification_migration_does_not_prepare_multiple_sql_commands() -> None:
|
||||
migration = Path("alembic/versions/0008_notification_center_v1.py")
|
||||
tree = ast.parse(migration.read_text(encoding="utf-8"))
|
||||
for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
|
||||
if (
|
||||
isinstance(call.func, ast.Attribute)
|
||||
and call.func.attr == "execute"
|
||||
and call.args
|
||||
and isinstance(call.args[0], ast.Call)
|
||||
and isinstance(call.args[0].func, ast.Attribute)
|
||||
and call.args[0].func.attr == "text"
|
||||
and call.args[0].args
|
||||
and isinstance(call.args[0].args[0], ast.Constant)
|
||||
and isinstance(call.args[0].args[0].value, str)
|
||||
):
|
||||
assert call.args[0].args[0].value.count(";") <= 1
|
||||
|
||||
|
||||
def create_body(**changes: object) -> NotificationCreateRequest:
|
||||
values: dict[str, object] = {
|
||||
"user_id": uuid.uuid4(),
|
||||
"notification_type": "news",
|
||||
"source": "producer_test",
|
||||
"external_id": "event-1",
|
||||
"notification_datetime": "2026-07-27T12:00:00Z",
|
||||
"header": "Новость",
|
||||
"details": {"details_text": "Текст"},
|
||||
}
|
||||
values.update(changes)
|
||||
return NotificationCreateRequest.model_validate(values)
|
||||
|
||||
|
||||
def context() -> AuditContext:
|
||||
return AuditContext("request-1", "trace-1", None, None, None)
|
||||
|
||||
|
||||
def snapshot(default_ttl: int = 3) -> SettingsSnapshot:
|
||||
return SettingsSnapshot(
|
||||
{
|
||||
"notification.hidden.default_ttl_days": str(default_ttl),
|
||||
"notification.center.max_items": "15",
|
||||
},
|
||||
"v1",
|
||||
)
|
||||
|
||||
|
||||
def notification(**changes: object) -> Notification:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid.uuid4(),
|
||||
"user_id": uuid.uuid4(),
|
||||
"notification_type": "news",
|
||||
"source": "producer_test",
|
||||
"external_id": "event-1",
|
||||
"request_fingerprint": "a" * 64,
|
||||
"notification_datetime": datetime.now(UTC),
|
||||
"header": "Header",
|
||||
"lifecycle_status": "active",
|
||||
"visibility": "visible",
|
||||
"is_read": False,
|
||||
"date_expired": None,
|
||||
"close_reason": None,
|
||||
"closed_at": None,
|
||||
}
|
||||
values.update(changes)
|
||||
return Notification(**values)
|
||||
|
||||
|
||||
def kind(**changes: object) -> NotificationType:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid.uuid4(),
|
||||
"code": "news",
|
||||
"contour": "P",
|
||||
"priority": 4,
|
||||
"countable": True,
|
||||
"label": "Новость",
|
||||
"color_token": "info",
|
||||
"icon_code": "news",
|
||||
"cta_text": "Подробнее",
|
||||
"cta_action": "open_detail",
|
||||
"cta_sets_hidden": False,
|
||||
"cta_close_reason": None,
|
||||
"button_primary_code": "gotit",
|
||||
"button_secondary_code": None,
|
||||
"hidden_ttl_days": None,
|
||||
"documents_allowed": False,
|
||||
"hide_on_document_download": False,
|
||||
"required_detail_blocks": [],
|
||||
}
|
||||
values.update(changes)
|
||||
return NotificationType(**values)
|
||||
|
||||
|
||||
class ScalarRows:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self.rows = rows
|
||||
|
||||
def scalars(self) -> "ScalarRows":
|
||||
return self
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self.rows
|
||||
|
||||
|
||||
def test_uuid7_has_rfc_version_variant_and_embedded_timestamp(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
timestamp_ns = 1_722_340_800_123_000_000
|
||||
monkeypatch.setattr("app.notification_models.time.time_ns", lambda: timestamp_ns)
|
||||
monkeypatch.setattr("app.notification_models.secrets.randbits", lambda bits: (1 << bits) - 1)
|
||||
|
||||
value = uuid7()
|
||||
|
||||
assert value.version == 7
|
||||
assert value.variant == uuid.RFC_4122
|
||||
assert value.int >> 80 == timestamp_ns // 1_000_000
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical_stable_and_sensitive_to_body() -> None:
|
||||
first = create_body()
|
||||
same = NotificationCreateRequest.model_validate(first.model_dump(mode="json"))
|
||||
changed = create_body(header="Другая новость")
|
||||
|
||||
assert fingerprint(first) == fingerprint(same)
|
||||
assert fingerprint(first) != fingerprint(changed)
|
||||
assert len(fingerprint(first)) == 64
|
||||
|
||||
|
||||
def test_create_schema_rejects_read_only_or_unknown_detail_blocks() -> None:
|
||||
with pytest.raises(ValidationError) as pending:
|
||||
create_body(details={"details_text": "Text", "pending_documents": []})
|
||||
with pytest.raises(ValidationError) as unknown:
|
||||
create_body(details={"details_text": "Text", "invented": True})
|
||||
|
||||
assert "pending_documents" in str(pending.value)
|
||||
assert "invented" in str(unknown.value)
|
||||
|
||||
|
||||
def test_catalog_driven_create_validation_covers_required_and_forbidden_fields() -> None:
|
||||
action = SimpleNamespace(required_instance_fields=["details"])
|
||||
docs_kind = kind(
|
||||
required_detail_blocks=["documents"],
|
||||
documents_allowed=True,
|
||||
button_primary_code="gotit",
|
||||
)
|
||||
|
||||
with pytest.raises(DomainError) as error:
|
||||
validate_create(create_body(details={"details_text": "No documents"}), docs_kind, action)
|
||||
|
||||
assert error.value.code == "validation_error"
|
||||
assert error.value.details["fields"] == ["details.documents"]
|
||||
|
||||
with pytest.raises(DomainError) as forbidden:
|
||||
validate_create(
|
||||
create_body(
|
||||
details={"details_text": "Text"},
|
||||
payment_url="https://pay.example/order",
|
||||
),
|
||||
kind(),
|
||||
action,
|
||||
)
|
||||
assert forbidden.value.details["fields"] == ["payment_url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_create_returns_existing_only_for_matching_fingerprint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
body = create_body()
|
||||
existing = notification(request_fingerprint=fingerprint(body))
|
||||
session = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=existing),
|
||||
add=Mock(),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
dto = {"id": str(existing.id)}
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.notification_dto", AsyncMock(return_value=dto)
|
||||
)
|
||||
|
||||
result, status = await create_notification(
|
||||
session,
|
||||
body,
|
||||
SimpleNamespace(code="producer_test"),
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
context(),
|
||||
)
|
||||
assert (result, status) == (dto, 200)
|
||||
|
||||
existing.request_fingerprint = "0" * 64
|
||||
with pytest.raises(DomainError) as conflict:
|
||||
await create_notification(
|
||||
session,
|
||||
body,
|
||||
SimpleNamespace(code="producer_test"),
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
context(),
|
||||
)
|
||||
assert conflict.value.code == "notification_conflict"
|
||||
assert conflict.value.status == 409
|
||||
assert conflict.value.details == {"notification_id": str(existing.id)}
|
||||
|
||||
|
||||
def test_hidden_ttl_preserves_existing_expiry_and_uses_type_or_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
now = datetime(2026, 7, 27, 12, tzinfo=UTC)
|
||||
monkeypatch.setattr("app.notification_service.datetime", SimpleNamespace(now=lambda tz: now))
|
||||
existing = now + timedelta(hours=2)
|
||||
with_expiry = notification(date_expired=existing)
|
||||
type_specific = notification()
|
||||
defaulted = notification()
|
||||
|
||||
_apply_hidden_ttl(with_expiry, kind(hidden_ttl_days=9), snapshot())
|
||||
_apply_hidden_ttl(type_specific, kind(hidden_ttl_days=5), snapshot())
|
||||
_apply_hidden_ttl(defaulted, kind(hidden_ttl_days=None), snapshot(3))
|
||||
|
||||
assert with_expiry.date_expired == existing
|
||||
assert type_specific.date_expired == now + timedelta(days=5)
|
||||
assert defaulted.date_expired == now + timedelta(days=3)
|
||||
assert {with_expiry.visibility, type_specific.visibility, defaulted.visibility} == {"hidden"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hide_applies_ttl_without_marking_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = notification()
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.owned_notification",
|
||||
AsyncMock(return_value=(item, kind(hidden_ttl_days=2))),
|
||||
)
|
||||
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=1))
|
||||
session = SimpleNamespace(add=Mock(), commit=AsyncMock())
|
||||
fanout = SimpleNamespace(publish_user=AsyncMock())
|
||||
|
||||
result = await apply_read_or_hide(
|
||||
session,
|
||||
item.user_id,
|
||||
item.id,
|
||||
"hide",
|
||||
snapshot(),
|
||||
fanout,
|
||||
context(),
|
||||
)
|
||||
|
||||
assert result["visibility"] == "hidden"
|
||||
assert result["is_read"] is False
|
||||
assert item.date_expired is not None
|
||||
event = fanout.publish_user.await_args.args[1]
|
||||
assert event["date_expired"] == item.date_expired
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cta_and_buttons_follow_catalog_lifecycle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = notification()
|
||||
cta_kind = kind(cta_action="open_detail")
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.owned_notification",
|
||||
AsyncMock(return_value=(item, cta_kind)),
|
||||
)
|
||||
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
|
||||
session = SimpleNamespace(add=Mock(), commit=AsyncMock(), scalar=AsyncMock())
|
||||
fanout = SimpleNamespace(publish_user=AsyncMock())
|
||||
|
||||
_, _, cta_result = await invoke_cta_state(
|
||||
session, item.user_id, item.id, snapshot(), fanout, context()
|
||||
)
|
||||
assert item.is_read is True
|
||||
assert item.visibility == "visible"
|
||||
assert item.lifecycle_status == "active"
|
||||
assert cta_result["result"]["action"] == "open_detail"
|
||||
|
||||
button = NotificationButton(
|
||||
id=uuid.uuid4(),
|
||||
code="done",
|
||||
label="Готово",
|
||||
sets_hidden=False,
|
||||
applies_hidden_ttl=False,
|
||||
close_reason="user_done",
|
||||
submits_documents=False,
|
||||
)
|
||||
session.scalar.return_value = button
|
||||
cta_kind.button_primary_code = "done"
|
||||
result = await press_button(
|
||||
session, item.user_id, item.id, "done", snapshot(), fanout, context()
|
||||
)
|
||||
assert result["lifecycle_status"] == "closed"
|
||||
assert result["close_reason"] == "user_done"
|
||||
assert item.closed_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_download_of_any_document_hides_once_and_preserves_expiry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
expires = datetime.now(UTC) + timedelta(hours=1)
|
||||
item = notification(date_expired=expires)
|
||||
download_kind = kind(
|
||||
code="docs_ready",
|
||||
documents_allowed=True,
|
||||
hide_on_document_download=True,
|
||||
)
|
||||
link = NotificationDocument(
|
||||
id=uuid.uuid4(),
|
||||
notification_id=item.id,
|
||||
document_id=uuid.uuid4(),
|
||||
sort_order=0,
|
||||
download_url_issued_at=None,
|
||||
)
|
||||
document = Document(
|
||||
id=link.document_id,
|
||||
user_id=item.user_id,
|
||||
name="result.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=42,
|
||||
checksum_sha256="a" * 64,
|
||||
storage_bucket="documents",
|
||||
object_key="documents/result.pdf",
|
||||
sent_at=datetime.now(UTC),
|
||||
)
|
||||
row_result = SimpleNamespace(one_or_none=lambda: (link, document))
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=row_result),
|
||||
scalar=AsyncMock(return_value=0),
|
||||
add=Mock(),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.owned_notification",
|
||||
AsyncMock(return_value=(item, download_kind)),
|
||||
)
|
||||
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
|
||||
fanout = SimpleNamespace(publish_user=AsyncMock())
|
||||
s3 = SimpleNamespace(presign_get=AsyncMock(return_value="https://download.example/file"))
|
||||
|
||||
result = await document_download(
|
||||
session,
|
||||
item.user_id,
|
||||
item.id,
|
||||
document.id,
|
||||
snapshot(),
|
||||
s3,
|
||||
fanout,
|
||||
context(),
|
||||
)
|
||||
|
||||
assert result["download_url"] == "https://download.example/file"
|
||||
assert item.is_read is True
|
||||
assert item.visibility == "hidden"
|
||||
assert item.date_expired == expires
|
||||
assert link.download_url_issued_at is not None
|
||||
fanout.publish_user.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_token_hash_and_producer_authentication() -> None:
|
||||
value = "producer-secret"
|
||||
source = NotificationSource(
|
||||
id=uuid.uuid4(),
|
||||
code="producer_test",
|
||||
description="test",
|
||||
token_hash=source_token_hash(value),
|
||||
token_rotated_at=None,
|
||||
record_status="A",
|
||||
)
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=ScalarRows([source])))
|
||||
|
||||
assert await authenticate_source(session, f"Bearer {value}") is source
|
||||
assert source.token_hash != value
|
||||
with pytest.raises(DomainError) as invalid:
|
||||
await authenticate_source(session, "Bearer wrong")
|
||||
assert (invalid.value.code, invalid.value.status) == ("unauthorized", 401)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_dto_is_sorted_and_contains_only_public_behavior() -> None:
|
||||
types = [
|
||||
kind(code="news", contour="P", priority=4, button_primary_code="gotit"),
|
||||
kind(
|
||||
code="urgent",
|
||||
contour="P",
|
||||
priority=1,
|
||||
label="Срочно",
|
||||
button_primary_code="done",
|
||||
button_secondary_code="later",
|
||||
),
|
||||
]
|
||||
buttons = [
|
||||
NotificationButton(id=uuid.uuid4(), code="done", label="Готово"),
|
||||
NotificationButton(id=uuid.uuid4(), code="later", label="Позже"),
|
||||
NotificationButton(id=uuid.uuid4(), code="gotit", label="Понятно"),
|
||||
]
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=[ScalarRows(types), ScalarRows(buttons)])
|
||||
)
|
||||
|
||||
result = await catalog(session)
|
||||
|
||||
assert [item["code"] for item in result] == ["urgent", "news"]
|
||||
assert result[0]["button_primary"] == {"code": "done", "label": "Готово"}
|
||||
assert result[0]["button_secondary"] == {"code": "later", "label": "Позже"}
|
||||
assert not {
|
||||
"hidden_ttl_days",
|
||||
"cta_sets_hidden",
|
||||
"cta_close_reason",
|
||||
"required_detail_blocks",
|
||||
} & result[0].keys()
|
||||
button_statement = session.execute.await_args_list[1].args[0]
|
||||
assert "notification_buttons.record_status" in str(button_statement)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_uses_per_user_channel_and_strips_internal_identity() -> None:
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
class PubSub:
|
||||
def __init__(self) -> None:
|
||||
self.channels: tuple[str, ...] = ()
|
||||
|
||||
async def subscribe(self, *channels: str) -> None:
|
||||
self.channels = channels
|
||||
|
||||
async def get_message(self, **_kwargs: object) -> dict[str, str]:
|
||||
return {
|
||||
"data": json.dumps(
|
||||
{
|
||||
"type": "notification.updated",
|
||||
"_user_id": str(user_id),
|
||||
"notification_id": str(uuid.uuid4()),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async def unsubscribe(self, *_channels: str) -> None:
|
||||
return None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
pubsub = PubSub()
|
||||
redis = SimpleNamespace(publish=AsyncMock(), pubsub=lambda: pubsub)
|
||||
fanout = RealtimeFanout(redis)
|
||||
|
||||
await fanout.publish_user(user_id, {"type": "notification.created"})
|
||||
channel, payload = redis.publish.await_args.args
|
||||
assert channel == USER_CHANNEL_PREFIX + str(user_id)
|
||||
assert json.loads(payload)["_user_id"] == str(user_id)
|
||||
|
||||
stream = cast(
|
||||
AsyncGenerator[dict[str, Any], None],
|
||||
fanout.events(set(), user_id, notifications=True),
|
||||
)
|
||||
event = await anext(stream)
|
||||
await stream.aclose()
|
||||
assert pubsub.channels == (USER_CHANNEL_PREFIX + str(user_id),)
|
||||
assert event["type"] == "notification.updated"
|
||||
assert "_user_id" not in event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expire_job_is_locked_set_based_and_commits() -> None:
|
||||
session = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=True),
|
||||
execute=AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(rowcount=3),
|
||||
SimpleNamespace(rowcount=2),
|
||||
]
|
||||
),
|
||||
add=Mock(),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
|
||||
assert await expire_notifications(session) == (3, 2)
|
||||
assert session.execute.await_count == 2
|
||||
personal_sql = str(session.execute.await_args_list[0].args[0])
|
||||
guest_sql = str(session.execute.await_args_list[1].args[0])
|
||||
assert "lifecycle_status" in personal_sql and "date_expired" in personal_sql
|
||||
assert "lifecycle_status" in guest_sql and "date_expired" in guest_sql
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_cleanup_deletes_objects_but_keeps_submitted_object(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
abandoned = ClientUploadDraft(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
context_type="notification",
|
||||
context_id=uuid.uuid4(),
|
||||
original_file_name="a.pdf",
|
||||
safe_file_name="a.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=1,
|
||||
storage_bucket="quarantine",
|
||||
object_key="q/a",
|
||||
quarantine_object_key="q/a",
|
||||
state="draft",
|
||||
)
|
||||
submitted = ClientUploadDraft(
|
||||
id=uuid.uuid4(),
|
||||
user_id=abandoned.user_id,
|
||||
context_type="notification",
|
||||
context_id=abandoned.context_id,
|
||||
original_file_name="b.pdf",
|
||||
safe_file_name="b.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=1,
|
||||
storage_bucket="attachments",
|
||||
object_key="a/b",
|
||||
quarantine_object_key=None,
|
||||
state="submitted",
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=[ScalarRows([abandoned, submitted]), None, None]),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
|
||||
class Sessions:
|
||||
async def __aenter__(self) -> object:
|
||||
return session
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
db = SimpleNamespace(sessions=lambda: Sessions())
|
||||
s3 = SimpleNamespace(delete_quarantine=AsyncMock(), delete=AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"app.workers.load_settings",
|
||||
AsyncMock(
|
||||
return_value=SettingsSnapshot(
|
||||
{"notification.upload_draft.ttl_days": "7"}, "v1"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert await notification_draft_cleanup_once(db, s3) == 2
|
||||
s3.delete_quarantine.assert_awaited_once_with("q/a")
|
||||
s3.delete.assert_not_awaited()
|
||||
assert session.execute.await_count == 3
|
||||
session.commit.assert_awaited_once()
|
||||
@@ -0,0 +1,25 @@
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
from app import telemetry
|
||||
|
||||
|
||||
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||
|
||||
assert telemetry.init_telemetry("test-service") is None
|
||||
|
||||
|
||||
def test_structlog_processor_adds_active_trace_context_only() -> None:
|
||||
provider = TracerProvider()
|
||||
tracer = provider.get_tracer("test")
|
||||
event = {"event": "safe", "request_id": "request-1"}
|
||||
|
||||
with tracer.start_as_current_span("operation"):
|
||||
result = telemetry.add_trace_context(None, "info", event)
|
||||
|
||||
assert result["event"] == "safe"
|
||||
assert result["request_id"] == "request-1"
|
||||
assert len(result["trace_id"]) == 32
|
||||
assert len(result["span_id"]) == 16
|
||||
assert set(result) == {"event", "request_id", "trace_id", "span_id"}
|
||||
Reference in New Issue
Block a user