更新
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""COS video uploads using short-lived server credentials and the official SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError, ApiTransportError
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
|
||||
_VIDEO_EXTENSIONS = {
|
||||
"wmv", "avi", "mpg", "mpeg", "3gp", "mov", "mp4", "m4v", "flv", "f4v", "rmvb", "mkv", "webm"
|
||||
}
|
||||
_MAX_VIDEO_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
|
||||
def _positive_int(value: Any, field: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
||||
raise ApiProtocolError(f"COS 响应的 {field} 无效")
|
||||
try:
|
||||
result = int(value)
|
||||
except ValueError:
|
||||
raise ApiProtocolError(f"COS 响应的 {field} 无效") from None
|
||||
if result <= 0:
|
||||
raise ApiProtocolError(f"COS 响应的 {field} 无效")
|
||||
return result
|
||||
|
||||
|
||||
def _string(data: Mapping[str, Any], field: str) -> str:
|
||||
value = data.get(field)
|
||||
if not isinstance(value, str) or not value or value != value.strip():
|
||||
raise ApiProtocolError(f"COS 响应缺少有效的 {field}")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||
raise ApiProtocolError(f"COS 响应的 {field} 无效")
|
||||
return value
|
||||
|
||||
|
||||
def _upload_key(credentials: Mapping[str, Any], extension: str) -> str:
|
||||
prefix = _string(credentials, "key_prefix")
|
||||
if not re.fullmatch(r"uploads/video/[0-9]{8}/", prefix):
|
||||
raise ApiProtocolError("COS 视频上传目录无效")
|
||||
try:
|
||||
datetime.strptime(prefix.split("/")[2], "%Y%m%d")
|
||||
except ValueError:
|
||||
raise ApiProtocolError("COS 视频上传目录日期无效") from None
|
||||
|
||||
fixed_key = credentials.get("object_key", "")
|
||||
if not isinstance(fixed_key, str):
|
||||
raise ApiProtocolError("COS 视频对象 Key 无效")
|
||||
if not fixed_key:
|
||||
return f"{prefix}{int(time.time() * 1000)}-{uuid4().hex}.{extension}"
|
||||
suffix = fixed_key.removeprefix(prefix)
|
||||
if (
|
||||
not fixed_key.startswith(prefix)
|
||||
or len(fixed_key) > 1024
|
||||
or not all(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", part) for part in suffix.split("/"))
|
||||
or Path(suffix).suffix.lower() != f".{extension}"
|
||||
):
|
||||
raise ApiProtocolError("COS 视频对象 Key 不在授权范围内")
|
||||
return fixed_key
|
||||
|
||||
|
||||
def _confirmed_result(result: Any, key: str) -> dict[str, Any]:
|
||||
if not isinstance(result, Mapping):
|
||||
raise ApiProtocolError("COS 上传确认响应无效")
|
||||
_positive_int(result.get("id"), "id")
|
||||
address = result.get("uri") or result.get("url")
|
||||
if not isinstance(address, str) or not address or address != address.strip():
|
||||
raise ApiProtocolError("COS 上传确认未返回有效的视频地址")
|
||||
try:
|
||||
parsed = urlsplit(address)
|
||||
# Accessing port also validates malformed/non-numeric authority ports.
|
||||
_ = parsed.port
|
||||
valid = (
|
||||
parsed.scheme in {"https", "http"}
|
||||
and bool(parsed.hostname)
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
and not parsed.fragment
|
||||
and not any(
|
||||
character.isspace() or ord(character) < 32 or ord(character) == 127
|
||||
for character in address
|
||||
)
|
||||
and unquote(parsed.path).endswith("/" + key)
|
||||
)
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ApiProtocolError("COS 上传确认返回的视频地址无效")
|
||||
return dict(result)
|
||||
|
||||
|
||||
def upload_video_to_cos(
|
||||
client: ApiClient,
|
||||
path: Path,
|
||||
*,
|
||||
mime_type: str,
|
||||
on_progress: Callable[[int, int], None] | None = None,
|
||||
on_processing: Callable[[], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload and confirm one video; the caller still owns call-record binding.
|
||||
|
||||
Progress stays below the full file size, including after confirmation. Only
|
||||
the caller can report completion once the recording is attached. SDK worker
|
||||
threads may invoke progress callbacks; callback failures are ignored.
|
||||
"""
|
||||
|
||||
source = path.expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise ValueError("视频文件不存在")
|
||||
size = source.stat().st_size
|
||||
extension = source.suffix.lower().removeprefix(".")
|
||||
if size <= 0 or size > _MAX_VIDEO_SIZE:
|
||||
raise ValueError("视频文件大小须大于 0 且不超过 2 GB")
|
||||
if extension not in _VIDEO_EXTENSIONS:
|
||||
raise ValueError("不支持的视频文件格式")
|
||||
if (
|
||||
not isinstance(mime_type, str)
|
||||
or not re.fullmatch(r"video/[A-Za-z0-9!#$&^_.+-]+(?:;[^\r\n]*)?", mime_type)
|
||||
or len(mime_type) > 120
|
||||
or any(ord(character) < 32 or ord(character) == 127 for character in mime_type)
|
||||
):
|
||||
raise ValueError("视频 MIME 类型无效")
|
||||
|
||||
last_progress = -1
|
||||
progress_lock = RLock()
|
||||
|
||||
def report(sent: int, _total: int = size) -> None:
|
||||
nonlocal last_progress
|
||||
with progress_lock, suppress(Exception):
|
||||
confirmed = max(0, min(int(sent), size - 1))
|
||||
if confirmed > last_progress:
|
||||
last_progress = confirmed
|
||||
if on_progress is not None:
|
||||
on_progress(confirmed, size)
|
||||
|
||||
report(0)
|
||||
response = client.post("upload/ossCredentials", {"type": "video", "name": source.name})
|
||||
if not isinstance(response, Mapping):
|
||||
raise ApiProtocolError("COS 上传凭证响应无效")
|
||||
if response.get("provider") != "qcloud" or response.get("fallback") is not False:
|
||||
raise ApiProtocolError("服务器未启用 COS 直传,无法上传视频回放")
|
||||
bucket = _string(response, "bucket")
|
||||
region = _string(response, "region")
|
||||
if len(bucket) > 63 or not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,49}-[0-9]{5,20}", bucket):
|
||||
raise ApiProtocolError("COS 存储桶无效")
|
||||
if not re.fullmatch(r"[a-z]{2}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*", region):
|
||||
raise ApiProtocolError("COS 存储区域无效")
|
||||
if size > _positive_int(response.get("max_size"), "max_size"):
|
||||
raise ValueError("视频大小超过服务器允许的 COS 上传上限")
|
||||
start = _positive_int(response.get("start_time"), "start_time")
|
||||
expires = _positive_int(response.get("expired_time"), "expired_time")
|
||||
now = time.time()
|
||||
if start >= expires or expires <= now or start > now + 60:
|
||||
raise ApiProtocolError("COS 上传凭证已过期或尚未生效,请重试")
|
||||
temporary = response.get("credentials")
|
||||
if not isinstance(temporary, Mapping):
|
||||
raise ApiProtocolError("COS 响应缺少临时凭证")
|
||||
secret_id = _string(temporary, "tmpSecretId")
|
||||
secret_key = _string(temporary, "tmpSecretKey")
|
||||
token = _string(temporary, "sessionToken")
|
||||
key = _upload_key(response, extension)
|
||||
|
||||
# SDK debug logs include signing keys and request headers; even error logs
|
||||
# may echo provider payloads. Keep these out of the application's debug log.
|
||||
# The SDK installs a NullHandler on this logger; report only our safe error.
|
||||
logging.getLogger("qcloud_cos").propagate = False
|
||||
try:
|
||||
config = CosConfig(
|
||||
Region=region,
|
||||
SecretId=secret_id,
|
||||
SecretKey=secret_key,
|
||||
Token=token,
|
||||
Scheme="https",
|
||||
Timeout=120,
|
||||
VerifySSL=True,
|
||||
)
|
||||
# Never use returned host/CDN values as an SDK endpoint, and never send
|
||||
# the application's admin token to COS. The SDK derives the COS host.
|
||||
CosS3Client(config).upload_file(
|
||||
Bucket=bucket,
|
||||
Key=key,
|
||||
LocalFilePath=str(source),
|
||||
PartSize=1,
|
||||
MAXThread=3,
|
||||
ContentType=mime_type,
|
||||
progress_callback=report,
|
||||
)
|
||||
except Exception:
|
||||
raise ApiTransportError(
|
||||
"COS 视频上传失败,请检查网络后重试。",
|
||||
data={"provider": "cos", "operation": "upload"},
|
||||
) from None
|
||||
|
||||
# Small-file put_object does not call the SDK's progress callback.
|
||||
report(size - 1)
|
||||
if on_processing is not None:
|
||||
with suppress(Exception):
|
||||
on_processing()
|
||||
result = client.post(
|
||||
"upload/ossConfirm",
|
||||
{
|
||||
"type": "video",
|
||||
"key": key,
|
||||
"name": source.name,
|
||||
"size": size,
|
||||
"content_type": mime_type,
|
||||
"cid": 0,
|
||||
},
|
||||
timeout=120.0,
|
||||
)
|
||||
return _confirmed_result(result, key)
|
||||
@@ -612,6 +612,7 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
self._checked_ids: set[int] = set()
|
||||
self._hover_row = -1
|
||||
self._sort_direction = ""
|
||||
self._assistant_names: dict[int, str] = {}
|
||||
|
||||
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
|
||||
return 0 if parent.isValid() else len(self.rows)
|
||||
@@ -645,6 +646,9 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
if self.record_id(row) in self._checked_ids
|
||||
else Qt.CheckState.Unchecked
|
||||
)
|
||||
if column == 7 and role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole):
|
||||
name = self.assistant_name(row)
|
||||
return name or ("—" if role == Qt.ItemDataRole.DisplayRole else "")
|
||||
if role == Qt.ItemDataRole.ToolTipRole:
|
||||
return self._tooltip(row, column)
|
||||
if role == Qt.ItemDataRole.TextAlignmentRole:
|
||||
@@ -711,6 +715,30 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
def checked_records(self) -> list[Any]:
|
||||
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
|
||||
|
||||
def assistant_name(self, row: Any) -> str:
|
||||
name = str(first_value(row, "assistant_name", default="")).strip()
|
||||
if name:
|
||||
return name
|
||||
assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0))
|
||||
return self._assistant_names.get(assistant_id, "") if assistant_id > 0 else ""
|
||||
|
||||
def set_assistant_names(self, names: Mapping[int, str]) -> bool:
|
||||
"""Refresh names from the authorized picker without changing diagnosis records."""
|
||||
normalized = {
|
||||
_as_int(key): str(value).strip()
|
||||
for key, value in names.items()
|
||||
if _as_int(key) > 0 and value and str(value).strip()
|
||||
}
|
||||
if normalized == self._assistant_names:
|
||||
return False
|
||||
self._assistant_names = normalized
|
||||
if self.rows:
|
||||
self.dataChanged.emit(
|
||||
self.index(0, 7), self.index(len(self.rows) - 1, 7),
|
||||
[Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole],
|
||||
)
|
||||
return True
|
||||
|
||||
def clear_checks(self) -> None:
|
||||
if not self._checked_ids:
|
||||
return
|
||||
@@ -1269,11 +1297,15 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
|
||||
"""Approved consultation presentation, isolated from the patients table."""
|
||||
|
||||
@staticmethod
|
||||
def row_height(record: Any, widths: Sequence[int]) -> int:
|
||||
def row_height(
|
||||
record: Any, widths: Sequence[int], *, assistant_name: str | None = None,
|
||||
) -> int:
|
||||
_entries, appointment_height = _blue_appointment_layout(record, widths[4])
|
||||
heights = [72, 16 + appointment_height]
|
||||
for column in (1, 2, 3, 6, 7):
|
||||
text = DiagnosisTableModel._display(record, column)
|
||||
if column == 7 and assistant_name is not None:
|
||||
text = assistant_name or "—"
|
||||
heights.append(18 + _blue_text_height(
|
||||
text, widths[column] - 16, 13 if column == 6 else 14, medium=column == 2,
|
||||
))
|
||||
@@ -1287,7 +1319,10 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
|
||||
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
|
||||
model = index.model()
|
||||
record = model.record(index.row()) if isinstance(model, DiagnosisTableModel) else None
|
||||
return QSize(option.rect.width(), self.row_height(record, _BLUE_COLUMN_WIDTHS))
|
||||
name = model.assistant_name(record) if isinstance(model, DiagnosisTableModel) else None
|
||||
return QSize(option.rect.width(), self.row_height(
|
||||
record, _BLUE_COLUMN_WIDTHS, assistant_name=name,
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _text(
|
||||
@@ -1850,6 +1885,11 @@ class DiagnosisTableHost(QFrame):
|
||||
def selected_records(self) -> list[Any]:
|
||||
return self.model.checked_records()
|
||||
|
||||
def set_assistant_names(self, names: Mapping[int, str]) -> None:
|
||||
if self.model.set_assistant_names(names):
|
||||
self._sync_row_heights()
|
||||
self._schedule_fixed_height_sync()
|
||||
|
||||
def clear_checks(self) -> None:
|
||||
self.model.clear_checks()
|
||||
|
||||
@@ -1898,7 +1938,8 @@ class DiagnosisTableHost(QFrame):
|
||||
)
|
||||
height = (
|
||||
_TechBlueDiagnosisDelegate.row_height(
|
||||
record, [self.main.columnWidth(column) for column in range(10)]
|
||||
record, [self.main.columnWidth(column) for column in range(10)],
|
||||
assistant_name=self.model.assistant_name(record),
|
||||
) if self.tech_blue else max(52, 12 + count * 42 + (18 if channel else 0))
|
||||
)
|
||||
self.main.setRowHeight(row, height)
|
||||
|
||||
@@ -1644,13 +1644,6 @@ class ConsultationsPage(QWidget):
|
||||
return combo.currentText().strip()
|
||||
return "" if data is None else data
|
||||
|
||||
def _assistant_cell(self, _value: Any, row: Any) -> str:
|
||||
name = first_value(row, "assistant_name", default="")
|
||||
if name:
|
||||
return display_text(name)
|
||||
assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0))
|
||||
return self._assistant_names.get(assistant_id, "—")
|
||||
|
||||
def _custom_date_changed(self, value: QDate) -> None:
|
||||
if value == _OPTIONAL_DATE_MINIMUM:
|
||||
return
|
||||
@@ -2327,11 +2320,12 @@ class ConsultationsPage(QWidget):
|
||||
combo.setEditText(str(current))
|
||||
self._assistant_names = {
|
||||
_as_int(first_value(row, "id", "value")): display_text(
|
||||
first_value(row, "name", "label")
|
||||
first_value(row, "name", "label"), ""
|
||||
)
|
||||
for row in options.get("assistants", [])
|
||||
if _as_int(first_value(row, "id", "value")) > 0
|
||||
}
|
||||
self.table_host.set_assistant_names(self._assistant_names)
|
||||
|
||||
def _checked_changed(self, count: int) -> None:
|
||||
self.selected_label.setText(f"已选 {count} 条")
|
||||
@@ -2425,6 +2419,7 @@ class ConsultationsPage(QWidget):
|
||||
}
|
||||
options = {key: value for key, value in options.items() if value}
|
||||
self._assistant_names.update(options)
|
||||
self.table_host.set_assistant_names(self._assistant_names)
|
||||
self._show_assistant_picker(records, options)
|
||||
|
||||
def _show_assistant_picker(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Medical-assistant names must survive independent list/options loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.core.errors import ApiBusinessError
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.pages import consultations as module
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self) -> None:
|
||||
self.assistants: Any = [{"id": 66, "name": "周医助"}]
|
||||
self.rows = [
|
||||
{"id": 1, "assistant_id": "66", "patient_name": "测试患者"},
|
||||
{"id": 2, "assistant_id": 66, "assistant_name": "诊单医助"},
|
||||
{"id": 3, "assistant_id": 99},
|
||||
{"id": 4, "assistant_id": 0},
|
||||
{"id": 5, "assistant_id": None},
|
||||
]
|
||||
|
||||
def get(self, endpoint: str, params: Any = None) -> Any:
|
||||
if endpoint == "tcm.diagnosis/lists":
|
||||
return {"lists": deepcopy(self.rows), "count": len(self.rows)}
|
||||
if endpoint == "tcm.diagnosis/getAssistants":
|
||||
if isinstance(self.assistants, Exception):
|
||||
raise self.assistants
|
||||
return deepcopy(self.assistants)
|
||||
return []
|
||||
|
||||
|
||||
class DetailStub(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
|
||||
jobs = []
|
||||
|
||||
def defer(function: Any, **callbacks: Any) -> None:
|
||||
jobs.append(lambda: callbacks["on_success"](function()))
|
||||
|
||||
monkeypatch.setattr(module, "run_async", defer)
|
||||
client = Client()
|
||||
result = module.ConsultationsPage(RemoteDoctorRepository(client))
|
||||
yield result, client, jobs
|
||||
result.poll_timer.stop()
|
||||
result.close()
|
||||
result.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("options_first", [False, True])
|
||||
def test_names_render_with_either_response_order_without_changing_source(page, options_first):
|
||||
view, client, jobs = page
|
||||
result = view.repository.list_consultations()
|
||||
before = deepcopy(result)
|
||||
view._load_filter_options()
|
||||
if options_first:
|
||||
jobs.pop()()
|
||||
view._apply_result(result, view._generation)
|
||||
model = view.table_host.model
|
||||
model.setData(model.index(0, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||
view.table.selectRow(0)
|
||||
if not options_first:
|
||||
assert model.index(0, 7).data() == "—"
|
||||
changes = []
|
||||
model.dataChanged.connect(lambda first, last, roles: changes.append((first, last, roles)))
|
||||
jobs.pop()()
|
||||
assert any(first.column() == last.column() == 7 for first, last, _ in changes)
|
||||
assert [model.index(row, 7).data() for row in range(5)] == [
|
||||
"周医助", "诊单医助", "—", "—", "—",
|
||||
]
|
||||
assert model.index(0, 7).data(Qt.ItemDataRole.ToolTipRole) == "周医助"
|
||||
assert [row.id for row in model.checked_records()] == [1]
|
||||
assert view.table.current_data().id == 1
|
||||
assert result == before
|
||||
assert "assistant_name" not in client.rows[0]
|
||||
# A later page uses the same lookup; reassignment uses the new ID, not a cached row name.
|
||||
client.rows = [{"id": 1, "assistant_id": 99}, {"id": 6, "assistant_id": 66}]
|
||||
view._apply_result(view.repository.list_consultations(), view._generation)
|
||||
assert [model.index(row, 7).data() for row in range(2)] == ["—", "周医助"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("options", [[], ApiBusinessError("权限不足,无法访问或操作")])
|
||||
def test_server_name_still_displays_when_picker_empty_or_denied(page, options):
|
||||
view, client, jobs = page
|
||||
client.assistants = options
|
||||
view._apply_result(view.repository.list_consultations(), view._generation)
|
||||
view._load_filter_options()
|
||||
jobs.pop()()
|
||||
assert view.table_host.model.index(1, 7).data() == "诊单医助"
|
||||
assert view.table_host.model.index(0, 7).data() == "—"
|
||||
|
||||
|
||||
def test_updated_and_stale_options_do_not_leave_old_assistant_names(page):
|
||||
view, _client, jobs = page
|
||||
view._apply_result(view.repository.list_consultations(), view._generation)
|
||||
view._load_filter_options()
|
||||
jobs.pop()()
|
||||
generation = view._options_generation
|
||||
view._apply_filter_options({"assistants": [{"id": 66, "name": "新医助"}]}, generation)
|
||||
assert view.table_host.model.index(0, 7).data() == "新医助"
|
||||
view._apply_filter_options({"assistants": []}, generation - 1)
|
||||
assert view.table_host.model.index(0, 7).data() == "新医助"
|
||||
view._apply_filter_options({"assistants": []}, generation)
|
||||
assert view.table_host.model.index(0, 7).data() == "—"
|
||||
assert view.table_host.model.index(1, 7).data() == "诊单医助"
|
||||
|
||||
|
||||
def test_long_assistant_name_resizes_both_table_sections(page):
|
||||
view, _client, _jobs = page
|
||||
host = view.table_host
|
||||
view._apply_result(view.repository.list_consultations(), view._generation)
|
||||
original_height = host.main.rowHeight(0)
|
||||
name = "完整医助姓名用于显示换行" * 5
|
||||
host.set_assistant_names({66: name, 0: "不能用于未分配患者", -1: "无效医助"})
|
||||
assert host.model.index(0, 7).data() == name
|
||||
assert host.main.rowHeight(0) > original_height
|
||||
assert host.main.rowHeight(0) == host.fixed.rowHeight(0)
|
||||
assert host.model.index(3, 7).data() == "—"
|
||||
@@ -0,0 +1,352 @@
|
||||
"""COS upload contract tests: real SDK configuration, no external requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qcloud_cos import CosClientError, CosConfig, CosS3Client
|
||||
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError, ApiTransportError
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.cos_upload import upload_video_to_cos
|
||||
|
||||
BUCKET = "synthetic-video-1234567890"
|
||||
REGION = "ap-guangzhou"
|
||||
PREFIX = "uploads/video/20261105/"
|
||||
_AUTOMATIC = object()
|
||||
|
||||
|
||||
def _credentials() -> dict[str, Any]:
|
||||
now = int(time.time())
|
||||
return {
|
||||
"provider": "qcloud",
|
||||
"fallback": False,
|
||||
"bucket": BUCKET,
|
||||
"region": REGION,
|
||||
"key_prefix": PREFIX,
|
||||
"object_key": "",
|
||||
"host": "https://untrusted-host.example.test",
|
||||
"cdn_domain": "https://cdn.example.test",
|
||||
"max_size": 2 * 1024 * 1024 * 1024,
|
||||
"start_time": now - 10,
|
||||
"expired_time": now + 1800,
|
||||
"credentials": {
|
||||
"tmpSecretId": "synthetic-temporary-id",
|
||||
"tmpSecretKey": "synthetic-temporary-secret",
|
||||
"sessionToken": "synthetic-temporary-token",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Harness:
|
||||
source: Path
|
||||
credentials: Any = field(default_factory=_credentials)
|
||||
confirmation: Any = _AUTOMATIC
|
||||
sdk_error: Exception | None = None
|
||||
sdk_reports_progress: bool = True
|
||||
api_failure: str = ""
|
||||
log_sensitive_sdk_data: bool = False
|
||||
api_requests: list[httpx.Request] = field(default_factory=list)
|
||||
sdk_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
configs: list[CosConfig] = field(default_factory=list)
|
||||
progress: list[tuple[int, int]] = field(default_factory=list)
|
||||
events: list[str] = field(default_factory=list)
|
||||
|
||||
def handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.api_requests.append(request)
|
||||
assert request.method == "POST"
|
||||
assert request.headers["token"] == "synthetic-admin-token"
|
||||
endpoint = request.url.path.rsplit("/", 1)[-1]
|
||||
assert endpoint in {"ossCredentials", "ossConfirm"}
|
||||
self.events.append(endpoint)
|
||||
if self.api_failure == endpoint:
|
||||
return httpx.Response(200, json={"code": 0, "msg": "synthetic API rejection"})
|
||||
if endpoint == "ossCredentials":
|
||||
return httpx.Response(200, json={"code": 1, "data": self.credentials})
|
||||
assert request.extensions["timeout"]["read"] == 120.0
|
||||
body = json.loads(request.content)
|
||||
result = self.confirmation
|
||||
if result is _AUTOMATIC:
|
||||
address = "https://cdn.example.test/" + body["key"]
|
||||
result = {"id": 19, "uri": address, "url": address, "type": 20, "cid": 0}
|
||||
return httpx.Response(200, json={"code": 1, "data": result})
|
||||
|
||||
def client(self) -> ApiClient:
|
||||
return ApiClient(
|
||||
"https://api.example.test",
|
||||
token="synthetic-admin-token",
|
||||
max_retries=5,
|
||||
transport=httpx.MockTransport(self.handle),
|
||||
)
|
||||
|
||||
def processing(self) -> None:
|
||||
self.events.append("processing")
|
||||
|
||||
def report(self, sent: int, total: int) -> None:
|
||||
self.progress.append((sent, total))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Harness]:
|
||||
source = tmp_path / "synthetic-replay.mp4"
|
||||
source.write_bytes(b"synthetic-video-bytes")
|
||||
state = _Harness(source)
|
||||
|
||||
def sdk_upload(sdk: CosS3Client, **kwargs: Any) -> dict[str, str]:
|
||||
state.configs.append(sdk.get_conf())
|
||||
state.sdk_calls.append(kwargs)
|
||||
state.events.append("sdk-start")
|
||||
if state.log_sensitive_sdk_data:
|
||||
logging.getLogger("qcloud_cos.cos_auth").debug("synthetic-temporary-secret")
|
||||
logging.getLogger("qcloud_cos.cos_client").error("synthetic-temporary-token")
|
||||
if state.sdk_error is not None:
|
||||
raise state.sdk_error
|
||||
if state.sdk_reports_progress:
|
||||
size = source.stat().st_size
|
||||
callback = kwargs["progress_callback"]
|
||||
callback(size // 2, size)
|
||||
callback(size, size)
|
||||
callback(size * 2, size) # Defensive clamping must keep binding pending.
|
||||
state.events.append("sdk-done")
|
||||
return {"ETag": '"synthetic-etag"'}
|
||||
|
||||
def no_external_request(*_args: Any, **_kwargs: Any) -> None:
|
||||
pytest.fail("SDK tests must never make an external request")
|
||||
|
||||
monkeypatch.setattr(CosS3Client, "upload_file", sdk_upload)
|
||||
monkeypatch.setattr("requests.Session.request", no_external_request)
|
||||
# Restore the logger after the test while exercising the helper's choice.
|
||||
monkeypatch.setattr(logging.getLogger("qcloud_cos"), "propagate", True)
|
||||
yield state
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [1, 1024, 5 * 1024 * 1024])
|
||||
def test_official_sdk_config_and_confirm_order_keep_completion_pending(
|
||||
harness: _Harness, size: int
|
||||
) -> None:
|
||||
harness.source.write_bytes(b"v" * size)
|
||||
harness.sdk_reports_progress = size > 1024 * 1024
|
||||
with harness.client() as client:
|
||||
result = upload_video_to_cos(
|
||||
client,
|
||||
harness.source,
|
||||
mime_type="video/mp4",
|
||||
on_progress=harness.report,
|
||||
on_processing=harness.processing,
|
||||
)
|
||||
|
||||
assert harness.events == ["ossCredentials", "sdk-start", "sdk-done", "processing", "ossConfirm"]
|
||||
assert json.loads(harness.api_requests[0].content) == {
|
||||
"type": "video", "name": harness.source.name
|
||||
}
|
||||
assert len(harness.sdk_calls) == 1
|
||||
config = harness.configs[0]
|
||||
call = harness.sdk_calls[0]
|
||||
assert config._secret_id == "synthetic-temporary-id"
|
||||
assert config._secret_key == "synthetic-temporary-secret"
|
||||
assert config._token == "synthetic-temporary-token"
|
||||
assert config._region == REGION
|
||||
assert config._scheme == "https"
|
||||
assert config._timeout == 120
|
||||
assert config._verify_ssl is True
|
||||
assert config._allow_redirects is False
|
||||
assert config._domain is None
|
||||
assert config._ip is None
|
||||
assert config._port is None
|
||||
assert config.uri(bucket=BUCKET, path=call["Key"]).startswith(
|
||||
f"https://{BUCKET}.cos.{REGION}.myqcloud.com/"
|
||||
)
|
||||
assert call["Bucket"] == BUCKET
|
||||
assert call["LocalFilePath"] == str(harness.source.resolve())
|
||||
assert call["PartSize"] == 1
|
||||
assert call["MAXThread"] == 3
|
||||
assert call["ContentType"] == "video/mp4"
|
||||
assert set(call) == {
|
||||
"Bucket", "Key", "LocalFilePath", "PartSize", "MAXThread", "ContentType", "progress_callback"
|
||||
}
|
||||
assert re.fullmatch(re.escape(PREFIX) + r"[0-9]+-[0-9a-f]{32}\.mp4", call["Key"])
|
||||
assert json.loads(harness.api_requests[1].content) == {
|
||||
"type": "video", "key": call["Key"], "name": harness.source.name,
|
||||
"size": size, "content_type": "video/mp4", "cid": 0,
|
||||
}
|
||||
assert result["id"] == 19
|
||||
assert result["uri"].endswith("/" + call["Key"])
|
||||
assert harness.progress[0] == (0, size)
|
||||
assert harness.progress[-1] == (size - 1, size)
|
||||
assert all(0 <= sent < total == size for sent, total in harness.progress)
|
||||
|
||||
|
||||
def test_fixed_object_key_is_used_without_expanding_its_scope(harness: _Harness) -> None:
|
||||
harness.credentials["object_key"] = PREFIX + "session/fixed.mp4"
|
||||
with harness.client() as client:
|
||||
upload_video_to_cos(client, harness.source, mime_type="video/mp4")
|
||||
assert harness.sdk_calls[0]["Key"] == PREFIX + "session/fixed.mp4"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "invalid"),
|
||||
[
|
||||
("provider", "local"),
|
||||
("fallback", True),
|
||||
("fallback", "false"),
|
||||
("bucket", "bucket.example.test/escape-1234567890"),
|
||||
("bucket", "bucket@attacker-1234567890"),
|
||||
("bucket", ""),
|
||||
("region", "ap-guangzhou.attacker.test"),
|
||||
("region", "https://attacker.test"),
|
||||
("key_prefix", "uploads/voice/20261105/"),
|
||||
("key_prefix", "uploads/video/20261105/../"),
|
||||
("key_prefix", "uploads/video/20261399/"),
|
||||
("key_prefix", "uploads/video/20261105"),
|
||||
("object_key", "uploads/video/20261106/other.mp4"),
|
||||
("object_key", PREFIX + "../other.mp4"),
|
||||
("object_key", PREFIX + "%2e%2e/other.mp4"),
|
||||
("object_key", PREFIX + "nested\\other.mp4"),
|
||||
("object_key", PREFIX + "other.exe"),
|
||||
("object_key", []),
|
||||
("max_size", 0),
|
||||
("max_size", True),
|
||||
("start_time", 9999999999),
|
||||
("expired_time", 1),
|
||||
("expired_time", False),
|
||||
("credentials", None),
|
||||
("credentials", {"tmpSecretId": "id", "tmpSecretKey": "secret"}),
|
||||
("credentials", {"tmpSecretId": "id", "tmpSecretKey": "", "sessionToken": "token"}),
|
||||
("credentials", {"tmpSecretId": "id", "tmpSecretKey": "secret", "sessionToken": "bad\r\n"}),
|
||||
],
|
||||
)
|
||||
def test_invalid_credentials_fail_before_any_cos_upload(
|
||||
harness: _Harness, field_name: str, invalid: Any
|
||||
) -> None:
|
||||
harness.credentials[field_name] = invalid
|
||||
with harness.client() as client, pytest.raises(ApiProtocolError):
|
||||
upload_video_to_cos(client, harness.source, mime_type="video/mp4")
|
||||
assert harness.sdk_calls == []
|
||||
assert harness.events == ["ossCredentials"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response", [None, [], "invalid"])
|
||||
def test_non_mapping_credentials_are_rejected(harness: _Harness, response: Any) -> None:
|
||||
harness.credentials = response
|
||||
with harness.client() as client, pytest.raises(ApiProtocolError):
|
||||
upload_video_to_cos(client, harness.source, mime_type="video/mp4")
|
||||
assert harness.sdk_calls == []
|
||||
|
||||
|
||||
def test_server_size_limit_is_enforced_before_cos_upload(harness: _Harness) -> None:
|
||||
harness.credentials["max_size"] = harness.source.stat().st_size - 1
|
||||
with harness.client() as client, pytest.raises(ValueError, match="上限"):
|
||||
upload_video_to_cos(client, harness.source, mime_type="video/mp4")
|
||||
assert harness.sdk_calls == []
|
||||
assert harness.events == ["ossCredentials"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("condition", ["missing", "empty", "oversized", "wrong-extension", "bad-mime"])
|
||||
def test_invalid_local_video_fails_before_request(harness: _Harness, condition: str) -> None:
|
||||
mime = "video/mp4"
|
||||
if condition == "missing":
|
||||
harness.source.unlink()
|
||||
elif condition == "empty":
|
||||
harness.source.write_bytes(b"")
|
||||
elif condition == "oversized":
|
||||
with harness.source.open("wb") as stream:
|
||||
stream.truncate(2 * 1024 * 1024 * 1024 + 1)
|
||||
elif condition == "wrong-extension":
|
||||
renamed = harness.source.with_suffix(".exe")
|
||||
harness.source.rename(renamed)
|
||||
harness.source = renamed
|
||||
else:
|
||||
mime = "video/mp4\r\nx-injected: value"
|
||||
with harness.client() as client, pytest.raises(ValueError):
|
||||
upload_video_to_cos(client, harness.source, mime_type=mime)
|
||||
assert harness.api_requests == []
|
||||
assert harness.sdk_calls == []
|
||||
|
||||
|
||||
def test_cos_error_is_sanitized_and_never_confirmed(
|
||||
harness: _Harness, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
harness.sdk_error = CosClientError("synthetic-temporary-secret synthetic-temporary-token")
|
||||
harness.log_sensitive_sdk_data = True
|
||||
caplog.set_level(logging.DEBUG)
|
||||
with harness.client() as client, pytest.raises(ApiTransportError) as caught:
|
||||
upload_video_to_cos(
|
||||
client, harness.source, mime_type="video/mp4",
|
||||
on_progress=harness.report, on_processing=harness.processing,
|
||||
)
|
||||
rendered = "".join(traceback.format_exception(caught.value))
|
||||
assert "synthetic-temporary-secret" not in rendered + caplog.text
|
||||
assert "synthetic-temporary-token" not in rendered + caplog.text
|
||||
assert caught.value.data == {"provider": "cos", "operation": "upload"}
|
||||
assert caught.value.__suppress_context__ is True
|
||||
assert harness.events == ["ossCredentials", "sdk-start"]
|
||||
assert harness.progress == [(0, harness.source.stat().st_size)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["ossCredentials", "ossConfirm"])
|
||||
def test_api_business_errors_keep_their_type_and_are_not_retried(
|
||||
harness: _Harness, endpoint: str
|
||||
) -> None:
|
||||
harness.api_failure = endpoint
|
||||
with harness.client() as client, pytest.raises(ApiBusinessError, match="synthetic API rejection"):
|
||||
upload_video_to_cos(client, harness.source, mime_type="video/mp4", on_progress=harness.report)
|
||||
assert sum(request.url.path.endswith(endpoint) for request in harness.api_requests) == 1
|
||||
assert len(harness.sdk_calls) == (0 if endpoint == "ossCredentials" else 1)
|
||||
assert all(sent < total for sent, total in harness.progress)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid",
|
||||
[
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
{"id": 0},
|
||||
{"id": True},
|
||||
{"id": 19},
|
||||
{"id": 19, "uri": "/uploads/video/local.mp4"},
|
||||
{"id": 19, "uri": "file:///tmp/local.mp4"},
|
||||
{"id": 19, "uri": "https://cdn.example.test/different.mp4"},
|
||||
{"id": 19, "uri": "https://cdn.example.test:bad/" + PREFIX + "fixed.mp4"},
|
||||
{"id": 19, "uri": "https://user:pass@cdn.example.test/" + PREFIX + "fixed.mp4"},
|
||||
],
|
||||
)
|
||||
def test_invalid_confirmation_never_reports_completion(harness: _Harness, invalid: Any) -> None:
|
||||
harness.credentials["object_key"] = PREFIX + "fixed.mp4"
|
||||
harness.confirmation = invalid
|
||||
with harness.client() as client, pytest.raises(ApiProtocolError):
|
||||
upload_video_to_cos(client, harness.source, mime_type="video/mp4", on_progress=harness.report)
|
||||
assert len(harness.sdk_calls) == 1
|
||||
assert harness.events[-1] == "ossConfirm"
|
||||
assert all(sent < total for sent, total in harness.progress)
|
||||
|
||||
|
||||
def test_disposed_ui_callbacks_do_not_interrupt_upload_or_confirmation(harness: _Harness) -> None:
|
||||
processing = []
|
||||
|
||||
def broken_progress(_sent: int, _total: int) -> None:
|
||||
raise RuntimeError("synthetic disposed progress receiver")
|
||||
|
||||
def broken_processing() -> None:
|
||||
processing.append(True)
|
||||
raise RuntimeError("synthetic disposed processing receiver")
|
||||
|
||||
with harness.client() as client:
|
||||
result = upload_video_to_cos(
|
||||
client, harness.source, mime_type="video/mp4",
|
||||
on_progress=broken_progress, on_processing=broken_processing,
|
||||
)
|
||||
assert result["id"] == 19
|
||||
assert processing == [True]
|
||||
assert harness.events == ["ossCredentials", "sdk-start", "sdk-done", "ossConfirm"]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Exercise the real COS SDK and repository with synthetic HTTP responses."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [1024, 5 * 1024 * 1024])
|
||||
@pytest.mark.parametrize("record_id", [None, 901])
|
||||
def test_real_sdk_sends_video_only_to_cos_before_confirming_and_binding(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, size: int, record_id: int | None,
|
||||
) -> None:
|
||||
source = tmp_path / "synthetic-replay.mp4"
|
||||
source.write_bytes(b"v" * size)
|
||||
api_calls: list[str] = []
|
||||
cos_requests: list[requests.PreparedRequest] = []
|
||||
uploaded_parts: dict[int, bytes] = {}
|
||||
progress: list[int] = []
|
||||
stages: list[str] = []
|
||||
confirmed_urls: list[str] = []
|
||||
|
||||
def cos_send(_session, request, **kwargs):
|
||||
parsed = urlsplit(request.url)
|
||||
assert parsed.scheme == "https"
|
||||
assert parsed.hostname == "test-bucket-1250000000.cos.ap-guangzhou.myqcloud.com"
|
||||
assert request.headers["x-cos-security-token"] == b"synthetic-sts-token"
|
||||
assert "q-sign-algorithm=sha1" in request.headers["Authorization"]
|
||||
assert "token" not in request.headers
|
||||
cos_requests.append(request)
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
if request.method == "GET":
|
||||
body = b"<ListMultipartUploadsResult><IsTruncated>false</IsTruncated></ListMultipartUploadsResult>"
|
||||
elif request.method == "POST" and "uploads" in query:
|
||||
body = b"<InitiateMultipartUploadResult><UploadId>synthetic-upload</UploadId></InitiateMultipartUploadResult>"
|
||||
elif request.method == "PUT":
|
||||
part = int(query.get("partNumber", ["1"])[0])
|
||||
uploaded_parts[part] = request.body.read() if hasattr(request.body, "read") else request.body
|
||||
body = b""
|
||||
else:
|
||||
assert request.method == "POST" and query.get("uploadId") == ["synthetic-upload"]
|
||||
body = b'<CompleteMultipartUploadResult><ETag>"synthetic-etag"</ETag></CompleteMultipartUploadResult>'
|
||||
response = requests.Response()
|
||||
response.status_code = 200
|
||||
response.url = request.url
|
||||
response.request = request
|
||||
response._content = body
|
||||
response.headers.update({"ETag": '"synthetic-etag"', "Content-Type": "application/xml"})
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(requests.Session, "send", cos_send)
|
||||
|
||||
def api_handler(request: httpx.Request) -> httpx.Response:
|
||||
endpoint = request.url.path.removeprefix("/adminapi/")
|
||||
api_calls.append(endpoint)
|
||||
assert request.method == "POST"
|
||||
assert request.headers["token"] == "synthetic-admin-token"
|
||||
payload = json.loads(request.content)
|
||||
if endpoint == "upload/ossCredentials":
|
||||
assert payload == {"type": "video", "name": source.name}
|
||||
data = {
|
||||
"provider": "qcloud", "fallback": False,
|
||||
"bucket": "test-bucket-1250000000", "region": "ap-guangzhou",
|
||||
"host": "https://test-bucket-1250000000.cos.ap-guangzhou.myqcloud.com",
|
||||
"key_prefix": "uploads/video/20261105/", "max_size": 2 * 1024**3,
|
||||
"expired_time": int(time.time()) + 1800, "start_time": int(time.time()) - 10,
|
||||
"credentials": {"tmpSecretId": "synthetic-id", "tmpSecretKey": "synthetic-key", "sessionToken": "synthetic-sts-token"},
|
||||
}
|
||||
elif endpoint == "upload/ossConfirm":
|
||||
assert b"".join(uploaded_parts[i] for i in sorted(uploaded_parts)) == source.read_bytes()
|
||||
assert stages == ["processing"]
|
||||
assert size not in progress
|
||||
assert payload == {
|
||||
"type": "video", "key": payload["key"], "name": source.name,
|
||||
"size": size, "content_type": "video/mp4", "cid": 0,
|
||||
}
|
||||
assert payload["key"].startswith("uploads/video/20261105/")
|
||||
assert payload["key"].endswith(".mp4")
|
||||
confirmed_urls.append("https://cdn.example.test/" + payload["key"])
|
||||
data = {"id": 101, "uri": confirmed_urls[-1]}
|
||||
elif endpoint == "tcm.diagnosis/createManualCallRecord":
|
||||
assert payload == {"diagnosis_id": 501}
|
||||
assert size not in progress
|
||||
data = {"id": 901}
|
||||
else:
|
||||
assert endpoint == "tcm.diagnosis/attachLocalCallRecording"
|
||||
assert payload == {"diagnosis_id": 501, "call_record_id": 901, "file_url": confirmed_urls[-1]}
|
||||
assert size not in progress
|
||||
data = []
|
||||
return httpx.Response(200, json={"code": 1, "data": data})
|
||||
|
||||
with ApiClient("https://api.example.test", token="synthetic-admin-token", transport=httpx.MockTransport(api_handler)) as client:
|
||||
result = RemoteDoctorRepository(client).upload_call_recording(
|
||||
source, 501, call_record_id=record_id, mime_type="video/mp4",
|
||||
on_progress=lambda sent, total: progress.append(sent),
|
||||
on_processing=lambda: stages.append("processing"),
|
||||
)
|
||||
assert result["completed"] is True and result["call_record_id"] == 901
|
||||
assert result["media_kind"] == "video"
|
||||
assert progress[0] == 0 and progress[-1] == size
|
||||
assert cos_requests
|
||||
assert api_calls == [
|
||||
"upload/ossCredentials", "upload/ossConfirm",
|
||||
*(["tcm.diagnosis/createManualCallRecord"] if record_id is None else []),
|
||||
"tcm.diagnosis/attachLocalCallRecording",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["missing-record", "attach-failed"])
|
||||
def test_video_is_not_complete_until_exact_record_is_bound(tmp_path: Path, monkeypatch, failure):
|
||||
from doctor_workstation.services import repository as repository_module
|
||||
|
||||
source = tmp_path / "synthetic.mp4"
|
||||
source.write_bytes(b"video")
|
||||
monkeypatch.setattr(repository_module, "upload_video_to_cos", lambda *args, **kwargs: {
|
||||
"id": 101, "uri": "https://cdn.example.test/replay.mp4",
|
||||
})
|
||||
calls = []
|
||||
progress = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(request.url.path)
|
||||
if request.url.path.endswith("createManualCallRecord"):
|
||||
return httpx.Response(200, json={"code": 1, "data": {"id": 0 if failure == "missing-record" else 901}})
|
||||
return httpx.Response(200, json={"code": 0, "msg": "绑定失败"})
|
||||
|
||||
with (
|
||||
ApiClient("https://api.example.test", transport=httpx.MockTransport(handler)) as client,
|
||||
pytest.raises(ApiProtocolError if failure == "missing-record" else ApiBusinessError),
|
||||
):
|
||||
RemoteDoctorRepository(client).upload_call_recording(
|
||||
source, 501, on_progress=lambda sent, total: progress.append(sent),
|
||||
)
|
||||
assert source.stat().st_size not in progress
|
||||
assert len(calls) == (1 if failure == "missing-record" else 2)
|
||||
@@ -184,6 +184,21 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 仅补充已通过权限过滤且落在本页的医助姓名,不依赖调用者拥有后台人员列表权限。
|
||||
$assistantIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
array_column($lists, 'assistant_id')
|
||||
), static function (int $id): bool {
|
||||
return $id > 0;
|
||||
})));
|
||||
$assistantNames = $assistantIds === []
|
||||
? []
|
||||
: Admin::whereIn('id', $assistantIds)->whereNull('delete_time')->column('name', 'id');
|
||||
foreach ($lists as &$item) {
|
||||
$item['assistant_name'] = trim((string) ($assistantNames[(int) ($item['assistant_id'] ?? 0)] ?? ''));
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// 关联挂号:doctor_appointment.patient_id 存诊单主键 id(与 Appointment 列表 join 一致)
|
||||
$diagnosisIds = array_filter(array_unique(array_column($lists, 'id')));
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Exercise the real lists/count methods with synthetic in-memory rows only.
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
final class AssistantNameQuery
|
||||
{
|
||||
public static array $tables = [];
|
||||
public static array $adminReads = [];
|
||||
private array $conditions = [];
|
||||
private ?array $fields = null;
|
||||
private int $offset = 0;
|
||||
private ?int $length = null;
|
||||
|
||||
public function __construct(private string $table) {}
|
||||
|
||||
public function where($field, $operator = null, $value = null): self
|
||||
{
|
||||
if (is_array($field)) {
|
||||
foreach ($field as $condition) {
|
||||
$this->where(...$condition);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
if (func_num_args() === 2) {
|
||||
$value = $operator;
|
||||
$operator = '=';
|
||||
}
|
||||
if ($operator === 'in') {
|
||||
return $this->whereIn($field, $value);
|
||||
}
|
||||
if ($operator !== '=') {
|
||||
throw new RuntimeException('Unexpected fixture operator: ' . $operator);
|
||||
}
|
||||
$this->conditions[] = [$field, '=', $value];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereIn(string $field, array $values): self
|
||||
{
|
||||
$this->conditions[] = [$field, 'in', $values];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereNull(string $field): self
|
||||
{
|
||||
$this->conditions[] = [$field, 'null', null];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function whereRaw(string $sql): self
|
||||
{
|
||||
if (!in_array($sql, ['0 = 1', '(assistant_id IS NULL OR assistant_id = 0)'], true)) {
|
||||
throw new RuntimeException('Unexpected fixture SQL: ' . $sql);
|
||||
}
|
||||
$this->conditions[] = ['', 'raw', $sql];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function with(array $relations): self { return $this; }
|
||||
public function append(array $fields): self { return $this; }
|
||||
public function orderRaw(string $order): self { return $this; }
|
||||
public function order(string $field, string $direction): self { return $this; }
|
||||
public function group(string $field): self { return $this; }
|
||||
public function getTableFields(): array { return []; }
|
||||
|
||||
public function field($fields): self
|
||||
{
|
||||
$this->fields = is_array($fields) ? $fields : array_map('trim', explode(',', $fields));
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function limit(int $offset, int $length): self
|
||||
{
|
||||
$this->offset = $offset;
|
||||
$this->length = $length;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function rows(): array
|
||||
{
|
||||
$rows = array_values(array_filter(self::$tables[$this->table] ?? [], function (array $row): bool {
|
||||
foreach ($this->conditions as [$field, $operator, $value]) {
|
||||
$actual = $row[$field] ?? null;
|
||||
if (($operator === '=' && $actual != $value)
|
||||
|| ($operator === 'in' && !in_array($actual, $value))
|
||||
|| ($operator === 'null' && $actual !== null)
|
||||
|| ($operator === 'raw' && ($value === '0 = 1' || ($row['assistant_id'] ?? 0) != 0))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
return array_slice($rows, $this->offset, $this->length);
|
||||
}
|
||||
|
||||
public function select(): think\Collection
|
||||
{
|
||||
if ($this->table === 'admin') {
|
||||
throw new RuntimeException('Assistant enrichment must not read full admin records');
|
||||
}
|
||||
$rows = $this->rows();
|
||||
if ($this->fields !== null) {
|
||||
$rows = array_map(fn (array $row): array => array_intersect_key($row, array_flip($this->fields)), $rows);
|
||||
}
|
||||
return new think\Collection($rows);
|
||||
}
|
||||
|
||||
public function column(string $field, string $key = ''): array
|
||||
{
|
||||
if ($this->table === 'admin') {
|
||||
self::$adminReads[] = ['conditions' => $this->conditions, 'field' => $field, 'key' => $key];
|
||||
}
|
||||
return array_column($this->rows(), $field, $key !== '' ? $key : null);
|
||||
}
|
||||
|
||||
public function count(): int { return count($this->rows()); }
|
||||
}
|
||||
|
||||
class AssistantNameModel
|
||||
{
|
||||
protected const TABLE = 'empty';
|
||||
public function getTable(): string { return static::TABLE; }
|
||||
public static function where(...$args): AssistantNameQuery
|
||||
{
|
||||
return (new AssistantNameQuery(static::TABLE))->where(...$args);
|
||||
}
|
||||
public static function whereIn(...$args): AssistantNameQuery
|
||||
{
|
||||
return (new AssistantNameQuery(static::TABLE))->whereIn(...$args);
|
||||
}
|
||||
}
|
||||
final class AssistantNameDiagnosis extends AssistantNameModel { protected const TABLE = 'tcm_diagnosis'; }
|
||||
final class AssistantNameAdmin extends AssistantNameModel { protected const TABLE = 'admin'; }
|
||||
final class AssistantNameRoles extends AssistantNameModel { protected const TABLE = 'admin_role'; }
|
||||
final class AssistantNameAppointment extends AssistantNameModel { protected const TABLE = 'doctor_appointment'; }
|
||||
final class AssistantNameDb
|
||||
{
|
||||
public static function name(string $table): AssistantNameQuery { return new AssistantNameQuery($table); }
|
||||
}
|
||||
final class AssistantNameRevisitDue
|
||||
{
|
||||
public static function buildListPayload(...$args): array { return []; }
|
||||
public static function emptyPayload(): array { return []; }
|
||||
}
|
||||
|
||||
class_alias(AssistantNameDiagnosis::class, 'app\\common\\model\\tcm\\Diagnosis');
|
||||
class_alias(AssistantNameAdmin::class, 'app\\common\\model\\auth\\Admin');
|
||||
class_alias(AssistantNameRoles::class, 'app\\common\\model\\auth\\AdminRole');
|
||||
class_alias(AssistantNameAppointment::class, 'app\\common\\model\\doctor\\Appointment');
|
||||
foreach (['tcm\\Prescription', 'tcm\\CallRecord', 'tcm\\BloodRecord', 'tcm\\DietRecord',
|
||||
'tcm\\ExerciseRecord', 'dict\\DictData'] as $model) {
|
||||
class_alias(AssistantNameModel::class, 'app\\common\\model\\' . $model);
|
||||
}
|
||||
class_alias(AssistantNameDb::class, 'think\\facade\\Db');
|
||||
class_alias(AssistantNameRevisitDue::class, 'app\\common\\service\\tcm\\RevisitDueService');
|
||||
|
||||
final class AssistantNameLists extends app\adminapi\lists\tcm\DiagnosisLists
|
||||
{
|
||||
public function __construct(private ?array $visibleIds = null, array $params = [], int $offset = 0,
|
||||
int $length = 100, int $actor = 91)
|
||||
{
|
||||
$this->params = $params;
|
||||
$this->adminId = $actor;
|
||||
$this->adminInfo = ['admin_id' => $actor];
|
||||
$this->searchWhere = [];
|
||||
$this->limitOffset = $offset;
|
||||
$this->limitLength = $length;
|
||||
}
|
||||
protected function dataScopeShouldApply(): bool { return true; }
|
||||
protected function getDataScopeVisibleAdminIds(): ?array { return $this->visibleIds; }
|
||||
}
|
||||
|
||||
$assertions = 0;
|
||||
function assistantNameExpect(bool $ok, string $message): void
|
||||
{
|
||||
global $assertions;
|
||||
if (!$ok) {
|
||||
throw new RuntimeException('FAIL: ' . $message);
|
||||
}
|
||||
$assertions++;
|
||||
}
|
||||
|
||||
// Input order is already the normal no-appointment descending-id order.
|
||||
AssistantNameQuery::$tables = [
|
||||
'tcm_diagnosis' => [
|
||||
['id' => 109, 'assistant_id' => 41],
|
||||
['id' => 108, 'assistant_id' => '42'],
|
||||
['id' => 107, 'assistant_id' => 41],
|
||||
['id' => 106, 'assistant_id' => null],
|
||||
['id' => 105, 'assistant_id' => 0],
|
||||
['id' => 104, 'assistant_id' => 404],
|
||||
['id' => 103, 'assistant_id' => 43],
|
||||
['id' => 102, 'assistant_id' => 44],
|
||||
['id' => 101, 'assistant_id' => -1],
|
||||
],
|
||||
'admin' => [
|
||||
['id' => 41, 'name' => ' 林医助 ', 'delete_time' => null, 'disable' => 0, 'account' => 'private-41'],
|
||||
['id' => 42, 'name' => '王医助', 'delete_time' => null, 'disable' => 1, 'account' => 'private-42'],
|
||||
['id' => 43, 'name' => '已删除医助', 'delete_time' => 123, 'disable' => 0],
|
||||
['id' => 44, 'name' => ' ', 'delete_time' => null, 'disable' => 0],
|
||||
['id' => 45, 'name' => '未关联人员', 'delete_time' => null, 'disable' => 0],
|
||||
],
|
||||
];
|
||||
$lists = new AssistantNameLists();
|
||||
$rows = $lists->lists();
|
||||
assistantNameExpect(array_column($rows, 'assistant_name', 'id') === [
|
||||
109 => '林医助', 108 => '王医助', 107 => '林医助', 106 => '', 105 => '',
|
||||
104 => '', 103 => '', 102 => '', 101 => '',
|
||||
], 'each row gets the assigned name; unassigned, missing, deleted, blank and invalid assignments remain empty');
|
||||
assistantNameExpect(AssistantNameQuery::$adminReads === [[
|
||||
'conditions' => [['id', 'in', [41, 42, 404, 43, 44]], ['delete_time', 'null', null]],
|
||||
'field' => 'name', 'key' => 'id',
|
||||
]], 'one batch reads only positive unique assigned ids and the name/id projection');
|
||||
assistantNameExpect($rows[1]['assistant_id'] === '42' && $rows[1]['assistant_name'] === '王医助',
|
||||
'disabled but still assigned staff retain their name without changing the original id');
|
||||
assistantNameExpect(!str_contains(json_encode($rows, JSON_UNESCAPED_UNICODE), 'private-')
|
||||
&& !str_contains(json_encode($rows, JSON_UNESCAPED_UNICODE), '未关联人员'),
|
||||
'enrichment exposes neither admin account fields nor unrelated names');
|
||||
assistantNameExpect($lists->count() === count($rows), 'enrichment does not change total count');
|
||||
|
||||
AssistantNameQuery::$adminReads = [];
|
||||
$scoped = new AssistantNameLists([41]);
|
||||
$rows = $scoped->lists();
|
||||
assistantNameExpect(array_column($rows, 'id') === [109, 107] && $scoped->count() === 2,
|
||||
'real data-scope filtering remains active for lists and count');
|
||||
assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [41]],
|
||||
'names outside the authorized rows are never queried');
|
||||
|
||||
AssistantNameQuery::$adminReads = [];
|
||||
$page = new AssistantNameLists(null, [], 1, 1);
|
||||
$rows = $page->lists();
|
||||
assistantNameExpect(array_column($rows, 'id') === [108] && $rows[0]['assistant_name'] === '王医助'
|
||||
&& $page->count() === 9, 'pagination and count are preserved');
|
||||
assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [42]],
|
||||
'only names on the requested page are queried');
|
||||
|
||||
AssistantNameQuery::$tables['admin_role'] = [['admin_id' => 41, 'role_id' => 2]];
|
||||
AssistantNameQuery::$adminReads = [];
|
||||
$self = new AssistantNameLists(null, [], 0, 100, 41);
|
||||
assistantNameExpect(array_column($self->lists(), 'id') === [109, 107] && $self->count() === 2,
|
||||
'assistant-role self filtering is unchanged');
|
||||
assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [41]],
|
||||
'assistant-role name hydration uses the self-filtered result only');
|
||||
AssistantNameQuery::$tables['admin_role'] = [];
|
||||
|
||||
foreach ([new AssistantNameLists([]), new AssistantNameLists(null, [], 50, 10)] as $empty) {
|
||||
AssistantNameQuery::$adminReads = [];
|
||||
assistantNameExpect($empty->lists() === [] && AssistantNameQuery::$adminReads === [],
|
||||
'empty authorization scope or an empty page does not query admin data');
|
||||
}
|
||||
AssistantNameQuery::$adminReads = [];
|
||||
$pending = new AssistantNameLists(null, ['pending_assign' => 1]);
|
||||
$rows = $pending->lists();
|
||||
assistantNameExpect(array_column($rows, 'id') === [106, 105] && $pending->count() === 2
|
||||
&& array_column($rows, 'assistant_name') === ['', ''] && AssistantNameQuery::$adminReads === [],
|
||||
'unassigned-only lists have an explicit empty name and need no admin query');
|
||||
|
||||
echo "Diagnosis assistant names: {$assertions} assertions passed (no DB or network)\n";
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Execute the real middleware, department controller and tree filtering logic.
|
||||
// Only authentication storage, department rows and scope lookup are in memory.
|
||||
namespace app\adminapi\logic {
|
||||
final class LoginLogic
|
||||
{
|
||||
public static function adminMustBindWorkWechat(array $adminInfo): bool { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\cache {
|
||||
final class AdminAuthCache
|
||||
{
|
||||
public static array $permissions = [];
|
||||
public static array $allUris = [];
|
||||
public static array $actors = [];
|
||||
public static int $allUriReads = 0;
|
||||
public function __construct(int $adminId) { self::$actors[] = $adminId; }
|
||||
public function getAdminUri(): array { return self::$permissions; }
|
||||
public function getAllUri(): array { self::$allUriReads++; return self::$allUris; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\service {
|
||||
final class JsonService
|
||||
{
|
||||
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): array
|
||||
{
|
||||
return compact('code', 'show', 'msg', 'data');
|
||||
}
|
||||
public static function data(array $data): array
|
||||
{
|
||||
return ['code' => 1, 'show' => 0, 'msg' => '', 'data' => $data];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\model\dept {
|
||||
final class Dept
|
||||
{
|
||||
public static array $rows = [];
|
||||
public static int $reads = 0;
|
||||
public static function order(array $order): self { self::$reads++; return new self(); }
|
||||
public function select(): self { return $this; }
|
||||
public function toArray(): array { return self::$rows; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\service\DataScope {
|
||||
final class DataScopeService
|
||||
{
|
||||
public static ?array $allowed = [12 => true];
|
||||
public static array $actors = [];
|
||||
public static function isEnabled(): bool { return true; }
|
||||
public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
self::$actors[] = [$adminId, $adminInfo];
|
||||
return self::$allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\adminapi\http\middleware {
|
||||
function request(): object { return $GLOBALS['diagnosis_department_request']; }
|
||||
function env(...$arguments): never { throw new \RuntimeException('This test must not read environment configuration'); }
|
||||
}
|
||||
|
||||
namespace {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\controller\dept\DeptController;
|
||||
use app\adminapi\http\middleware\AuthMiddleware;
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\JsonService;
|
||||
|
||||
final class DiagnosisDepartmentRequest
|
||||
{
|
||||
public array $adminInfo;
|
||||
public object $controllerObject;
|
||||
public string $controllerName = 'dept.Dept';
|
||||
public string $actionName = 'all';
|
||||
|
||||
public function __construct(public array $query = [], public string $method = 'GET',
|
||||
public array $body = [], bool $root = false)
|
||||
{
|
||||
$this->adminInfo = ['admin_id' => 91, 'root' => $root ? 1 : 0, 'role_id' => [7],
|
||||
'login_ip' => '192.0.2.91', 'work_wechat_userid' => 'synthetic-doctor-91'];
|
||||
}
|
||||
public function get(?string $name = null, $default = null)
|
||||
{
|
||||
return $name === null ? $this->query : ($this->query[$name] ?? $default);
|
||||
}
|
||||
public function param(string $name, $default = null)
|
||||
{
|
||||
return $this->body[$name] ?? $this->query[$name] ?? $default;
|
||||
}
|
||||
public function isGet(): bool { return $this->method === 'GET'; }
|
||||
public function controller(): string { return $this->controllerName; }
|
||||
public function action(): string { return $this->actionName; }
|
||||
public function ip(): string { return '192.0.2.91'; }
|
||||
}
|
||||
|
||||
final class DiagnosisDepartmentController extends DeptController
|
||||
{
|
||||
public function __construct(DiagnosisDepartmentRequest $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->adminId = $request->adminInfo['admin_id'];
|
||||
$this->adminInfo = $request->adminInfo;
|
||||
}
|
||||
protected function data($data) { return JsonService::data($data); }
|
||||
}
|
||||
|
||||
$assertions = 0;
|
||||
function departmentPermissionExpect(bool $ok, string $label): void
|
||||
{
|
||||
global $assertions;
|
||||
if (!$ok) { throw new RuntimeException('FAIL: ' . $label); }
|
||||
$assertions++;
|
||||
}
|
||||
|
||||
function departmentPermissionInvoke(DiagnosisDepartmentRequest $request, array $permissions,
|
||||
?array $registered = null): array
|
||||
{
|
||||
AdminAuthCache::$permissions = $permissions;
|
||||
AdminAuthCache::$allUris = $registered ?? [
|
||||
'dept.dept/all', 'dept.dept/lists', 'dept.dept/leaderDept', 'dept.dept/detail',
|
||||
'dept.dept/add', 'dept.dept/edit', 'dept.dept/delete', 'dept.jobs/all',
|
||||
];
|
||||
AdminAuthCache::$actors = [];
|
||||
AdminAuthCache::$allUriReads = 0;
|
||||
DataScopeService::$actors = [];
|
||||
Dept::$reads = 0;
|
||||
$GLOBALS['diagnosis_department_request'] = $request;
|
||||
$controller = new DiagnosisDepartmentController($request);
|
||||
$request->controllerObject = $controller;
|
||||
return (new AuthMiddleware())->handle($request, static function () use ($request, $controller): array {
|
||||
if (strtolower($request->controller() . '/' . $request->action()) === 'dept.dept/all') {
|
||||
return $controller->all();
|
||||
}
|
||||
// A sentinel verifies function-level grants without performing CRUD writes.
|
||||
return ['code' => 1, 'continued' => true];
|
||||
});
|
||||
}
|
||||
|
||||
Dept::$rows = [
|
||||
['id' => 10, 'pid' => 0, 'name' => '共享祖先'],
|
||||
['id' => 11, 'pid' => 10, 'name' => '未授权同级'],
|
||||
['id' => 12, 'pid' => 10, 'name' => '授权部门'],
|
||||
['id' => 20, 'pid' => 0, 'name' => '其他部门'],
|
||||
];
|
||||
$scopedTree = [[
|
||||
'id' => 10, 'pid' => 0, 'name' => '共享祖先', 'level' => 0,
|
||||
'children' => [['id' => 12, 'pid' => 10, 'name' => '授权部门', 'level' => 1, 'children' => []]],
|
||||
]];
|
||||
|
||||
foreach ([1, '1'] as $scope) {
|
||||
foreach (['tcm.diagnosis/lists', 'TCM.DIAGNOSIS/LISTS'] as $permission) {
|
||||
$request = new DiagnosisDepartmentRequest(['apply_data_scope' => $scope]);
|
||||
$request->controllerName = 'DePt.DePt';
|
||||
$request->actionName = 'AlL';
|
||||
$result = departmentPermissionInvoke($request, [$permission]);
|
||||
departmentPermissionExpect($result === ['code' => 1, 'show' => 0, 'msg' => '', 'data' => $scopedTree],
|
||||
'exact patient-list grant returns the real scoped tree and original envelope');
|
||||
departmentPermissionExpect(DataScopeService::$actors === [[91, $request->adminInfo]],
|
||||
'controller passes the authenticated identity to data scope');
|
||||
departmentPermissionExpect(Dept::$reads === 1 && AdminAuthCache::$allUriReads === 0,
|
||||
'read-only alias does not need a department management grant');
|
||||
}
|
||||
}
|
||||
|
||||
$invalidQueries = [[], ['apply_data_scope' => null]];
|
||||
foreach ([0, '0', '', '01', '1x', '1.0', ' 1', '1 ', true, false, 1.0, 2, -1,
|
||||
[], ['1'], new stdClass()] as $invalid) {
|
||||
$invalidQueries[] = ['apply_data_scope' => $invalid];
|
||||
}
|
||||
foreach ($invalidQueries as $query) {
|
||||
$result = departmentPermissionInvoke(new DiagnosisDepartmentRequest($query), ['tcm.diagnosis/lists']);
|
||||
departmentPermissionExpect($result['code'] === 0, 'missing or malformed scope cannot acquire the alias ' . json_encode($query));
|
||||
departmentPermissionExpect(Dept::$reads === 0 && DataScopeService::$actors === [],
|
||||
'invalid scope is rejected before any department read');
|
||||
}
|
||||
|
||||
foreach ([[], ['tcm.diagnosis/detail'], ['tcm.diagnosis/edit'], ['tcm.diagnosis/listsOther'],
|
||||
['dept.dept/lists']] as $permissions) {
|
||||
$result = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1']), $permissions);
|
||||
departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
|
||||
'unrelated permissions do not acquire the patient-list alias ' . json_encode($permissions));
|
||||
}
|
||||
|
||||
foreach (['POST', 'PUT', 'DELETE', 'HEAD'] as $method) {
|
||||
$result = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1'], $method),
|
||||
['tcm.diagnosis/lists']);
|
||||
departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0, 'alias denies HTTP method ' . $method);
|
||||
}
|
||||
foreach (['GET', 'POST'] as $method) {
|
||||
$result = departmentPermissionInvoke(new DiagnosisDepartmentRequest([], $method, ['apply_data_scope' => '1']),
|
||||
['tcm.diagnosis/lists']);
|
||||
departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
|
||||
'request body cannot enable GET-only scope on ' . $method);
|
||||
}
|
||||
|
||||
foreach (['lists', 'leaderDept', 'detail', 'add', 'edit', 'delete'] as $action) {
|
||||
foreach (['GET', 'POST'] as $method) {
|
||||
$request = new DiagnosisDepartmentRequest(['apply_data_scope' => '1'], $method);
|
||||
$request->actionName = $action;
|
||||
$result = departmentPermissionInvoke($request, ['tcm.diagnosis/lists']);
|
||||
departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
|
||||
'patient-list grant never authorizes department action ' . $method . ' ' . $action);
|
||||
}
|
||||
$request = new DiagnosisDepartmentRequest([], 'POST');
|
||||
$request->actionName = $action;
|
||||
$result = departmentPermissionInvoke($request, ['dept.dept/' . $action]);
|
||||
departmentPermissionExpect(($result['continued'] ?? false) === true,
|
||||
'original exact department permission still authorizes ' . $action);
|
||||
}
|
||||
$otherController = new DiagnosisDepartmentRequest(['apply_data_scope' => '1']);
|
||||
$otherController->controllerName = 'dept.Jobs';
|
||||
$result = departmentPermissionInvoke($otherController, ['tcm.diagnosis/lists']);
|
||||
departmentPermissionExpect($result['code'] === 0, 'alias is limited to the exact department controller');
|
||||
|
||||
$originalGrant = departmentPermissionInvoke(new DiagnosisDepartmentRequest(), ['dept.dept/all']);
|
||||
departmentPermissionExpect($originalGrant['code'] === 1 && count($originalGrant['data']) === 2
|
||||
&& count($originalGrant['data'][0]['children']) === 2 && DataScopeService::$actors === [],
|
||||
'original all-departments permission retains its unscoped response');
|
||||
$originalScoped = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => 1]), ['dept.dept/all']);
|
||||
departmentPermissionExpect($originalScoped['data'] === $scopedTree && count(DataScopeService::$actors) === 1,
|
||||
'original department grant can still request a scoped tree');
|
||||
|
||||
$root = departmentPermissionInvoke(new DiagnosisDepartmentRequest([], 'GET', [], true), []);
|
||||
departmentPermissionExpect($root['code'] === 1 && count($root['data']) === 2 && AdminAuthCache::$actors === [],
|
||||
'root retains the existing middleware bypass and full tree');
|
||||
DataScopeService::$allowed = null;
|
||||
$rootScoped = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => 1], 'GET', [], true), []);
|
||||
departmentPermissionExpect($rootScoped['data'] === $root['data'] && count(DataScopeService::$actors) === 1,
|
||||
'root scoped requests still execute the controller data-scope contract');
|
||||
DataScopeService::$allowed = [];
|
||||
$empty = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1']), ['tcm.diagnosis/lists']);
|
||||
departmentPermissionExpect($empty === ['code' => 1, 'show' => 0, 'msg' => '', 'data' => []],
|
||||
'authorized empty scope remains a successful empty tree, never a full-data fallback');
|
||||
|
||||
$legacy = departmentPermissionInvoke(new DiagnosisDepartmentRequest(), [], []);
|
||||
departmentPermissionExpect($legacy['code'] === 1 && count($legacy['data']) === 2 && AdminAuthCache::$allUriReads === 1,
|
||||
'unmatched requests continue through the existing unregistered-route policy unchanged');
|
||||
|
||||
echo "Diagnosis department filter permission: {$assertions} assertions passed (no DB or network)\n";
|
||||
}
|
||||
Reference in New Issue
Block a user