520 lines
16 KiB
Python
520 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import tomllib
|
|
|
|
import pytest
|
|
|
|
import opensquilla.gateway.rpc_config # noqa: F401 ensures registration
|
|
from opensquilla.gateway.auth import Principal
|
|
from opensquilla.gateway.config import GatewayConfig
|
|
from opensquilla.gateway.rpc import RpcContext, get_dispatcher
|
|
|
|
|
|
def _admin_ctx(config: GatewayConfig) -> RpcContext:
|
|
return RpcContext(
|
|
conn_id="t",
|
|
config=config,
|
|
principal=Principal(
|
|
role="operator",
|
|
scopes=frozenset({"operator.admin"}),
|
|
is_owner=True,
|
|
authenticated=True,
|
|
),
|
|
)
|
|
|
|
|
|
def _memory_remote_secret_config(config_path) -> GatewayConfig:
|
|
return GatewayConfig(
|
|
config_path=str(config_path),
|
|
memory={
|
|
"embedding": {
|
|
"provider": "openai",
|
|
"remote": {
|
|
"api_key": "mem-secret",
|
|
"headers": {
|
|
"Authorization": "Bearer header-secret",
|
|
"x-api-key": "header-key",
|
|
},
|
|
},
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
def _assert_memory_remote_secrets_preserved(
|
|
cfg: GatewayConfig,
|
|
config_path,
|
|
) -> None:
|
|
assert cfg.memory.embedding.remote.api_key == "mem-secret"
|
|
headers = cfg.memory.embedding.remote.headers
|
|
assert headers["Authorization"] == "Bearer header-secret"
|
|
assert headers["x-api-key"] == "header-key"
|
|
persisted = config_path.read_text(encoding="utf-8")
|
|
assert "[redacted]" not in persisted
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_memory_embedding_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"memory.embedding.provider": "none"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_same_memory_embedding_does_not_report_restart_required(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
memory={"embedding": {"provider": "none"}},
|
|
)
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"memory.embedding.provider": "none"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_permissions_default_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"permissions.default_mode": "full"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_same_permissions_default_does_not_report_restart_required(
|
|
tmp_path,
|
|
):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
permissions={"default_mode": "bypass"},
|
|
)
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"permissions.default_mode": "bypass"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_apply_sandbox_posture_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
payload = cfg.model_dump(mode="python")
|
|
payload["sandbox"]["sandbox"] = False
|
|
payload["sandbox"]["security_grading"] = False
|
|
payload["permissions"]["default_mode"] = "off"
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.apply",
|
|
{"config": payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_memory_retrieval_mode_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"memory.retrieval_mode": "fts_only"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_apply_memory_retrieval_mode_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
payload = cfg.model_dump(mode="python")
|
|
payload["memory"]["retrieval_mode"] = "fts_only"
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.apply",
|
|
{"config": payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_get_redacts_memory_remote_api_key(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
memory={"embedding": {"remote": {"api_key": "mem-secret"}}},
|
|
)
|
|
|
|
res = await get_dispatcher().dispatch("r1", "config.get", {}, _admin_ctx(cfg))
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["memory"]["embedding"]["remote"]["api_key"] == "[redacted]"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_get_does_not_redact_empty_memory_remote_api_key(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
|
|
res = await get_dispatcher().dispatch("r1", "config.get", {}, _admin_ctx(cfg))
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["memory"]["embedding"]["remote"]["api_key"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_get_redacts_memory_remote_header_api_key(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
memory={
|
|
"embedding": {
|
|
"remote": {
|
|
"headers": {
|
|
"Authorization": "Bearer secret",
|
|
"x-api-key": "header-secret",
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
res = await get_dispatcher().dispatch("r1", "config.get", {}, _admin_ctx(cfg))
|
|
|
|
assert res.error is None, res.error
|
|
headers = res.payload["memory"]["embedding"]["remote"]["headers"]
|
|
assert headers["Authorization"] == "[redacted]"
|
|
assert headers["x-api-key"] == "[redacted]"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_apply_preserves_redacted_memory_remote_secrets(tmp_path):
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = _memory_remote_secret_config(config_path)
|
|
dispatcher = get_dispatcher()
|
|
|
|
get_res = await dispatcher.dispatch("r1", "config.get", {}, _admin_ctx(cfg))
|
|
assert get_res.error is None, get_res.error
|
|
assert get_res.payload["memory"]["embedding"]["remote"]["api_key"] == "[redacted]"
|
|
|
|
apply_res = await dispatcher.dispatch(
|
|
"r2",
|
|
"config.apply",
|
|
{"config": get_res.payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert apply_res.error is None, apply_res.error
|
|
_assert_memory_remote_secrets_preserved(cfg, config_path)
|
|
persisted = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
assert "network_observability_disabled_effective" not in persisted.get("privacy", {})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_preserves_redacted_memory_object_secrets(tmp_path):
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = _memory_remote_secret_config(config_path)
|
|
dispatcher = get_dispatcher()
|
|
|
|
get_res = await dispatcher.dispatch("r1", "config.get", {}, _admin_ctx(cfg))
|
|
assert get_res.error is None, get_res.error
|
|
|
|
memory_payload = get_res.payload["memory"]
|
|
assert memory_payload["embedding"]["remote"]["api_key"] == "[redacted]"
|
|
patch_res = await dispatcher.dispatch(
|
|
"r2",
|
|
"config.patch",
|
|
{"patches": {"memory": memory_payload}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert patch_res.error is None, patch_res.error
|
|
_assert_memory_remote_secrets_preserved(cfg, config_path)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_apply_keeps_literal_redacted_for_non_secret_memory_fields(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
payload = cfg.model_dump(mode="python")
|
|
payload["memory"]["embedding"]["remote"]["model"] = "[redacted]"
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.apply",
|
|
{"config": payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert cfg.memory.embedding.remote.model == "[redacted]"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_keeps_literal_redacted_for_non_secret_memory_fields(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"memory.embedding.remote.model": "[redacted]"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert cfg.memory.embedding.remote.model == "[redacted]"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_rejects_direct_redacted_secret_marker(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"memory.embedding.remote.api_key": "[redacted]"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is not None
|
|
assert res.error.code == "INVALID_REQUEST"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_set_rejects_direct_redacted_secret_marker(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.set",
|
|
{"path": "memory.embedding.remote.api_key", "value": "[redacted]"},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is not None
|
|
assert res.error.code == "INVALID_REQUEST"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_set_rejects_redacted_secret_marker_inside_object(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.set",
|
|
{
|
|
"path": "memory.embedding.remote",
|
|
"value": {"api_key": "[redacted]"},
|
|
},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is not None
|
|
assert res.error.code == "INVALID_REQUEST"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_set_preserves_redacted_memory_remote_object_secrets(tmp_path):
|
|
config_path = tmp_path / "c.toml"
|
|
cfg = _memory_remote_secret_config(config_path)
|
|
dispatcher = get_dispatcher()
|
|
|
|
get_res = await dispatcher.dispatch("r1", "config.get", {}, _admin_ctx(cfg))
|
|
assert get_res.error is None, get_res.error
|
|
|
|
remote_payload = get_res.payload["memory"]["embedding"]["remote"]
|
|
remote_payload["base_url"] = "https://embeddings.example/v1"
|
|
set_res = await dispatcher.dispatch(
|
|
"r2",
|
|
"config.set",
|
|
{"path": "memory.embedding.remote", "value": remote_payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert set_res.error is None, set_res.error
|
|
assert cfg.memory.embedding.remote.base_url == "https://embeddings.example/v1"
|
|
_assert_memory_remote_secrets_preserved(cfg, config_path)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_rejects_explicit_remote_without_memory_api_key(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"memory.embedding.provider": "openai"}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is not None
|
|
assert res.error.code == "INVALID_REQUEST"
|
|
assert "memory.embedding.remote.api_key" in res.error.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_apply_rejects_explicit_remote_without_memory_api_key(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
payload = cfg.model_dump(mode="python")
|
|
payload["memory"]["embedding"]["provider"] = "openai"
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.apply",
|
|
{"config": payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is not None
|
|
assert res.error.code == "INVALID_REQUEST"
|
|
assert "memory.embedding.remote.api_key" in res.error.message
|
|
|
|
|
|
def _slack_channel_entry(name: str = "work", token: str = "xoxb-test-token") -> dict:
|
|
return {
|
|
"type": "slack",
|
|
"name": name,
|
|
"enabled": True,
|
|
"agent_id": "main",
|
|
"debounce_window_s": 0.0,
|
|
"status_reactions_enabled": False,
|
|
"token": token,
|
|
"slack_channel_id": "C001",
|
|
"signing_secret": None,
|
|
"reply_in_thread": False,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_channels_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(config_path=str(tmp_path / "c.toml"))
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"channels.channels": [_slack_channel_entry()]}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_same_channels_does_not_report_restart_required(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
channels={"channels": [_slack_channel_entry()]},
|
|
)
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"channels.channels": [_slack_channel_entry()]}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_apply_channels_token_change_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
channels={"channels": [_slack_channel_entry(token="xoxb-old")]},
|
|
)
|
|
payload = cfg.model_dump(mode="python")
|
|
payload["channels"]["channels"][0]["token"] = "xoxb-new"
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.apply",
|
|
{"config": payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_apply_unchanged_channels_does_not_report_restart_required(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
channels={"channels": [_slack_channel_entry()]},
|
|
)
|
|
payload = cfg.model_dump(mode="python")
|
|
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.apply",
|
|
{"config": payload},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_set_channel_token_rotation_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
channels={"channels": [_slack_channel_entry(token="xoxb-old")]},
|
|
)
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.set",
|
|
{
|
|
"path": "channels.channels",
|
|
"value": [_slack_channel_entry(token="xoxb-new")],
|
|
},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_patch_disabling_channel_reports_restart_required(tmp_path):
|
|
cfg = GatewayConfig(
|
|
config_path=str(tmp_path / "c.toml"),
|
|
channels={"channels": [_slack_channel_entry()]},
|
|
)
|
|
disabled_entry = {**_slack_channel_entry(), "enabled": False}
|
|
res = await get_dispatcher().dispatch(
|
|
"r1",
|
|
"config.patch",
|
|
{"patches": {"channels.channels": [disabled_entry]}},
|
|
_admin_ctx(cfg),
|
|
)
|
|
|
|
assert res.error is None, res.error
|
|
assert res.payload["restartRequired"] is True
|