fix(tasks): isolate peripheral feedback writes by execution scope
Generic Container CI/CD / test-build-publish (push) Successful in 4m15s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m17s

An old development synchronizer could overwrite a production recovery timestamp in a shared project database even after production deployment. Check task scope before feedback or source-withdrawal writes and before idempotency replay.

Cover cross-scope rejection, same-scope success and replay, and preserve actual field feedback time when later order metadata changes.
This commit is contained in:
2026-11-08 04:07:07 +08:00
parent acc8f8f7cb
commit 549019507e
4 changed files with 74 additions and 8 deletions
+56
View File
@@ -2487,3 +2487,59 @@ def test_task_scope_is_required_in_persisted_contract():
OperationalTaskSummary.model_validate(task)
assert any(item["loc"] == ("execution_scope",) and item["type"] == "missing"
for item in error.value.errors())
@pytest.mark.anyio
@pytest.mark.parametrize("operation", ["feedback", "withdrawal"])
@pytest.mark.parametrize("task_scope,server_scope,allowed", [
(None, "production", False),
(None, "development", False),
("production", "production", True),
("production", "development", False),
("development", "production", False),
("development", "development", True),
])
async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation, task_scope, server_scope, allowed):
from copy import deepcopy
now = datetime(2026, 11, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
source_id = f"peripheral_sample:{uuid4()}"
task.update(task_type="external_work_order", source={
"kind": "external_system", "external_id": source_id,
"external_revision": 1, "event_kind": "general",
})
if task_scope is None:
task.pop("execution_scope")
else:
task["execution_scope"] = task_scope
repository = TaskRepository(task)
service = OperationalTaskService(repository, context, set(), clock=lambda: now, execution_scope=server_scope)
if operation == "feedback":
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=source_id,
source_revision=2, status="completed", occurred_at=now, detail="已关阀修复管道")
method = "external_feedback"
else:
command = ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id)
method = "withdraw_external_source"
before = deepcopy(repository.task)
if not allowed:
with pytest.raises(DomainError) as error:
await getattr(service, method)(task["task_id"], command)
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
assert repository.task == before
assert repository.feedback == repository.evidence == repository.requests == {}
return
await getattr(service, method)(task["task_id"], command)
assert repository.task["status"] == ("completed" if operation == "feedback" else "cancelled")
# Check scope before idempotency replay as well as before the first write.
await getattr(service, method)(task["task_id"], command)
before = deepcopy(repository.task)
other = OperationalTaskService(repository, context, set(), clock=lambda: now,
execution_scope="development" if server_scope == "production" else "production")
with pytest.raises(DomainError) as error:
await getattr(other, method)(task["task_id"], command)
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
assert repository.task == before
+7 -3
View File
@@ -226,7 +226,7 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
@pytest.mark.anyio
@pytest.mark.parametrize("targeted", [False, True])
@pytest.mark.parametrize("order_revision", [1, 2])
@pytest.mark.parametrize("order_revision", [1, 4])
@pytest.mark.parametrize(
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
[
@@ -254,9 +254,13 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
"updated_at": "2026-09-23T09:00:00Z",
"title": "采样工单", "priority": "normal",
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
"scheduled_at": None,
"scheduled_at": "2026-09-23T08:30:00Z",
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员",
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}]
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"},
{"sequence": 3, "occurred_at": "2026-09-23T08:50:00Z", "actor": "调度员",
"kind": "updated", "title": "工单信息更新", "detail": "更新了状态。"},
{"sequence": 4, "occurred_at": "2026-09-23T09:00:00Z", "actor": "调度员",
"kind": "updated", "title": "工单信息更新", "detail": "更新了优先级。"}]
if order_revision > 1 else []),
}
if targeted: