Initial project import
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[2] / "plugins" / "1c" / "observer" / "observer_server.py"
|
||||
SPEC = importlib.util.spec_from_file_location("adapter_observer", MODULE)
|
||||
assert SPEC and SPEC.loader
|
||||
observer = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(observer)
|
||||
|
||||
|
||||
def test_summary_separates_expected_rejection_and_exception() -> None:
|
||||
events = [
|
||||
{"event": "adapter_rpc", "time": "2026-08-07T10:00:00Z", "request_id": "a", "method": "metadata.form.decode", "request": {"base_id": "upo_test", "ref": "Document.X"}, "status": "ok", "duration_ms": 100},
|
||||
{"event": "adapter_rpc", "time": "2026-08-07T10:01:00Z", "request_id": "b", "method": "metadata.form.decode", "request": {"base_id": "upo_test"}, "status": "partial", "error": "time_budget_exhausted", "duration_ms": 6000},
|
||||
{"event": "adapter_rpc", "time": "2026-08-07T10:02:00Z", "request_id": "c", "method": "modules.read", "request": {"base_id": "upo_test"}, "status": "exception", "error": "request_exception", "duration_ms": 10},
|
||||
{"event": "adapter_rpc", "time": "2026-08-07T10:03:00Z", "request_id": "d", "method": "adapter.job.get", "request": {"base_id": "upo_test"}, "status": "running", "duration_ms": 1},
|
||||
]
|
||||
result = observer.build_summary(events, 0)
|
||||
assert result["events"] == 4
|
||||
assert result["exceptions"] == 1
|
||||
assert result["p95_ms"] == 6000
|
||||
assert any(item["error"] == "time_budget_exhausted" for item in result["findings"])
|
||||
assert not any(item["status"] == "running" for item in result["findings"])
|
||||
assert "BSL" not in json.dumps(observer.event_view(events[0]), ensure_ascii=False)
|
||||
|
||||
|
||||
def test_correlations_keep_mcp_request_that_did_not_reach_rest() -> None:
|
||||
rest = [{"request_id": "same", "method": "modules.read", "status": "ok", "duration_ms": 20}]
|
||||
mcp = [{"request_id": "same", "method": "modules.read", "status": "ok", "duration_ms": 30}, {"request_id": "lost", "method": "modules.read", "status": "exception", "error": "adapter_unavailable"}]
|
||||
result = observer.correlations(rest, mcp)
|
||||
assert result[0]["correlation_status"] == "not_reached_rest"
|
||||
assert result[1]["correlation_status"] == "matched"
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONNECTOR = ROOT / "plugins" / "1c" / "connector"
|
||||
if str(CONNECTOR) not in sys.path:
|
||||
sys.path.insert(0, str(CONNECTOR))
|
||||
|
||||
import adapter_1c_server as adapter # noqa: E402
|
||||
|
||||
|
||||
def test_additional_attributes_find_reports_empty_source_not_silent_empty(monkeypatch) -> None:
|
||||
monkeypatch.setattr(adapter, "require_base_id", lambda _payload, _method: "upo_test")
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"data_read",
|
||||
lambda _payload, method: {
|
||||
"status": "ok",
|
||||
"object": {"ref": "ChartOfCharacteristicTypes.ДополнительныеРеквизитыИСведения"},
|
||||
"rows": [],
|
||||
"counts": {"rows": 0},
|
||||
"route_diagnostics": {"status": "empty_source", "source_count": 0},
|
||||
},
|
||||
)
|
||||
|
||||
result = adapter.additional_attributes_find({"base_id": "upo_test", "query": "Ответственное направление"})
|
||||
|
||||
assert result["status"] == "empty_source"
|
||||
assert result["properties"] == []
|
||||
assert result["diagnostics"]["route"]["source_count"] == 0
|
||||
|
||||
|
||||
def test_additional_attributes_storage_refuses_to_guess_storage_route() -> None:
|
||||
result = adapter.additional_attributes_storage_resolve(
|
||||
{
|
||||
"base_id": "upo_test",
|
||||
"property_ref": "0123456789ABCDEF0123456789ABCDEF",
|
||||
"owner_ref": "Catalog.СтруктурныеЕдиницы",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["status"] == "unresolved"
|
||||
assert result["storage"] is None
|
||||
assert "will not infer" in result["diagnostics"]["message"]
|
||||
|
||||
|
||||
def test_additional_attributes_storage_confirms_metadata_evidenced_route(monkeypatch) -> None:
|
||||
monkeypatch.setattr(adapter, "list_objects", lambda *_args, **_kwargs: {"objects": [{"ref": "InformationRegister.ДополнительныеСведения"}]})
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"metadata_object_attributes",
|
||||
lambda _payload: {
|
||||
"status": "ok",
|
||||
"dimensions": [
|
||||
{"name": "Объект", "type": {"kind": "reference"}},
|
||||
{"name": "Свойство", "type": {"resolved": {"kind": "ChartOfCharacteristicTypes", "name": "ДополнительныеРеквизитыИСведения"}}},
|
||||
],
|
||||
"resources": [{"name": "Значение", "type": {"kind": "composite"}}],
|
||||
},
|
||||
)
|
||||
|
||||
result = adapter.additional_attributes_storage_resolve(
|
||||
{
|
||||
"base_id": "upo_test",
|
||||
"property_ref": "0123456789ABCDEF0123456789ABCDEF",
|
||||
"owner_ref": "Catalog.СтруктурныеЕдиницы",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["status"] == "confirmed"
|
||||
assert result["storage"]["source_ref"] == "InformationRegister.ДополнительныеСведения"
|
||||
assert result["storage"]["fields"] == {"object": "Объект", "property": "Свойство", "value": "Значение"}
|
||||
assert result["scd_join"]["parameters"]["Свойство"] == "0123456789ABCDEF0123456789ABCDEF"
|
||||
|
||||
|
||||
def test_additional_attributes_storage_requires_public_inputs() -> None:
|
||||
result = adapter.additional_attributes_storage_resolve({"base_id": "upo_test", "owner_ref": "Catalog.СтруктурныеЕдиницы"})
|
||||
|
||||
assert result["status"] == "invalid_argument"
|
||||
assert result["argument"] == "property_ref"
|
||||
|
||||
|
||||
def test_chart_semantics_exposes_only_evidenced_chart_level_value_types(monkeypatch) -> None:
|
||||
monkeypatch.setattr(adapter, "config_tree_item_at_path", lambda _tree, path: {"path": path})
|
||||
monkeypatch.setattr(adapter, "public_pattern_value_type", lambda *_args, **_kwargs: {"kind": "union", "count": 2})
|
||||
|
||||
result = adapter.chart_of_characteristic_types_sql_details("upo_test", {"type": "list"}, table="Config", timeout_seconds=30)
|
||||
|
||||
assert result["allowed_value_types"] == {"kind": "union", "count": 2}
|
||||
assert result["evidence"]["config_path"] == "1.18"
|
||||
|
||||
|
||||
def test_chart_data_list_accepts_description_contains_filter(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"data_object_schema",
|
||||
lambda _payload: {
|
||||
"status": "ok",
|
||||
"base_id": "upo_test",
|
||||
"table": {"name": "_Chrc1"},
|
||||
"object": {"kind": "ChartOfCharacteristicTypes"},
|
||||
"fields": [
|
||||
{"name": "ref", "physical_name": "_IDRRef", "type": {}},
|
||||
{"name": "description", "physical_name": "_Description", "type": {}},
|
||||
{"name": "marked_for_deletion", "physical_name": "_Marked", "type": {}},
|
||||
],
|
||||
},
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_sql(_base_id, sql, params, **_kwargs):
|
||||
captured["sql"] = sql
|
||||
captured["params"] = params
|
||||
return [], None
|
||||
|
||||
monkeypatch.setattr(adapter, "data_sql_rows", fake_sql)
|
||||
result = adapter.data_read(
|
||||
{
|
||||
"base_id": "upo_test",
|
||||
"ref": "ChartOfCharacteristicTypes.Тест",
|
||||
"fields": ["ref", "description"],
|
||||
"filters": {"description": {"operator": "contains", "value": "Ответственное направление"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert "[_Description] LIKE %s" in str(captured["sql"])
|
||||
assert "%Ответственное направление%" in captured["params"]
|
||||
|
||||
|
||||
def test_password_test_mode_accepts_explicit_global_unauthenticated_profile(monkeypatch) -> None:
|
||||
monkeypatch.delenv("ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED", raising=False)
|
||||
monkeypatch.setenv("ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN", "true")
|
||||
|
||||
assert adapter.infobase_user_password_unauthenticated_test_mode() is True
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import embed_1c_code_vectors as worker # noqa: E402
|
||||
|
||||
|
||||
def test_embed_pending_code_vectors_upserts_with_precondition(monkeypatch) -> None:
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
def fake_adapter_call(
|
||||
adapter_url: str,
|
||||
method: str,
|
||||
payload: dict,
|
||||
*,
|
||||
timeout_seconds: int = 180,
|
||||
) -> dict:
|
||||
calls.append((method, payload))
|
||||
if method == "metadata.code_vector.pending":
|
||||
assert payload["embedding_model"] == "local-hashing-v1"
|
||||
assert payload["chunk_kinds"] == ["routine"]
|
||||
assert payload["max_text_chars"] == 4000
|
||||
return {
|
||||
"status": "ok",
|
||||
"chunks": [
|
||||
{
|
||||
"chunk_id": "chunk-1",
|
||||
"text_sha1": "a" * 40,
|
||||
"text": "Процедура РассчитатьНалог()",
|
||||
}
|
||||
],
|
||||
}
|
||||
if method == "metadata.code_vector.embedding.upsert":
|
||||
assert payload["chunk_id"] == "chunk-1"
|
||||
assert payload["text_sha1"] == "a" * 40
|
||||
assert payload["embedding_model"] == "local-hashing-v1"
|
||||
assert len(payload["embedding"]) == 16
|
||||
return {"status": "ok", "dimensions": 16}
|
||||
raise AssertionError(method)
|
||||
|
||||
monkeypatch.setattr(worker, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = worker.embed_pending_code_vectors(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
limit=1,
|
||||
dimensions=16,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["embedding"]["dimensions"] == 16
|
||||
assert result["counts"] == {
|
||||
"pending": 1,
|
||||
"processed": 1,
|
||||
"stored": 1,
|
||||
"conflicts": 0,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
assert [method for method, _payload in calls] == [
|
||||
"metadata.code_vector.pending",
|
||||
"metadata.code_vector.embedding.upsert",
|
||||
]
|
||||
|
||||
|
||||
def test_embed_pending_code_vectors_dry_run_does_not_upsert(monkeypatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_adapter_call(
|
||||
adapter_url: str,
|
||||
method: str,
|
||||
payload: dict,
|
||||
*,
|
||||
timeout_seconds: int = 180,
|
||||
) -> dict:
|
||||
calls.append(method)
|
||||
return {
|
||||
"status": "ok",
|
||||
"chunks": [
|
||||
{
|
||||
"chunk_id": "chunk-1",
|
||||
"text_sha1": "b" * 40,
|
||||
"text": "Функция НайтиОбъект()",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(worker, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = worker.embed_pending_code_vectors(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
dimensions=8,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result["counts"]["processed"] == 1
|
||||
assert result["counts"]["stored"] == 0
|
||||
assert result["upserts"][0]["status"] == "dry_run"
|
||||
assert calls == ["metadata.code_vector.pending"]
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import search_1c_code_vectors as search_cli # noqa: E402
|
||||
|
||||
|
||||
def test_search_code_vectors_sends_model_and_strict_query_embedding(monkeypatch) -> None:
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
search_cli,
|
||||
"embed_texts",
|
||||
lambda texts, **kwargs: [[1.0, 0.0, 0.0]],
|
||||
)
|
||||
|
||||
def fake_adapter_call(
|
||||
adapter_url: str,
|
||||
method: str,
|
||||
payload: dict,
|
||||
*,
|
||||
timeout_seconds: int = 180,
|
||||
) -> dict:
|
||||
calls.append((method, payload))
|
||||
return {"status": "ok", "matches": []}
|
||||
|
||||
monkeypatch.setattr(search_cli, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = search_cli.search_code_vectors(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
query="где рассчитывается налог",
|
||||
embedding_provider="openai-compatible",
|
||||
embedding_model="test-code-model",
|
||||
embedding_base_url="http://embeddings",
|
||||
dimensions=3,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["client_embedding"]["stored_embedding_model"] == "openai-compatible:test-code-model@d3"
|
||||
assert calls == [
|
||||
(
|
||||
"metadata.code_vector.search",
|
||||
{
|
||||
"base_id": "upo_test",
|
||||
"query": "где рассчитывается налог",
|
||||
"query_embedding": [1.0, 0.0, 0.0],
|
||||
"embedding_model": "openai-compatible:test-code-model@d3",
|
||||
"limit": 10,
|
||||
"scan_limit": 2000,
|
||||
"verify": True,
|
||||
"strict": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_qwen3_search_uses_query_instruction_but_keeps_lexical_query_plain(monkeypatch) -> None:
|
||||
embedded_texts: list[str] = []
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
def fake_embed_texts(texts: list[str], **kwargs) -> list[list[float]]:
|
||||
embedded_texts.extend(texts)
|
||||
return [[0.5, 0.5]]
|
||||
|
||||
monkeypatch.setattr(search_cli, "embed_texts", fake_embed_texts)
|
||||
|
||||
def fake_adapter_call(
|
||||
adapter_url: str,
|
||||
method: str,
|
||||
payload: dict,
|
||||
*,
|
||||
timeout_seconds: int = 180,
|
||||
) -> dict:
|
||||
calls.append((method, payload))
|
||||
return {"status": "ok", "matches": []}
|
||||
|
||||
monkeypatch.setattr(search_cli, "adapter_call", fake_adapter_call)
|
||||
|
||||
result = search_cli.search_code_vectors(
|
||||
adapter_url="http://adapter/rpc",
|
||||
base_id="upo_test",
|
||||
query="обработка проведения документа",
|
||||
embedding_provider="openai-compatible",
|
||||
embedding_model="qwen3-embedding-0.6b",
|
||||
embedding_base_url="http://embeddings",
|
||||
)
|
||||
|
||||
assert embedded_texts == [
|
||||
"Instruct: Given a natural-language software task, retrieve the relevant "
|
||||
"1C Enterprise BSL source-code fragment that implements or explains it\n"
|
||||
"Query:обработка проведения документа"
|
||||
]
|
||||
assert calls[0][1]["query"] == "обработка проведения документа"
|
||||
assert result["client_embedding"]["query_instruction"] == (
|
||||
search_cli.DEFAULT_QWEN3_CODE_RETRIEVAL_INSTRUCTION
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Regression checks for SQL saved-state → Configurator refresh guidance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector"
|
||||
if str(CONNECTOR) not in sys.path:
|
||||
sys.path.insert(0, str(CONNECTOR))
|
||||
|
||||
import adapter_1c_server as adapter # noqa: E402
|
||||
|
||||
|
||||
EXTENSION_FILE = "fb26cf42-7609-11f1-828f-005056b0d483__77494708-43ea-4956-ac3c-199cfb035ad2.0"
|
||||
|
||||
|
||||
def test_existing_extension_saved_object_reopens_object() -> None:
|
||||
result = adapter.saved_state_configurator_refresh_guidance(
|
||||
table="ConfigCASSave", file_name=EXTENSION_FILE, object_existed_in_saved_state=True
|
||||
)
|
||||
assert result["scope"] == "object"
|
||||
assert result["action"] == "close_reopen_object"
|
||||
|
||||
|
||||
def test_new_extension_saved_object_reopens_extension() -> None:
|
||||
result = adapter.saved_state_configurator_refresh_guidance(
|
||||
table="ConfigCASSave", file_name=EXTENSION_FILE, object_existed_in_saved_state=False
|
||||
)
|
||||
assert result["scope"] == "extension"
|
||||
assert result["action"] == "close_reopen_extension"
|
||||
|
||||
|
||||
def test_new_base_saved_object_reopens_configuration() -> None:
|
||||
result = adapter.saved_state_configurator_refresh_guidance(
|
||||
table="ConfigSave", file_name="77494708-43ea-4956-ac3c-199cfb035ad2.0", object_existed_in_saved_state=False
|
||||
)
|
||||
assert result["scope"] == "configuration"
|
||||
assert result["action"] == "close_reopen_configuration"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Extension CommonForm decoding must use the manifest payload route, not its GUID as a file name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector"
|
||||
if str(CONNECTOR) not in sys.path:
|
||||
sys.path.insert(0, str(CONNECTOR))
|
||||
|
||||
import adapter_1c_server as adapter # noqa: E402
|
||||
|
||||
|
||||
def test_common_form_uses_extension_manifest_route(monkeypatch) -> None:
|
||||
form_guid = "e7517ee6-7e33-40ba-9442-48c78e0818e1"
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"extension_objects_find",
|
||||
lambda _payload: {
|
||||
"status": "ok",
|
||||
"objects": [{
|
||||
"kind": "CommonForm",
|
||||
"name": "ФормаОтчетаПродкорзина",
|
||||
"guid": form_guid,
|
||||
"route": {"table": "ConfigCAS", "file_name": "manifest-hash-key"},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
def fake_read(_base_id, table, file_name, **_kwargs):
|
||||
captured["table"] = table
|
||||
captured["file_name"] = file_name
|
||||
return None, None, {"status": "source_missing", "diagnostics": {"message": "fixture stop"}}
|
||||
|
||||
monkeypatch.setattr(adapter, "read_storage_file_bytes", fake_read)
|
||||
|
||||
result = adapter.metadata_form_decode(
|
||||
{
|
||||
"base_id": "upo_test",
|
||||
"kind": "CommonForm",
|
||||
"extension": "фс_Отчеты1",
|
||||
"name": "ФормаОтчетаПродкорзина",
|
||||
"guid": form_guid,
|
||||
"source_state": "active",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["status"] == "source_missing"
|
||||
assert captured == {"table": "ConfigCAS", "file_name": "manifest-hash-key"}
|
||||
|
||||
|
||||
def test_static_form_structure_marks_unproven_tree_edges() -> None:
|
||||
structure = adapter.form_static_structure(
|
||||
{"items": [{"id": "1", "name": "Настройки", "type_name": "Группа", "path": "1.7", "depth": 2}]}
|
||||
)
|
||||
assert structure["status"] == "partial"
|
||||
assert structure["elements"][0]["parent"]["status"] == "unresolved"
|
||||
assert structure["elements"][0]["children"]["status"] == "unresolved"
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Pure regression fixtures for the proven ConfigInfo file-SHA1 map rewrite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONNECTOR = ROOT / "plugins" / "1c" / "connector"
|
||||
PARSER = ROOT / "plugins" / "1c"
|
||||
for path in (CONNECTOR, PARSER):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
import adapter_1c_server as adapter # noqa: E402
|
||||
from parser.payload import decode_payload_lossless # noqa: E402
|
||||
|
||||
|
||||
LOGICAL = "77494708-43ea-4956-ac3c-199cfb035ad2.0"
|
||||
|
||||
|
||||
def raw_deflate(text: str) -> bytes:
|
||||
encoder = zlib.compressobj(wbits=-zlib.MAX_WBITS)
|
||||
return encoder.compress(text.encode("utf-8")) + encoder.flush()
|
||||
|
||||
|
||||
def test_rewrites_only_named_file_sha1_reference() -> None:
|
||||
original_file = b"old module bytes"
|
||||
replacement_file = b"new module bytes"
|
||||
old_ref = base64.b64encode(hashlib.sha1(original_file).digest()).decode("ascii")
|
||||
service_atom = base64.b64encode(b"S" * 32).decode("ascii")
|
||||
source_text = f'{{2,extension,{service_atom}}},{{7,"{LOGICAL}",{old_ref},"other.0",{old_ref}}}'
|
||||
result = adapter.rewrite_extension_configinfo_file_sha1_reference(
|
||||
raw_deflate(source_text), logical_file_name=LOGICAL, replacement_file_data=replacement_file
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert result["replacement_file_sha1"] == hashlib.sha1(replacement_file).hexdigest()
|
||||
decoded = str(decode_payload_lossless(result["payload"]).get("text") or "")
|
||||
assert service_atom in decoded
|
||||
assert result["replacement_reference_base64"] in decoded
|
||||
assert decoded.count(old_ref) == 1 # unrelated map entry was preserved
|
||||
|
||||
|
||||
def test_rejects_ambiguous_logical_reference() -> None:
|
||||
ref = base64.b64encode(hashlib.sha1(b"old").digest()).decode("ascii")
|
||||
source_text = f'{{7,"{LOGICAL}",{ref},"{LOGICAL}",{ref}}}'
|
||||
result = adapter.rewrite_extension_configinfo_file_sha1_reference(
|
||||
raw_deflate(source_text), logical_file_name=LOGICAL, replacement_file_data=b"new"
|
||||
)
|
||||
assert result["status"] == "reference_not_unique"
|
||||
assert result["occurrences"] == 2
|
||||
|
||||
|
||||
def test_builds_paired_plan_only_when_map_matches_current_stream() -> None:
|
||||
current = b"current module"
|
||||
replacement = b"replacement module"
|
||||
current_ref = base64.b64encode(hashlib.sha1(current).digest()).decode("ascii")
|
||||
configinfo = raw_deflate(f'{{7,"{LOGICAL}",{current_ref}}}')
|
||||
result = adapter.build_extension_saved_state_pair_plan(
|
||||
logical_file_name=LOGICAL,
|
||||
current_file_data=current,
|
||||
replacement_file_data=replacement,
|
||||
current_configinfo_data=configinfo,
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert result["module"]["replacement_sha1"] == hashlib.sha1(replacement).hexdigest()
|
||||
assert result["configinfo"]["service_atoms"] == "preserved_unmodified"
|
||||
|
||||
|
||||
def test_rejects_pair_when_map_points_to_another_stream() -> None:
|
||||
mapped = b"other module"
|
||||
current = b"current module"
|
||||
mapped_ref = base64.b64encode(hashlib.sha1(mapped).digest()).decode("ascii")
|
||||
configinfo = raw_deflate(f'{{7,"{LOGICAL}",{mapped_ref}}}')
|
||||
result = adapter.build_extension_saved_state_pair_plan(
|
||||
logical_file_name=LOGICAL,
|
||||
current_file_data=current,
|
||||
replacement_file_data=b"replacement",
|
||||
current_configinfo_data=configinfo,
|
||||
)
|
||||
assert result["status"] == "configinfo_reference_mismatch"
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, state: dict[str, bytes]) -> None:
|
||||
self.state = state
|
||||
self.rows: list[dict[str, object]] = []
|
||||
|
||||
def execute(self, sql: str, params: tuple[object, ...] | None = None) -> None:
|
||||
if sql.startswith("SET LOCK_TIMEOUT"):
|
||||
return
|
||||
if sql.startswith("SELECT FileName, PartNo, BinaryData"):
|
||||
assert params is not None
|
||||
self.rows = [
|
||||
{"FileName": name, "PartNo": 0, "BinaryData": self.state[name]}
|
||||
for name in params
|
||||
if name in self.state
|
||||
]
|
||||
return
|
||||
if sql.startswith("UPDATE dbo.ConfigCASSave"):
|
||||
assert params is not None
|
||||
data, _size, name, _part = params
|
||||
self.state[str(name)] = bytes(data)
|
||||
return
|
||||
raise AssertionError(sql)
|
||||
|
||||
def fetchall(self) -> list[dict[str, object]]:
|
||||
return self.rows
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, state: dict[str, bytes]) -> None:
|
||||
self.cursor_instance = FakeCursor(state)
|
||||
self.committed = False
|
||||
self.rolled_back = False
|
||||
self.closed = False
|
||||
|
||||
def cursor(self, **_kwargs: object) -> FakeCursor:
|
||||
return self.cursor_instance
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def install_fake_pair_sql(monkeypatch: object, state: dict[str, bytes], connection: FakeConnection) -> None:
|
||||
monkeypatch.setattr(adapter, "connect_live_sql", lambda *_args, **_kwargs: (connection, {"database": "test"}, None))
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"read_storage_file_bytes",
|
||||
lambda _base, _table, name, **_kwargs: (state.get(name), {"database": "test"}, None if name in state else {"status": "missing"}),
|
||||
)
|
||||
monkeypatch.setattr(adapter, "write_storage_apply_backup", lambda **_kwargs: {"backup_id": "test"})
|
||||
monkeypatch.setattr(adapter, "invalidate_adapter_caches_after_saved_state_change", lambda *_args, **_kwargs: {"invalidated": True})
|
||||
|
||||
|
||||
def test_atomically_updates_module_and_configinfo(monkeypatch: object) -> None:
|
||||
extension = "fb26cf42-7609-11f1-828f-005056b0d483"
|
||||
module_name = f"{extension}__{LOGICAL}"
|
||||
configinfo_name = f"{extension}__configinfo"
|
||||
current = b"current module"
|
||||
current_ref = base64.b64encode(hashlib.sha1(current).digest()).decode("ascii")
|
||||
state = {module_name: current, configinfo_name: raw_deflate(f'{{7,"{LOGICAL}",{current_ref}}}')}
|
||||
connection = FakeConnection(state)
|
||||
install_fake_pair_sql(monkeypatch, state, connection)
|
||||
|
||||
result = adapter.apply_extension_saved_state_pair(
|
||||
"upo_test",
|
||||
module_file_name=module_name,
|
||||
replacement_module_data=b"new module",
|
||||
expected_module_sha1=hashlib.sha1(current).hexdigest(),
|
||||
proposal={"method": "test"},
|
||||
)
|
||||
|
||||
assert result["status"] == "applied"
|
||||
assert result["applied"] is True
|
||||
assert connection.committed is True
|
||||
assert result["configurator_refresh"]["action"] == "close_reopen_object"
|
||||
map_text = str(decode_payload_lossless(state[configinfo_name]).get("text") or "")
|
||||
assert base64.b64encode(hashlib.sha1(b"new module").digest()).decode("ascii") in map_text
|
||||
|
||||
|
||||
def test_atomic_writer_rolls_back_when_configinfo_map_diverges(monkeypatch: object) -> None:
|
||||
extension = "fb26cf42-7609-11f1-828f-005056b0d483"
|
||||
module_name = f"{extension}__{LOGICAL}"
|
||||
configinfo_name = f"{extension}__configinfo"
|
||||
current = b"current module"
|
||||
wrong_ref = base64.b64encode(hashlib.sha1(b"other module").digest()).decode("ascii")
|
||||
state = {module_name: current, configinfo_name: raw_deflate(f'{{7,"{LOGICAL}",{wrong_ref}}}')}
|
||||
connection = FakeConnection(state)
|
||||
install_fake_pair_sql(monkeypatch, state, connection)
|
||||
|
||||
result = adapter.apply_extension_saved_state_pair(
|
||||
"upo_test",
|
||||
module_file_name=module_name,
|
||||
replacement_module_data=b"new module",
|
||||
expected_module_sha1=hashlib.sha1(current).hexdigest(),
|
||||
proposal={"method": "test"},
|
||||
)
|
||||
|
||||
assert result["status"] == "configinfo_reference_mismatch"
|
||||
assert result["applied"] is False
|
||||
assert connection.rolled_back is True
|
||||
assert state[module_name] == current
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Public MCP selectors must not leak storage coordinates to coding agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MCP = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "mcp"
|
||||
if str(MCP) not in sys.path:
|
||||
sys.path.insert(0, str(MCP))
|
||||
|
||||
import adapter_1c_mcp as mcp # noqa: E402
|
||||
|
||||
|
||||
def test_read_selector_is_replaced_with_opaque_token() -> None:
|
||||
mcp.SELECTOR_TOKENS.clear()
|
||||
result = mcp.publicize_read_selectors(
|
||||
{
|
||||
"read_selector": {
|
||||
"method": "metadata.form.decode",
|
||||
"base_id": "upo_test",
|
||||
"table": "ConfigCAS",
|
||||
"file_name": "hash-key",
|
||||
"guid": "11111111-1111-1111-1111-111111111111",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
selector = result["read_selector"]
|
||||
assert selector["method"] == "metadata.form.decode"
|
||||
assert selector["selector_token"].startswith("onecsel_")
|
||||
assert "table" not in selector
|
||||
assert "file_name" not in selector
|
||||
|
||||
payload, error = mcp.resolve_selector_token("metadata.form.decode", {"selector_token": selector["selector_token"]})
|
||||
assert error is None
|
||||
assert payload is not None
|
||||
assert payload["table"] == "ConfigCAS"
|
||||
assert payload["file_name"] == "hash-key"
|
||||
assert payload["_selector_token_resolved"] is True
|
||||
|
||||
|
||||
def test_public_result_hides_nested_technical_fields() -> None:
|
||||
result = mcp.publicize_read_selectors(
|
||||
{"object": {"name": "Тест", "guid": "private"}, "source": {"table": "ConfigSave"}}
|
||||
)
|
||||
assert result == {"object": {"name": "Тест"}, "source": {}}
|
||||
|
||||
|
||||
def test_selector_token_cannot_be_reused_for_another_method() -> None:
|
||||
token = mcp.issue_selector_token({"method": "code.read", "base_id": "upo_test", "module_ref": "private"})
|
||||
payload, error = mcp.resolve_selector_token("metadata.form.decode", {"selector_token": token})
|
||||
assert payload is None
|
||||
assert error is not None
|
||||
assert error["error"] == "selector_token_method_mismatch"
|
||||
|
||||
|
||||
def test_normal_agent_request_rejects_technical_coordinates() -> None:
|
||||
blocked = mcp.normal_agent_technical_field_guardrail(
|
||||
"metadata.form.decode",
|
||||
{"base_id": "upo_test", "table": "ConfigCASSave", "file_name": "private"},
|
||||
)
|
||||
assert blocked is not None
|
||||
assert blocked["reason"] == "technical_selector_forbidden"
|
||||
assert blocked["diagnostics"]["fields"] == ["file_name", "table"]
|
||||
|
||||
|
||||
def test_normal_agent_request_rejects_nested_technical_coordinates() -> None:
|
||||
blocked = mcp.normal_agent_technical_field_guardrail(
|
||||
"metadata.form.decode",
|
||||
{"base_id": "upo_test", "selector": {"table": "ConfigCASSave"}},
|
||||
)
|
||||
assert blocked is not None
|
||||
assert blocked["diagnostics"]["fields"] == ["table"]
|
||||
|
||||
|
||||
def test_diagnostic_request_may_keep_technical_coordinates(monkeypatch) -> None:
|
||||
monkeypatch.setenv("ONEC_MCP_ALLOW_DIAGNOSTIC", "true")
|
||||
assert mcp.normal_agent_technical_field_guardrail(
|
||||
"metadata.form.decode",
|
||||
{"base_id": "upo_test", "table": "ConfigCASSave", "diagnostic": True},
|
||||
) is None
|
||||
|
||||
|
||||
def test_form_methods_receive_effective_working_state() -> None:
|
||||
payload = mcp.apply_freshness_request_policy(
|
||||
{"base_id": "upo_test", "ref": "ОбщаяФорма.Тест"},
|
||||
"metadata.form.decode",
|
||||
)
|
||||
assert payload["configuration_view"] == "effective_working"
|
||||
assert payload["source_state"] == "working"
|
||||
assert payload["state"] == "working"
|
||||
|
||||
|
||||
def test_runtime_form_inspection_is_explicitly_unsupported() -> None:
|
||||
result = mcp.runtime_form_inspection_unsupported(
|
||||
"runtime.form.elements.inspect",
|
||||
{"base_id": "upo_test"},
|
||||
)
|
||||
assert result is not None
|
||||
assert result["error"] == "runtime_inspection_unsupported"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Component graph must expose only children confirmed by its source readers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector"
|
||||
if str(CONNECTOR) not in sys.path:
|
||||
sys.path.insert(0, str(CONNECTOR))
|
||||
|
||||
import adapter_1c_server as adapter # noqa: E402
|
||||
|
||||
|
||||
def test_components_aggregate_confirmed_children_without_inference(monkeypatch) -> None:
|
||||
object_card = {"ref": "Report.Sales", "kind": "Report", "name": "Sales", "guid": "a" * 36}
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"metadata_object_modules",
|
||||
lambda _: {"status": "ok", "method": "metadata.object.modules", "object": object_card,
|
||||
"modules": [{"role": "object_module", "name": "Модуль объекта", "path": "Report.Sales.МодульОбъекта"}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"metadata_object_forms",
|
||||
lambda _: {"status": "ok", "method": "metadata.object.forms", "object": object_card,
|
||||
"forms": [{"name": "Main", "guid": "b" * 36}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
adapter,
|
||||
"metadata_object_templates",
|
||||
lambda _: {"status": "ok", "method": "metadata.object.templates", "object": object_card,
|
||||
"templates": [{"name": "ОсновнаяСхемаКомпоновкиДанных", "guid": "c" * 36, "kind": "DataCompositionSchema"}]},
|
||||
)
|
||||
|
||||
result = adapter.metadata_object_components(
|
||||
{"base_id": "upo_test", "extension": "test2", "ref": "Report.Sales", "include_storage": False}
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert [item["component_kind"] for item in result["components"]] == ["metadata_object", "module", "form", "scd"]
|
||||
assert result["components"][2]["read_selector"]["method"] == "metadata.object.form.details"
|
||||
assert result["components"][3]["read_selector"]["method"] == "scd.inspect"
|
||||
assert result["components"][3]["read_selector"]["params"]["extension"] == "test2"
|
||||
assert result["unresolved"] == []
|
||||
|
||||
|
||||
def test_components_marks_a_failed_area_instead_of_creating_a_child(monkeypatch) -> None:
|
||||
object_card = {"ref": "CommonForm.t_Форма", "kind": "CommonForm", "name": "t_Форма"}
|
||||
monkeypatch.setattr(adapter, "metadata_object_modules", lambda _: {"status": "ok", "object": object_card, "modules": []})
|
||||
monkeypatch.setattr(adapter, "metadata_object_forms", lambda _: {"status": "ok", "object": object_card, "forms": []})
|
||||
monkeypatch.setattr(adapter, "metadata_object_templates", lambda _: {"status": "unsupported", "error": "not_decoded", "templates": []})
|
||||
|
||||
result = adapter.metadata_object_components({"base_id": "upo_test", "ref": "CommonForm.t_Форма"})
|
||||
|
||||
assert [item["component_kind"] for item in result["components"]] == ["metadata_object"]
|
||||
assert result["unresolved"] == [{"area": "templates", "method": "metadata.object.templates", "status": "unsupported", "error": "not_decoded"}]
|
||||
@@ -562,7 +562,7 @@ def test_adapter_live_methods_require_base_id(onec_agent_server: dict[str, str])
|
||||
assert status == 400
|
||||
assert is_error
|
||||
assert turn_data["error"]["code"] == "adapter_error"
|
||||
assert "requires params.base_id" in turn_data["error"]["message"]
|
||||
assert "forbidden technical fields: table" in turn_data["error"]["message"]
|
||||
|
||||
audit_root = Path(onec_agent_server["audit_root"])
|
||||
turn_files = sorted((audit_root / "turn_audit").glob("*.jsonl"))
|
||||
@@ -572,10 +572,89 @@ def test_adapter_live_methods_require_base_id(onec_agent_server: dict[str, str])
|
||||
record["outcome"] == "failure"
|
||||
and record["failure_type"] == "adapter_error"
|
||||
and record["error_code"] == "adapter_error"
|
||||
and "requires params.base_id" in str(record["error_message"])
|
||||
and "forbidden technical fields: table" in str(record["error_message"])
|
||||
for record in turn_records
|
||||
)
|
||||
|
||||
|
||||
def test_agent_adapter_policy_uses_effective_view_and_blocks_storage_selectors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
prepared = onec_agent.prepare_agent_adapter_call(
|
||||
"metadata.object.full",
|
||||
{"base_id": "upo_test", "name": "Отчет.Продажи"},
|
||||
)
|
||||
assert prepared["configuration_view"] == "effective_working"
|
||||
assert prepared["source_state"] == "working"
|
||||
|
||||
with pytest.raises(ValueError, match="forbidden technical fields: table"):
|
||||
onec_agent.prepare_agent_adapter_call(
|
||||
"metadata.saved_state.forms.search",
|
||||
{"base_id": "upo_test", "table": "ConfigSave"},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="forbidden technical fields: module_ref"):
|
||||
onec_agent.prepare_agent_adapter_call(
|
||||
"code.read",
|
||||
{"base_id": "upo_test", "selector": {"module_ref": "private"}},
|
||||
)
|
||||
|
||||
monkeypatch.setenv("ONEC_AGENT_ALLOW_DIAGNOSTIC", "true")
|
||||
assert onec_agent.prepare_agent_adapter_call(
|
||||
"metadata.saved_state.forms.search",
|
||||
{"base_id": "upo_test", "table": "ConfigSave"},
|
||||
)["table"] == "ConfigSave"
|
||||
|
||||
|
||||
def test_agent_calls_adapter_through_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
requests: list[tuple[dict[str, Any], dict[str, str], str]] = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, body: dict[str, Any], headers: dict[str, str] | None = None) -> None:
|
||||
self._body = json.dumps(body).encode("utf-8")
|
||||
self.headers = headers or {}
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def fake_urlopen(request: Request, timeout: float):
|
||||
body = json.loads(request.data.decode("utf-8"))
|
||||
requests.append((body, dict(request.headers), request.full_url))
|
||||
if body["method"] == "initialize":
|
||||
return FakeResponse({"jsonrpc": "2.0", "id": body["id"], "result": {}}, {"Mcp-Session-Id": "test-session"})
|
||||
return FakeResponse(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": body["id"],
|
||||
"result": {"content": [{"type": "text", "text": json.dumps({"status": "ok"})}]},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(onec_agent, "urlopen", fake_urlopen)
|
||||
result = onec_agent.call_adapter("metadata.object.forms", {"base_id": "upo_test", "ref": "Report.Тест"})
|
||||
|
||||
assert result == {"status": "ok"}
|
||||
assert len(requests) == 2
|
||||
assert requests[0][0]["method"] == "initialize"
|
||||
assert requests[1][0]["method"] == "tools/call"
|
||||
assert requests[1][0]["params"]["name"] == "onec_request"
|
||||
assert requests[1][0]["params"]["arguments"]["payload"]["source_state"] == "working"
|
||||
assert requests[1][2] == "http://docker.cin.su:8021/mcp"
|
||||
|
||||
|
||||
def test_agent_system_prompt_requires_mcp_name_first_navigation() -> None:
|
||||
prompt = onec_agent.DEFAULT_SYSTEM_PROMPT_PATH.read_text(encoding="utf-8")
|
||||
assert "только инструментом MCP `onec_request`" in prompt
|
||||
assert "source_state=working" in prompt
|
||||
assert "Не проси и не подставляй GUID либо `module_ref`" in prompt
|
||||
|
||||
|
||||
def test_get_chat_runtime(onec_agent_server: dict[str, str]) -> None:
|
||||
url = onec_agent_server["url"]
|
||||
|
||||
|
||||
+6055
-59
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -50,6 +51,80 @@ def configured_layers(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json"))
|
||||
|
||||
|
||||
def test_repositoryless_base_inherits_test_write_profile_to_discovered_extension(monkeypatch, tmp_path: Path) -> None:
|
||||
config = {
|
||||
"upo_test": {
|
||||
"server": "sql", "database": "upo_test", "user": "reader",
|
||||
"development_layers": {
|
||||
"base": {
|
||||
"repository": {"mode": "none", "connection_state": "not_configured"},
|
||||
"support": {"mode": "unknown"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
extension_guid = "11111111-2222-3333-4444-555555555555"
|
||||
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(config))
|
||||
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json"))
|
||||
|
||||
payload = {"base_id": "upo_test", "extension_guid": extension_guid}
|
||||
repository, repository_error = repository_control.repository_config("upo_test", f"extension:{extension_guid}")
|
||||
|
||||
assert repository_error is None
|
||||
assert repository["mode"] == "none"
|
||||
assert repository["inherited_from_layer"] == "base"
|
||||
assert repository_control.write_gate(payload)["allowed"] is True
|
||||
support = repository_control.support_gate(payload)
|
||||
assert support["allowed"] is True
|
||||
assert support["status"] == "not_on_support_inherited_no_repository"
|
||||
|
||||
|
||||
def test_repository_policy_is_not_inherited_from_real_base_to_extension(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_layers(monkeypatch, tmp_path)
|
||||
|
||||
repository, error = repository_control.repository_config(
|
||||
"base", "extension:99999999-2222-3333-4444-555555555555"
|
||||
)
|
||||
|
||||
assert repository is None
|
||||
assert error["status"] == "layer_not_configured"
|
||||
|
||||
|
||||
def test_repository_state_migrates_once_to_local_sqlite(monkeypatch, tmp_path: Path) -> None:
|
||||
legacy_path = tmp_path / "locks.json"
|
||||
state_path = tmp_path / "adapter-state.sqlite"
|
||||
legacy = {
|
||||
"requests": {
|
||||
"rreq-legacy": {
|
||||
"base_id": "base",
|
||||
"layer_id": "base",
|
||||
"status": "cancelled",
|
||||
"created_at": 1.0,
|
||||
}
|
||||
},
|
||||
"sessions": {},
|
||||
"audit": [{"event": "legacy_event", "time": 1.0, "base_id": "base"}],
|
||||
}
|
||||
legacy_path.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
original = legacy_path.read_bytes()
|
||||
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(legacy_path))
|
||||
monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(state_path))
|
||||
|
||||
state = repository_control._read_state()
|
||||
|
||||
assert state["requests"]["rreq-legacy"]["status"] == "cancelled"
|
||||
assert legacy_path.read_bytes() == original
|
||||
with sqlite3.connect(state_path) as conn:
|
||||
assert conn.execute(
|
||||
"SELECT value FROM adapter_state_meta WHERE key='legacy_repository_state_imported'"
|
||||
).fetchone()[0] == "1"
|
||||
assert int(conn.execute(
|
||||
"SELECT value FROM adapter_state_meta WHERE key='adapter_state_schema_version'"
|
||||
).fetchone()[0]) >= 2
|
||||
assert conn.execute("SELECT COUNT(*) FROM repository_lock_requests").fetchone()[0] == 1
|
||||
assert conn.execute("SELECT COUNT(*) FROM repository_lock_events").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_repository_backend_and_endpoint_come_only_from_base_settings(monkeypatch, tmp_path: Path) -> None:
|
||||
configured_base(monkeypatch, tmp_path)
|
||||
config, error = repository_control.repository_config("base")
|
||||
@@ -70,6 +145,89 @@ def test_http_runner_profile_does_not_require_local_designer_or_credentials(monk
|
||||
assert "designer_path" not in configured
|
||||
|
||||
|
||||
def test_activation_debug_probe_calls_dedicated_http_endpoint_without_execution(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured = {}
|
||||
|
||||
class Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "ready",
|
||||
"runner": {
|
||||
"kind": "local",
|
||||
"reachable": True,
|
||||
"designer_available": True,
|
||||
"infobase_selector_configured": True,
|
||||
},
|
||||
"operation": {
|
||||
"kind": "/UpdateDBCfg",
|
||||
"execution_supported": False,
|
||||
},
|
||||
"execution": {
|
||||
"mode": "debug",
|
||||
"performed": False,
|
||||
"designer_started": False,
|
||||
},
|
||||
"debug_acceptance": {
|
||||
"accepted": True,
|
||||
"request_id": "actreq-" + "1" * 32,
|
||||
"fingerprint": "a" * 64,
|
||||
"receipt": "b" * 64,
|
||||
},
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
captured["url"] = request.full_url
|
||||
captured["body"] = json.loads(request.data.decode("utf-8"))
|
||||
captured["authorization"] = request.headers.get("Authorization")
|
||||
captured["timeout"] = timeout
|
||||
return Response()
|
||||
|
||||
monkeypatch.setenv("ACTIVATION_RUNNER_TOKEN", "runner-secret")
|
||||
monkeypatch.setattr(repository_control.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
result = repository_control.activation_debug_probe(
|
||||
"upo_test",
|
||||
{
|
||||
"runner": {
|
||||
"kind": "http",
|
||||
"url": "http://runner:8121",
|
||||
"token_env": "ACTIVATION_RUNNER_TOKEN",
|
||||
}
|
||||
},
|
||||
layer="base_saved_state",
|
||||
timeout_seconds=7,
|
||||
request_id="actreq-" + "1" * 32,
|
||||
fingerprint="a" * 64,
|
||||
)
|
||||
|
||||
assert result["status"] == "ready"
|
||||
assert captured == {
|
||||
"url": "http://runner:8121/configuration/activation/debug",
|
||||
"body": {
|
||||
"base_id": "upo_test",
|
||||
"layer": "base_saved_state",
|
||||
"mode": "debug",
|
||||
"request_id": "actreq-" + "1" * 32,
|
||||
"fingerprint": "a" * 64,
|
||||
},
|
||||
"authorization": "Bearer runner-secret",
|
||||
"timeout": 7,
|
||||
}
|
||||
assert result["execution"]["performed"] is False
|
||||
assert result["execution"]["designer_started"] is False
|
||||
assert result["debug_acceptance"]["receipt"] == "b" * 64
|
||||
|
||||
|
||||
def test_lock_plan_maps_child_metadata_to_development_owner() -> None:
|
||||
result = repository_control.lock_plan({"object": "РегистрСведений.Настройки.Реквизит.Код"})
|
||||
assert result["status"] == "ready"
|
||||
@@ -121,6 +279,9 @@ def test_manual_lock_request_stays_pending_until_user_confirms_exact_saved_scope
|
||||
}
|
||||
pending = repository_control.lock_request_status({"request_id": requested["request_id"]})
|
||||
assert pending["status"] == "pending_user_lock"
|
||||
assert pending["base_id"] == "base"
|
||||
assert pending["objects"] == ["РегистрСведений.Настройки"]
|
||||
assert pending["native_lock_state"] == "unknown"
|
||||
assert pending["next_call"] == requested["next_call"]
|
||||
|
||||
incomplete = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"]})
|
||||
@@ -494,6 +655,9 @@ def test_confirm_write_context_can_be_forwarded_under_repository_lock() -> None:
|
||||
assert context["lock_session_id"] == "rlock-test"
|
||||
assert context["repository_object"] == "Document.тл_Планировщик.Form.ФормаДокументаНовая"
|
||||
assert context["layer_id"] == "base"
|
||||
forwarded = adapter_1c_server.repository_write_context(context)
|
||||
assert forwarded["layer_id"] == "base"
|
||||
assert forwarded["lock_session_id"] == "rlock-test"
|
||||
|
||||
_context, conflict = adapter_1c_server.normalize_repository_write_context({
|
||||
"lock_session_id": "rlock-one",
|
||||
|
||||
@@ -46,9 +46,9 @@ def test_designer_sql_decoding_policy_keeps_adapter_read_only() -> None:
|
||||
service = yaml.safe_load((CONNECTOR / "service.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
assert policy["scope"]["default_base_id"] == "upo_test"
|
||||
assert policy["scope"]["adapter_role"] == "sql_observer_and_decoder"
|
||||
assert policy["scope"]["adapter_role"] == "specification_bound_sql_decoder_and_controlled_saved_state_writer"
|
||||
assert policy["credentials"]["persistence"] == "forbidden_in_repository"
|
||||
assert policy["sql_observation"]["adapter_access"] == "read_only"
|
||||
assert policy["sql_observation"]["adapter_access"] == "sql_only"
|
||||
assert "direct_application_data_write" in policy["sql_observation"]["forbidden"]
|
||||
assert policy["xml"]["role"] == "offline_schema_reference_only"
|
||||
assert "policies/designer-sql-decoding-policy.yaml" in service["contracts"]["policies"]
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
|
||||
|
||||
from parser.cas_payload import ( # noqa: E402
|
||||
classify_payload,
|
||||
decode_declared_utf8_bsl_prefix,
|
||||
extract_stream_blocks,
|
||||
replace_declared_utf8_bsl_prefix_same_width,
|
||||
stream_header,
|
||||
structural_stream_blocks_with_data,
|
||||
)
|
||||
|
||||
|
||||
def test_structural_stream_parser_ignores_header_signature_inside_member() -> None:
|
||||
fake_header = stream_header(1)
|
||||
first = b"prefix" + fake_header + b"tail"
|
||||
payload = b"lead" + stream_header(len(first)) + first + stream_header(3) + b"end"
|
||||
|
||||
# The old discovery scanner sees the signature embedded in `first`.
|
||||
assert len(extract_stream_blocks(payload, include_text=True)) == 3
|
||||
|
||||
blocks = structural_stream_blocks_with_data(payload)
|
||||
assert len(blocks) == 2
|
||||
assert blocks[0]["data"] == first
|
||||
assert blocks[1]["data"] == b"end"
|
||||
|
||||
|
||||
def test_declared_utf8_bsl_prefix_excludes_opaque_member_tail() -> None:
|
||||
bsl = b"\xef\xbb\xbf// marker\r\n"
|
||||
opaque = b"\x00\x04\x10metadata"
|
||||
member = bsl + opaque
|
||||
header = f"\r\n{len(bsl):08x} {len(member):08x} 7fffffff \r\n".encode("ascii")
|
||||
payload = b"lead" + header + member
|
||||
|
||||
decoded = decode_declared_utf8_bsl_prefix(payload, 0)
|
||||
assert decoded["status"] == "ok"
|
||||
assert decoded["text"] == "// marker\r\n"
|
||||
assert decoded["bsl_prefix_bytes"] == len(bsl)
|
||||
assert decoded["opaque_tail_bytes"] == len(opaque)
|
||||
|
||||
|
||||
def test_classification_uses_declared_utf8_prefix_not_opaque_member_tail() -> None:
|
||||
bsl = b"\xef\xbb\xbf// source-only\r\n"
|
||||
opaque = b"\x00metadata-tail-that-is-not-bsl"
|
||||
member = bsl + opaque
|
||||
header = f"\r\n{len(bsl):08x} {len(member):08x} 7fffffff \r\n".encode("ascii")
|
||||
|
||||
classified = classify_payload(b"lead" + header + member, include_text=True)
|
||||
|
||||
stream = classified["stream_blocks"][0]
|
||||
assert stream["text"] == "// source-only\r\n"
|
||||
assert stream["declared_utf8_bsl_prefix"]["opaque_tail_bytes"] == len(opaque)
|
||||
|
||||
|
||||
def test_classification_does_not_treat_declared_brace_prefix_as_bsl() -> None:
|
||||
descriptor = b"\xef\xbb\xbf{3,1,0,\"\",0}"
|
||||
member = descriptor + b"\x00opaque"
|
||||
header = f"\r\n{len(descriptor):08x} {len(member):08x} 7fffffff \r\n".encode("ascii")
|
||||
|
||||
classified = classify_payload(b"lead" + header + member, include_text=True)
|
||||
|
||||
assert "declared_utf8_bsl_prefix" not in classified["stream_blocks"][0]
|
||||
|
||||
|
||||
def test_fixed_width_prefix_replacement_preserves_tail_and_all_other_members() -> None:
|
||||
first = b"first-member"
|
||||
old_text = "//Пример - 3\r\n//Пример - 4 "
|
||||
old_prefix = b"\xef\xbb\xbf" + old_text.encode("utf-8")
|
||||
opaque = b"\x00opaque-platform-tail\x10\x20"
|
||||
member = old_prefix + opaque
|
||||
payload = (
|
||||
b"lead"
|
||||
+ stream_header(len(first))
|
||||
+ first
|
||||
+ f"\r\n{len(old_prefix):08x} {len(member):08x} 7fffffff \r\n".encode("ascii")
|
||||
+ member
|
||||
+ stream_header(3)
|
||||
+ b"end"
|
||||
)
|
||||
|
||||
replacement, evidence = replace_declared_utf8_bsl_prefix_same_width(
|
||||
payload,
|
||||
1,
|
||||
text="//Тест - 3\r\n//Пример - 4 ",
|
||||
)
|
||||
|
||||
before = structural_stream_blocks_with_data(payload)
|
||||
after = structural_stream_blocks_with_data(replacement)
|
||||
assert evidence["padding_bytes"] == len("Пример".encode("utf-8")) - len("Тест".encode("utf-8"))
|
||||
assert before[0]["data"] == after[0]["data"]
|
||||
assert before[1]["data"][len(old_prefix) :] == after[1]["data"][len(old_prefix) :]
|
||||
assert before[2]["data"] == after[2]["data"]
|
||||
assert decode_declared_utf8_bsl_prefix(replacement, 1)["text"].startswith("//Тест - 3")
|
||||
|
||||
|
||||
def test_fixed_width_prefix_replacement_rejects_growth() -> None:
|
||||
old_prefix = b"\xef\xbb\xbf// one"
|
||||
member = old_prefix + b"opaque"
|
||||
payload = b"lead" + f"\r\n{len(old_prefix):08x} {len(member):08x} 7fffffff \r\n".encode("ascii") + member
|
||||
|
||||
try:
|
||||
replace_declared_utf8_bsl_prefix_same_width(payload, 0, text="// replacement that is longer")
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "replacement_declared_bsl_prefix_exceeds_fixed_width"
|
||||
else:
|
||||
raise AssertionError("fixed-width codec accepted growth")
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector"
|
||||
if str(CONNECTOR) not in sys.path:
|
||||
sys.path.insert(0, str(CONNECTOR))
|
||||
|
||||
from write.registry import registered_handlers, select_write_handler # noqa: E402
|
||||
from write.context import AdapterWriteContext # noqa: E402
|
||||
from write.handlers.scheduled_job import execute as execute_scheduled_job # noqa: E402
|
||||
|
||||
|
||||
def test_registry_selects_one_supported_handler() -> None:
|
||||
assert select_write_handler(target_kind="module").key == "module"
|
||||
assert select_write_handler(target_kind="форма").key == "form"
|
||||
assert select_write_handler(target_kind="object", operation="add_attribute").key == "object_member"
|
||||
assert select_write_handler(target_kind="object", operation="synonym").key == "object_property"
|
||||
assert select_write_handler(target_kind="anything", is_schedule=True).key == "scheduled_job_schedule"
|
||||
|
||||
|
||||
def test_registry_denies_unknown_target_by_default() -> None:
|
||||
assert select_write_handler(target_kind="template") is None
|
||||
|
||||
|
||||
def test_registry_exposes_each_typed_handler_once() -> None:
|
||||
handlers = registered_handlers()
|
||||
assert [item["key"] for item in handlers] == [
|
||||
"module", "form", "object_property", "object_member", "scheduled_job_schedule"
|
||||
]
|
||||
|
||||
|
||||
def test_scheduled_job_handler_uses_explicit_context() -> None:
|
||||
seen: list[dict] = []
|
||||
result = execute_scheduled_job(
|
||||
{"base_id": "upo_test"},
|
||||
AdapterWriteContext(legacy_scheduled_job_writer=lambda payload: seen.append(payload) or {"status": "planned"}),
|
||||
)
|
||||
assert result == {"status": "planned"}
|
||||
assert seen == [{"base_id": "upo_test"}]
|
||||
Reference in New Issue
Block a user