Block conflicting extension write selectors
This commit is contained in:
@@ -780,6 +780,9 @@ concrete read-only `metadata.write.plan`. The same strict mode also requires
|
|||||||
the write-preflight smoke to discover an extension form by public
|
the write-preflight smoke to discover an extension form by public
|
||||||
`extension/ref/form/member` names, compose an allowed plan, and prove that the
|
`extension/ref/form/member` names, compose an allowed plan, and prove that the
|
||||||
repository and support gates use the same resolved `extension:<GUID>` layer.
|
repository and support gates use the same resolved `extension:<GUID>` layer.
|
||||||
|
It then submits the same public selector with a deliberately different GUID
|
||||||
|
and requires a read-only `blocked / extension_selector_conflict` result, so a
|
||||||
|
legacy permissive repository profile cannot authorize a mismatched layer.
|
||||||
Run that check directly with
|
Run that check directly with
|
||||||
`scripts/smoke_1c_write_preflight.py --require-name-first-extension-form`.
|
`scripts/smoke_1c_write_preflight.py --require-name-first-extension-form`.
|
||||||
|
|
||||||
|
|||||||
@@ -47447,6 +47447,18 @@ def repository_apply_gate(payload: dict[str, Any], method: str, mode: str) -> di
|
|||||||
"schema": "onec_repository_write_gate.v1", "method": method,
|
"schema": "onec_repository_write_gate.v1", "method": method,
|
||||||
"base_id": payload.get("base_id"), **context_error,
|
"base_id": payload.get("base_id"), **context_error,
|
||||||
}
|
}
|
||||||
|
layer_block = development_layer_write_block(payload)
|
||||||
|
if layer_block:
|
||||||
|
return {
|
||||||
|
"schema": "onec_repository_write_gate.v1",
|
||||||
|
"method": method,
|
||||||
|
"status": "blocked",
|
||||||
|
"base_id": payload.get("base_id"),
|
||||||
|
**layer_block,
|
||||||
|
"diagnostics": {
|
||||||
|
"message": "Saved-state apply is blocked because the public extension selector did not resolve to one matching development layer.",
|
||||||
|
},
|
||||||
|
}
|
||||||
gate = repository_control.write_gate(payload)
|
gate = repository_control.write_gate(payload)
|
||||||
support = live_sql_support_gate(payload)
|
support = live_sql_support_gate(payload)
|
||||||
if gate.get("allowed") is True and support.get("allowed") is True:
|
if gate.get("allowed") is True and support.get("allowed") is True:
|
||||||
@@ -47491,9 +47503,12 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
repository_gate = repository_control.write_gate(gate_payload)
|
repository_gate = repository_control.write_gate(gate_payload)
|
||||||
support_gate = live_sql_support_gate(gate_payload)
|
support_gate = live_sql_support_gate(gate_payload)
|
||||||
|
layer_block = development_layer_write_block(payload)
|
||||||
schedule_status = str(schedule_plan.get("status") or "blocked")
|
schedule_status = str(schedule_plan.get("status") or "blocked")
|
||||||
status = "ready" if schedule_status in {"planned", "unchanged"} else schedule_status
|
status = "ready" if schedule_status in {"planned", "unchanged"} else schedule_status
|
||||||
if repository_gate.get("allowed") is not True:
|
if layer_block:
|
||||||
|
status = "blocked"
|
||||||
|
elif repository_gate.get("allowed") is not True:
|
||||||
status = str(repository_gate.get("status") or "blocked")
|
status = str(repository_gate.get("status") or "blocked")
|
||||||
elif support_gate.get("allowed") is not True:
|
elif support_gate.get("allowed") is not True:
|
||||||
status = str(support_gate.get("status") or "blocked")
|
status = str(support_gate.get("status") or "blocked")
|
||||||
@@ -47526,6 +47541,11 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
"repository": repository_gate,
|
"repository": repository_gate,
|
||||||
"support": support_gate,
|
"support": support_gate,
|
||||||
|
**(
|
||||||
|
{"development_layer_resolution": payload["development_layer_resolution"]}
|
||||||
|
if isinstance(payload.get("development_layer_resolution"), dict)
|
||||||
|
else {}
|
||||||
|
),
|
||||||
"guards": {
|
"guards": {
|
||||||
"required": ["allow_saved_state_write", "expected_sha1", "backup", "readback_verification"],
|
"required": ["allow_saved_state_write", "expected_sha1", "backup", "readback_verification"],
|
||||||
"requires_saved_state_prepare": schedule_status == "needs_prepare",
|
"requires_saved_state_prepare": schedule_status == "needs_prepare",
|
||||||
@@ -47649,7 +47669,10 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
gate_payload["target"] = {**caller_target, **plan_target}
|
gate_payload["target"] = {**caller_target, **plan_target}
|
||||||
repository_gate = repository_control.write_gate(gate_payload)
|
repository_gate = repository_control.write_gate(gate_payload)
|
||||||
support_gate = live_sql_support_gate(gate_payload)
|
support_gate = live_sql_support_gate(gate_payload)
|
||||||
if repository_gate.get("allowed") is not True:
|
layer_block = development_layer_write_block(payload)
|
||||||
|
if layer_block:
|
||||||
|
status = "blocked"
|
||||||
|
elif repository_gate.get("allowed") is not True:
|
||||||
status = str(repository_gate.get("status") or "blocked")
|
status = str(repository_gate.get("status") or "blocked")
|
||||||
elif support_gate.get("allowed") is not True:
|
elif support_gate.get("allowed") is not True:
|
||||||
status = str(support_gate.get("status") or "blocked")
|
status = str(support_gate.get("status") or "blocked")
|
||||||
@@ -47682,6 +47705,11 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"saved_state": saved_state,
|
"saved_state": saved_state,
|
||||||
"repository": repository_gate,
|
"repository": repository_gate,
|
||||||
"support": support_gate,
|
"support": support_gate,
|
||||||
|
**(
|
||||||
|
{"development_layer_resolution": payload["development_layer_resolution"]}
|
||||||
|
if isinstance(payload.get("development_layer_resolution"), dict)
|
||||||
|
else {}
|
||||||
|
),
|
||||||
**({"owner_resolution": payload["owner_resolution"]} if isinstance(payload.get("owner_resolution"), dict) else {}),
|
**({"owner_resolution": payload["owner_resolution"]} if isinstance(payload.get("owner_resolution"), dict) else {}),
|
||||||
"guards": guards,
|
"guards": guards,
|
||||||
"plan": {
|
"plan": {
|
||||||
@@ -54672,6 +54700,20 @@ def repository_resolve_form_guid_sql(*, method: str, base_id: str, form_guid: st
|
|||||||
_FORM_MODULE_FILE_RE = re.compile(r"^(?:(?P<extension>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})__)?(?P<form>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.0$", re.IGNORECASE)
|
_FORM_MODULE_FILE_RE = re.compile(r"^(?:(?P<extension>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})__)?(?P<form>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.0$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def development_layer_write_block(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
resolution = payload.get("development_layer_resolution") if isinstance(payload.get("development_layer_resolution"), dict) else {}
|
||||||
|
status = str(resolution.get("status") or "").strip().casefold()
|
||||||
|
if not status or status == "resolved":
|
||||||
|
return None
|
||||||
|
diagnostics = resolution.get("diagnostics") if isinstance(resolution.get("diagnostics"), dict) else {}
|
||||||
|
error = str(diagnostics.get("error") or "extension_layer_not_resolved")
|
||||||
|
return {
|
||||||
|
"error": error,
|
||||||
|
"layer_id": resolution.get("layer_id") or "extension:unresolved",
|
||||||
|
"development_layer_resolution": resolution,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]:
|
def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Derive the exact development layer and form ownership before gates.
|
"""Derive the exact development layer and form ownership before gates.
|
||||||
|
|
||||||
@@ -54705,16 +54747,47 @@ def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
else:
|
else:
|
||||||
requested_extension = str(requested_extension_value or "").strip()
|
requested_extension = str(requested_extension_value or "").strip()
|
||||||
base_id = str(payload.get("base_id") or "").strip()
|
base_id = str(payload.get("base_id") or "").strip()
|
||||||
resolved_extension_guid = explicit_extension_guid
|
resolved_extension_guid = ""
|
||||||
extension_error: dict[str, Any] | None = None
|
extension_error: dict[str, Any] | None = None
|
||||||
if not resolved_extension_guid and requested_extension and base_id:
|
selector_conflict: dict[str, Any] | None = None
|
||||||
resolved_extension_guid, extension_error = extension_filter_to_guid(
|
if explicit_extension_guid and not is_guid_text(explicit_extension_guid):
|
||||||
|
selector_conflict = {
|
||||||
|
"status": "invalid_argument",
|
||||||
|
"error": "invalid_extension_guid",
|
||||||
|
"extension_guid": explicit_extension_guid,
|
||||||
|
}
|
||||||
|
resolved_name_guid: str | None = None
|
||||||
|
if requested_extension and base_id and selector_conflict is None:
|
||||||
|
resolved_name_guid, extension_error = extension_filter_to_guid(
|
||||||
base_id,
|
base_id,
|
||||||
requested_extension,
|
requested_extension,
|
||||||
method="write.gate_context",
|
method="write.gate_context",
|
||||||
)
|
)
|
||||||
|
if extension_error:
|
||||||
|
selector_conflict = extension_error
|
||||||
|
elif explicit_extension_guid and resolved_name_guid != explicit_extension_guid:
|
||||||
|
selector_conflict = {
|
||||||
|
"status": "conflict",
|
||||||
|
"error": "extension_selector_conflict",
|
||||||
|
"base_id": base_id,
|
||||||
|
"query": {
|
||||||
|
"extension": requested_extension,
|
||||||
|
"extension_guid": explicit_extension_guid,
|
||||||
|
},
|
||||||
|
"resolved_extension_guid": resolved_name_guid,
|
||||||
|
"diagnostics": {
|
||||||
|
"message": "The public extension name resolves to a different GUID than the supplied write context.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
elif requested_extension and not base_id and selector_conflict is None:
|
||||||
|
selector_conflict = {
|
||||||
|
"status": "not_resolved",
|
||||||
|
"error": "extension_resolution_requires_base_id",
|
||||||
|
"base_id": None,
|
||||||
|
}
|
||||||
|
if selector_conflict is None:
|
||||||
|
resolved_extension_guid = str(resolved_name_guid or explicit_extension_guid or "").lower()
|
||||||
if resolved_extension_guid:
|
if resolved_extension_guid:
|
||||||
resolved_extension_guid = resolved_extension_guid.lower()
|
|
||||||
result["extension_guid"] = resolved_extension_guid
|
result["extension_guid"] = resolved_extension_guid
|
||||||
target["extension_guid"] = resolved_extension_guid
|
target["extension_guid"] = resolved_extension_guid
|
||||||
target["origin"] = {
|
target["origin"] = {
|
||||||
@@ -54736,18 +54809,24 @@ def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
"layer_id": f"extension:{resolved_extension_guid}",
|
"layer_id": f"extension:{resolved_extension_guid}",
|
||||||
}
|
}
|
||||||
elif requested_extension:
|
elif requested_extension or selector_conflict:
|
||||||
# Never let an unresolved public extension selector inherit the base
|
# An explicit preflight/apply block consumes this resolution. The
|
||||||
# repository/support policy. The synthetic layer cannot match a
|
# synthetic layer also prevents accidental inheritance of base policy.
|
||||||
# configured GUID layer and therefore keeps every write gate closed.
|
result.pop("extension_guid", None)
|
||||||
|
target.pop("extension_guid", None)
|
||||||
result["layer_id"] = "extension:unresolved"
|
result["layer_id"] = "extension:unresolved"
|
||||||
target["layer_id"] = "extension:unresolved"
|
target["layer_id"] = "extension:unresolved"
|
||||||
|
unresolved_status = "conflict" if str((selector_conflict or {}).get("error") or "") == "extension_selector_conflict" else "not_resolved"
|
||||||
result["development_layer_resolution"] = {
|
result["development_layer_resolution"] = {
|
||||||
"status": "not_resolved",
|
"status": unresolved_status,
|
||||||
"source": "extension_name",
|
"source": "extension_name",
|
||||||
"extension": {"name": requested_extension},
|
"extension": {
|
||||||
|
**({"name": requested_extension} if requested_extension else {}),
|
||||||
|
**({"supplied_guid": explicit_extension_guid} if explicit_extension_guid else {}),
|
||||||
|
},
|
||||||
"layer_id": "extension:unresolved",
|
"layer_id": "extension:unresolved",
|
||||||
"diagnostics": extension_error
|
"diagnostics": selector_conflict
|
||||||
|
or extension_error
|
||||||
or {
|
or {
|
||||||
"status": "not_resolved",
|
"status": "not_resolved",
|
||||||
"error": "extension_resolution_requires_base_id",
|
"error": "extension_resolution_requires_base_id",
|
||||||
@@ -54756,6 +54835,8 @@ def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
if target:
|
if target:
|
||||||
result["target"] = target
|
result["target"] = target
|
||||||
|
if development_layer_write_block(result):
|
||||||
|
return result
|
||||||
existing = payload.get("owner_resolution") if isinstance(payload.get("owner_resolution"), dict) else {}
|
existing = payload.get("owner_resolution") if isinstance(payload.get("owner_resolution"), dict) else {}
|
||||||
if existing.get("status") == "resolved" and str(existing.get("repository_object") or "").strip():
|
if existing.get("status") == "resolved" and str(existing.get("repository_object") or "").strip():
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ def validate_write_preflight(
|
|||||||
failures.append({"code": "write_preflight_not_ok", "label": label, "path": str(path), "status": report.get("status")})
|
failures.append({"code": "write_preflight_not_ok", "label": label, "path": str(path), "status": report.get("status")})
|
||||||
if report.get("failures"):
|
if report.get("failures"):
|
||||||
failures.append({"code": "write_preflight_failures_present", "label": label, "path": str(path), "failures": report.get("failures")})
|
failures.append({"code": "write_preflight_failures_present", "label": label, "path": str(path), "failures": report.get("failures")})
|
||||||
for check in ("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight"):
|
for check in ("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight", "conflicting_extension_selector_preflight"):
|
||||||
if check not in checks:
|
if check not in checks:
|
||||||
failures.append({"code": "write_preflight_check_missing", "label": label, "check": check, "path": str(path)})
|
failures.append({"code": "write_preflight_check_missing", "label": label, "check": check, "path": str(path)})
|
||||||
expect_check(checks, failures, label, path, "method_exposed", {"status": "ok"}, failure_code="write_preflight_check_field_unexpected")
|
expect_check(checks, failures, label, path, "method_exposed", {"status": "ok"}, failure_code="write_preflight_check_field_unexpected")
|
||||||
@@ -470,6 +470,31 @@ def validate_write_preflight(
|
|||||||
"repository_layer_id": repository_layer or None,
|
"repository_layer_id": repository_layer or None,
|
||||||
"support_layer_id": support_layer or None,
|
"support_layer_id": support_layer or None,
|
||||||
})
|
})
|
||||||
|
conflicting_extension = checks.get("conflicting_extension_selector_preflight") if isinstance(checks.get("conflicting_extension_selector_preflight"), dict) else {}
|
||||||
|
if conflicting_extension.get("status") == "skipped_no_public_extension_form_target":
|
||||||
|
if require_name_first_form:
|
||||||
|
failures.append({
|
||||||
|
"code": "write_preflight_extension_conflict_check_required",
|
||||||
|
"label": label,
|
||||||
|
"path": str(path),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
expect_check(
|
||||||
|
checks,
|
||||||
|
failures,
|
||||||
|
label,
|
||||||
|
path,
|
||||||
|
"conflicting_extension_selector_preflight",
|
||||||
|
{
|
||||||
|
"schema": "onec_metadata_write_preflight.v1",
|
||||||
|
"status": "blocked",
|
||||||
|
"allowed": False,
|
||||||
|
"resolution_status": "conflict",
|
||||||
|
"error": "extension_selector_conflict",
|
||||||
|
"layer_id": "extension:unresolved",
|
||||||
|
},
|
||||||
|
failure_code="write_preflight_check_field_unexpected",
|
||||||
|
)
|
||||||
if require_mcp_initialize:
|
if require_mcp_initialize:
|
||||||
if "mcp.initialize" not in checks:
|
if "mcp.initialize" not in checks:
|
||||||
failures.append({"code": "write_preflight_check_missing", "label": label, "check": "mcp.initialize", "path": str(path)})
|
failures.append({"code": "write_preflight_check_missing", "label": label, "check": "mcp.initialize", "path": str(path)})
|
||||||
@@ -1633,6 +1658,14 @@ def write_self_test_reports(report_dir: Path, *, base_id: str, composed: bool, s
|
|||||||
"name_first": True,
|
"name_first": True,
|
||||||
"extension": "test2",
|
"extension": "test2",
|
||||||
},
|
},
|
||||||
|
"conflicting_extension_selector_preflight": {
|
||||||
|
"schema": "onec_metadata_write_preflight.v1",
|
||||||
|
"status": "blocked",
|
||||||
|
"allowed": False,
|
||||||
|
"resolution_status": "conflict",
|
||||||
|
"error": "extension_selector_conflict",
|
||||||
|
"layer_id": "extension:unresolved",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"failures": [],
|
"failures": [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,14 @@ def first_public_extension_form_candidate(result: dict[str, Any]) -> dict[str, A
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def different_extension_guid(layer_id: str) -> str | None:
|
||||||
|
if not EXTENSION_GUID_LAYER_RE.fullmatch(str(layer_id or "")):
|
||||||
|
return None
|
||||||
|
guid = str(layer_id).split(":", 1)[1].lower()
|
||||||
|
replacement = "0" if guid[-1] != "0" else "1"
|
||||||
|
return f"{guid[:-1]}{replacement}"
|
||||||
|
|
||||||
|
|
||||||
def run_smoke(
|
def run_smoke(
|
||||||
endpoint_url: str,
|
endpoint_url: str,
|
||||||
base_id: str,
|
base_id: str,
|
||||||
@@ -312,6 +320,9 @@ def run_smoke(
|
|||||||
"search_status": forms.get("status"),
|
"search_status": forms.get("status"),
|
||||||
"forms": int((forms.get("counts") or {}).get("forms") or 0),
|
"forms": int((forms.get("counts") or {}).get("forms") or 0),
|
||||||
}
|
}
|
||||||
|
checks["conflicting_extension_selector_preflight"] = {
|
||||||
|
"status": "skipped_no_public_extension_form_target",
|
||||||
|
}
|
||||||
if require_name_first_extension_form:
|
if require_name_first_extension_form:
|
||||||
failures.append("name-first extension form preflight target is required but was not discovered")
|
failures.append("name-first extension form preflight target is required but was not discovered")
|
||||||
else:
|
else:
|
||||||
@@ -355,6 +366,49 @@ def run_smoke(
|
|||||||
require(name_first, "extension form preflight input must remain name-first", failures)
|
require(name_first, "extension form preflight input must remain name-first", failures)
|
||||||
require(bool(EXTENSION_GUID_LAYER_RE.fullmatch(repository_layer)), "repository gate must use a resolved extension GUID layer", failures)
|
require(bool(EXTENSION_GUID_LAYER_RE.fullmatch(repository_layer)), "repository gate must use a resolved extension GUID layer", failures)
|
||||||
require(repository_layer == support_layer, "repository and support gates must use the same extension layer", failures)
|
require(repository_layer == support_layer, "repository and support gates must use the same extension layer", failures)
|
||||||
|
conflicting_guid = different_extension_guid(repository_layer)
|
||||||
|
if not conflicting_guid:
|
||||||
|
checks["conflicting_extension_selector_preflight"] = {
|
||||||
|
"status": "skipped_no_resolved_extension_layer",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
conflicting = rpc_call(
|
||||||
|
endpoint_url,
|
||||||
|
"metadata.write.preflight",
|
||||||
|
{
|
||||||
|
"base_id": base_id,
|
||||||
|
"extension_guid": conflicting_guid,
|
||||||
|
"target": form_candidate["target"],
|
||||||
|
"edits": [form_candidate["edit"]],
|
||||||
|
"resolve_origin": False,
|
||||||
|
"timeout_seconds": int(timeout),
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
transport=transport,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
layer_resolution = (
|
||||||
|
conflicting.get("development_layer_resolution")
|
||||||
|
if isinstance(conflicting.get("development_layer_resolution"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
layer_diagnostics = (
|
||||||
|
layer_resolution.get("diagnostics")
|
||||||
|
if isinstance(layer_resolution.get("diagnostics"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
checks["conflicting_extension_selector_preflight"] = {
|
||||||
|
"schema": conflicting.get("schema"),
|
||||||
|
"status": conflicting.get("status"),
|
||||||
|
"allowed": conflicting.get("allowed"),
|
||||||
|
"resolution_status": layer_resolution.get("status"),
|
||||||
|
"error": layer_diagnostics.get("error"),
|
||||||
|
"layer_id": layer_resolution.get("layer_id"),
|
||||||
|
}
|
||||||
|
require(conflicting.get("schema") == "onec_metadata_write_preflight.v1", "conflicting extension selector preflight must return expected schema", failures)
|
||||||
|
require(conflicting.get("status") == "blocked" and conflicting.get("allowed") is False, "conflicting extension name/GUID must block preflight", failures)
|
||||||
|
require(layer_resolution.get("status") == "conflict", "conflicting extension selector must report conflict resolution status", failures)
|
||||||
|
require(layer_diagnostics.get("error") == "extension_selector_conflict", "conflicting extension selector must report extension_selector_conflict", failures)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"schema": "onec_write_preflight_smoke.v1",
|
"schema": "onec_write_preflight_smoke.v1",
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ function Assert-WritePreflightReport {
|
|||||||
if ($report.failures -and $report.failures.Count -gt 0) {
|
if ($report.failures -and $report.failures.Count -gt 0) {
|
||||||
throw "$Label report contains failures: $Path"
|
throw "$Label report contains failures: $Path"
|
||||||
}
|
}
|
||||||
foreach ($check in @("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight")) {
|
foreach ($check in @("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight", "conflicting_extension_selector_preflight")) {
|
||||||
if ($report.checks.PSObject.Properties.Name -notcontains $check) {
|
if ($report.checks.PSObject.Properties.Name -notcontains $check) {
|
||||||
throw "$Label report is missing check '$check': $Path"
|
throw "$Label report is missing check '$check': $Path"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20992,6 +20992,14 @@ def test_metadata_write_active_form_target_auto_prepares_when_apply_allowed(monk
|
|||||||
def test_metadata_write_missing_form_target_auto_prepares_and_retries(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_metadata_write_missing_form_target_auto_prepares_and_retries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
calls: dict[str, int] = {"apply": 0}
|
calls: dict[str, int] = {"apply": 0}
|
||||||
seen: dict[str, Any] = {}
|
seen: dict[str, Any] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"extension_filter_to_guid",
|
||||||
|
lambda base_id, extension, method: (
|
||||||
|
"fb26cf42-7609-11f1-828f-005056b0d483",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def fake_apply(payload: dict[str, Any]) -> dict[str, Any]:
|
def fake_apply(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
calls["apply"] += 1
|
calls["apply"] += 1
|
||||||
@@ -21856,6 +21864,104 @@ def test_write_gate_context_does_not_fall_back_to_base_for_unknown_extension(
|
|||||||
assert adapter_server.repository_control.development_layer_id(result) == "extension:unresolved"
|
assert adapter_server.repository_control.development_layer_id(result) == "extension:unresolved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_gate_context_rejects_conflicting_extension_name_and_guid(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
name_guid = "fb26cf42-7609-11f1-828f-005056b0d483"
|
||||||
|
supplied_guid = "11111111-1111-1111-1111-111111111111"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"extension_filter_to_guid",
|
||||||
|
lambda base_id, extension, method: (name_guid, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = adapter_server.resolve_write_gate_context(
|
||||||
|
{
|
||||||
|
"base_id": "upo_test",
|
||||||
|
"extension_guid": supplied_guid,
|
||||||
|
"target": {
|
||||||
|
"kind": "form",
|
||||||
|
"extension": "test2",
|
||||||
|
"ref": "Catalog.test2",
|
||||||
|
"form": "t_Форма",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = result["development_layer_resolution"]
|
||||||
|
assert "extension_guid" not in result
|
||||||
|
assert "extension_guid" not in result["target"]
|
||||||
|
assert resolution["status"] == "conflict"
|
||||||
|
assert resolution["layer_id"] == "extension:unresolved"
|
||||||
|
assert resolution["extension"] == {
|
||||||
|
"name": "test2",
|
||||||
|
"supplied_guid": supplied_guid,
|
||||||
|
}
|
||||||
|
assert resolution["diagnostics"]["error"] == "extension_selector_conflict"
|
||||||
|
assert resolution["diagnostics"]["resolved_extension_guid"] == name_guid
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_apply_gate_blocks_unresolved_extension_before_policy_gates(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def must_not_run(_payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raise AssertionError("repository/support policy gates must not authorize an unresolved extension layer")
|
||||||
|
|
||||||
|
monkeypatch.setattr(adapter_server.repository_control, "write_gate", must_not_run)
|
||||||
|
monkeypatch.setattr(adapter_server, "live_sql_support_gate", must_not_run)
|
||||||
|
|
||||||
|
result = adapter_server.repository_apply_gate(
|
||||||
|
{
|
||||||
|
"base_id": "upo_test",
|
||||||
|
"development_layer_resolution": {
|
||||||
|
"status": "conflict",
|
||||||
|
"layer_id": "extension:unresolved",
|
||||||
|
"diagnostics": {"error": "extension_selector_conflict"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"metadata.write",
|
||||||
|
"apply",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "blocked"
|
||||||
|
assert result["error"] == "extension_selector_conflict"
|
||||||
|
assert result["layer_id"] == "extension:unresolved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_write_apply_blocks_conflicting_extension_name_and_guid(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"extension_filter_to_guid",
|
||||||
|
lambda base_id, extension, method: (
|
||||||
|
"fb26cf42-7609-11f1-828f-005056b0d483",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = adapter_server.metadata_write(
|
||||||
|
{
|
||||||
|
"base_id": "upo_test",
|
||||||
|
"extension_guid": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"target": {
|
||||||
|
"kind": "form",
|
||||||
|
"extension": "test2",
|
||||||
|
"ref": "Catalog.test2",
|
||||||
|
"form": "t_Форма",
|
||||||
|
"command": "ЗаменаДомена",
|
||||||
|
},
|
||||||
|
"edits": [{"property": "Заголовок", "value": "Новое имя"}],
|
||||||
|
"mode": "apply",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["schema"] == "onec_repository_write_gate.v1"
|
||||||
|
assert result["status"] == "blocked"
|
||||||
|
assert result["error"] == "extension_selector_conflict"
|
||||||
|
assert result["development_layer_resolution"]["status"] == "conflict"
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_write_preflight_routes_public_extension_form_gates_to_extension_layer(
|
def test_metadata_write_preflight_routes_public_extension_form_gates_to_extension_layer(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -21928,6 +22034,84 @@ def test_metadata_write_preflight_routes_public_extension_form_gates_to_extensio
|
|||||||
assert captured_layers == [expected_layer, expected_layer]
|
assert captured_layers == [expected_layer, expected_layer]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_write_preflight_blocks_conflicting_extension_name_and_guid(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
name_guid = "fb26cf42-7609-11f1-828f-005056b0d483"
|
||||||
|
supplied_guid = "11111111-1111-1111-1111-111111111111"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"extension_filter_to_guid",
|
||||||
|
lambda base_id, extension, method: (name_guid, None),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"metadata_form_write_target_resolve",
|
||||||
|
lambda payload: {
|
||||||
|
"status": "ok",
|
||||||
|
"source": {
|
||||||
|
"table": "ConfigCASSave",
|
||||||
|
"file_name": f"{name_guid}__form-guid.0",
|
||||||
|
},
|
||||||
|
"counts": {"matches": 1},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"metadata_saved_state_diff",
|
||||||
|
lambda payload: {
|
||||||
|
"status": "changed",
|
||||||
|
"target": {"table": payload["table"], "file_name": payload["file_name"]},
|
||||||
|
"freshness": {
|
||||||
|
"source": "live_sql",
|
||||||
|
"status": "live_sql_verified",
|
||||||
|
"verified_against_sql": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server.repository_control,
|
||||||
|
"write_gate",
|
||||||
|
lambda payload: {
|
||||||
|
"status": "not_configured",
|
||||||
|
"allowed": True,
|
||||||
|
"layer_id": adapter_server.repository_control.development_layer_id(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"live_sql_support_gate",
|
||||||
|
lambda payload: {
|
||||||
|
"status": "support_not_configured_legacy",
|
||||||
|
"allowed": True,
|
||||||
|
"layer_id": adapter_server.repository_control.development_layer_id(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = adapter_server.metadata_write_preflight(
|
||||||
|
{
|
||||||
|
"base_id": "upo_test",
|
||||||
|
"extension_guid": supplied_guid,
|
||||||
|
"target": {
|
||||||
|
"kind": "form",
|
||||||
|
"extension": "test2",
|
||||||
|
"ref": "Catalog.test2",
|
||||||
|
"form": "t_Форма",
|
||||||
|
"command": "ЗаменаДомена",
|
||||||
|
},
|
||||||
|
"edits": [{"property": "Заголовок", "value": "Новое имя"}],
|
||||||
|
"resolve_origin": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "blocked"
|
||||||
|
assert result["allowed"] is False
|
||||||
|
assert result["repository"]["allowed"] is True
|
||||||
|
assert result["support"]["allowed"] is True
|
||||||
|
assert result["development_layer_resolution"]["status"] == "conflict"
|
||||||
|
assert result["development_layer_resolution"]["diagnostics"]["error"] == "extension_selector_conflict"
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_write_plan_help_describes_name_first_form_and_module_targets() -> None:
|
def test_metadata_write_plan_help_describes_name_first_form_and_module_targets() -> None:
|
||||||
methods = {
|
methods = {
|
||||||
row["name"]: row
|
row["name"]: row
|
||||||
@@ -26459,6 +26643,14 @@ def test_metadata_write_missing_module_target_exposes_prepare_plan(monkeypatch:
|
|||||||
def test_metadata_write_missing_module_target_auto_prepares_and_retries(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_metadata_write_missing_module_target_auto_prepares_and_retries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
calls: dict[str, int] = {"search": 0}
|
calls: dict[str, int] = {"search": 0}
|
||||||
seen: dict[str, Any] = {}
|
seen: dict[str, Any] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter_server,
|
||||||
|
"extension_filter_to_guid",
|
||||||
|
lambda base_id, extension, method: (
|
||||||
|
"fb26cf42-7609-11f1-828f-005056b0d483",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def fake_search(payload: dict[str, Any]) -> dict[str, Any]:
|
def fake_search(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
calls["search"] += 1
|
calls["search"] += 1
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ def test_public_extension_form_candidate_is_name_first_and_prefers_title() -> No
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_extension_guid_changes_only_last_hex_digit() -> None:
|
||||||
|
assert (
|
||||||
|
smoke.different_extension_guid(
|
||||||
|
"extension:fb26cf42-7609-11f1-828f-005056b0d483"
|
||||||
|
)
|
||||||
|
== "fb26cf42-7609-11f1-828f-005056b0d480"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
smoke.different_extension_guid(
|
||||||
|
"extension:fb26cf42-7609-11f1-828f-005056b0d480"
|
||||||
|
)
|
||||||
|
== "fb26cf42-7609-11f1-828f-005056b0d481"
|
||||||
|
)
|
||||||
|
assert smoke.different_extension_guid("base") is None
|
||||||
|
|
||||||
|
|
||||||
def test_name_first_extension_form_preflight_rejects_base_gate_layer(monkeypatch) -> None:
|
def test_name_first_extension_form_preflight_rejects_base_gate_layer(monkeypatch) -> None:
|
||||||
def fake_rpc_call(
|
def fake_rpc_call(
|
||||||
_endpoint_url: str,
|
_endpoint_url: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user