Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74060192e0 | |||
| 5365a7a852 | |||
| f289b6382c | |||
| 3073230f58 |
@@ -0,0 +1,297 @@
|
||||
"""Unit: egress_addon_core route parsing, serialization, and match
|
||||
evaluation error/edge branches (coverage ratchet, ADR 0004).
|
||||
|
||||
Complements test_egress_addon_core.py — focuses on the validation
|
||||
rejections, the Route->YAML serializer, and evaluate_matches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.egress_addon_core import (
|
||||
HeaderMatch,
|
||||
MatchEntry,
|
||||
PathMatch,
|
||||
Route,
|
||||
evaluate_matches,
|
||||
load_config,
|
||||
parse_config,
|
||||
parse_routes,
|
||||
route_to_yaml_dict,
|
||||
)
|
||||
|
||||
|
||||
def _route(d: dict[str, object]) -> Route:
|
||||
return parse_routes({"routes": [d]})[0]
|
||||
|
||||
|
||||
class TestRouteValidationErrors(unittest.TestCase):
|
||||
def _bad(self, d: dict[str, object]) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_routes({"routes": [d]})
|
||||
|
||||
# routes-payload shape
|
||||
def test_payload_not_dict(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_routes(["nope"])
|
||||
|
||||
def test_routes_not_list(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_routes({"routes": "nope"})
|
||||
|
||||
def test_route_not_dict(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_routes({"routes": ["nope"]})
|
||||
|
||||
def test_host_missing(self) -> None:
|
||||
self._bad({})
|
||||
|
||||
def test_unknown_route_key(self) -> None:
|
||||
self._bad({"host": "h", "bogus": 1})
|
||||
|
||||
# auth
|
||||
def test_auth_scheme_without_token_env(self) -> None:
|
||||
self._bad({"host": "h", "auth_scheme": "Bearer"})
|
||||
|
||||
def test_auth_scheme_wrong_type(self) -> None:
|
||||
self._bad({"host": "h", "auth_scheme": 5, "token_env": "T"})
|
||||
|
||||
# git
|
||||
def test_git_not_dict(self) -> None:
|
||||
self._bad({"host": "h", "git": "yes"})
|
||||
|
||||
def test_git_fetch_not_bool(self) -> None:
|
||||
self._bad({"host": "h", "git": {"fetch": "yes"}})
|
||||
|
||||
def test_git_unknown_key(self) -> None:
|
||||
self._bad({"host": "h", "git": {"fetch": True, "push": True}})
|
||||
|
||||
# matches: paths
|
||||
def test_matches_not_list(self) -> None:
|
||||
self._bad({"host": "h", "matches": "x"})
|
||||
|
||||
def test_match_entry_not_dict(self) -> None:
|
||||
self._bad({"host": "h", "matches": ["x"]})
|
||||
|
||||
def test_paths_not_list(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"paths": "x"}]})
|
||||
|
||||
def test_path_not_dict(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"paths": ["x"]}]})
|
||||
|
||||
def test_path_bad_type(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"paths": [{"type": "bogus", "value": "/x"}]}]})
|
||||
|
||||
def test_path_empty_value(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"paths": [{"value": ""}]}]})
|
||||
|
||||
def test_path_value_missing_slash(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"paths": [{"type": "prefix", "value": "x"}]}]})
|
||||
|
||||
def test_path_bad_regex(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"paths": [{"type": "regex", "value": "("}]}]})
|
||||
|
||||
def test_path_unknown_key(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"paths": [{"value": "/x", "z": 1}]}]})
|
||||
|
||||
# matches: methods
|
||||
def test_methods_not_list(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"methods": "GET"}]})
|
||||
|
||||
def test_method_not_string(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"methods": [5]}]})
|
||||
|
||||
def test_method_invalid(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"methods": ["FETCH"]}]})
|
||||
|
||||
# matches: headers
|
||||
def test_headers_not_list(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"headers": "x"}]})
|
||||
|
||||
def test_header_not_dict(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"headers": ["x"]}]})
|
||||
|
||||
def test_header_name_empty(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"headers": [{"name": "", "value": "v"}]}]})
|
||||
|
||||
def test_header_value_not_string(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"headers": [{"name": "X", "value": 1}]}]})
|
||||
|
||||
def test_header_bad_type(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"headers": [{"name": "X", "value": "v", "type": "z"}]}]})
|
||||
|
||||
def test_header_bad_regex(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"headers": [{"name": "X", "value": "(", "type": "regex"}]}]})
|
||||
|
||||
def test_header_unknown_key(self) -> None:
|
||||
self._bad({"host": "h", "matches": [{"headers": [{"name": "X", "value": "v", "z": 1}]}]})
|
||||
|
||||
# dlp
|
||||
def test_dlp_not_dict(self) -> None:
|
||||
self._bad({"host": "h", "dlp": "x"})
|
||||
|
||||
def test_dlp_detectors_wrong_type(self) -> None:
|
||||
self._bad({"host": "h", "dlp": {"outbound_detectors": "x"}})
|
||||
|
||||
def test_dlp_detector_name_invalid(self) -> None:
|
||||
self._bad({"host": "h", "dlp": {"outbound_detectors": ["bogus"]}})
|
||||
|
||||
def test_dlp_detector_item_not_string(self) -> None:
|
||||
self._bad({"host": "h", "dlp": {"outbound_detectors": [5]}})
|
||||
|
||||
def test_dlp_on_match_invalid(self) -> None:
|
||||
self._bad({"host": "h", "dlp": {"outbound_on_match": "maybe"}})
|
||||
|
||||
def test_dlp_unknown_key(self) -> None:
|
||||
self._bad({"host": "h", "dlp": {"bogus": 1}})
|
||||
|
||||
|
||||
class TestRouteValidAccepts(unittest.TestCase):
|
||||
def test_full_route_parses(self) -> None:
|
||||
r = _route({
|
||||
"host": "api.example.com",
|
||||
"auth_scheme": "Bearer",
|
||||
"token_env": "TOK",
|
||||
"matches": [{
|
||||
"paths": [{"type": "exact", "value": "/v1"}],
|
||||
"methods": ["get", "post"],
|
||||
"headers": [{"name": "X-Env", "value": "prod"}],
|
||||
}],
|
||||
"git": {"fetch": True},
|
||||
"dlp": {
|
||||
"outbound_detectors": ["token_patterns"],
|
||||
"inbound_detectors": ["naive_injection_detection"],
|
||||
"outbound_on_match": "block",
|
||||
},
|
||||
})
|
||||
self.assertEqual("api.example.com", r.host)
|
||||
self.assertEqual(("GET", "POST"), r.matches[0].methods)
|
||||
self.assertTrue(r.git_fetch)
|
||||
self.assertEqual("block", r.outbound_on_match)
|
||||
|
||||
def test_dlp_detectors_false_disables(self) -> None:
|
||||
r = _route({"host": "h", "dlp": {"outbound_detectors": False}})
|
||||
self.assertEqual((), r.outbound_detectors)
|
||||
|
||||
|
||||
class TestParseConfig(unittest.TestCase):
|
||||
def test_log_must_be_valid_level(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_config({"log": 5, "routes": []})
|
||||
|
||||
def test_log_true_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_config({"log": True, "routes": []})
|
||||
|
||||
def test_top_level_not_dict(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_config(["x"])
|
||||
|
||||
def test_load_config_invalid_yaml(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
load_config("routes: [unterminated\n")
|
||||
|
||||
|
||||
class TestRouteToYamlDict(unittest.TestCase):
|
||||
def test_minimal(self) -> None:
|
||||
self.assertEqual({"host": "h"}, route_to_yaml_dict(Route(host="h")))
|
||||
|
||||
def test_auth_fields(self) -> None:
|
||||
d = route_to_yaml_dict(Route(host="h", auth_scheme="Bearer", token_env="T"))
|
||||
self.assertEqual("Bearer", d["auth_scheme"])
|
||||
self.assertEqual("T", d["token_env"])
|
||||
|
||||
def test_git_fetch(self) -> None:
|
||||
d = route_to_yaml_dict(Route(host="h", git_fetch=True))
|
||||
self.assertEqual({"fetch": True}, d["git"])
|
||||
|
||||
def test_dlp_fields(self) -> None:
|
||||
d = route_to_yaml_dict(Route(
|
||||
host="h",
|
||||
outbound_detectors=("token_patterns",),
|
||||
inbound_detectors=("naive_injection_detection",),
|
||||
outbound_on_match="redact",
|
||||
))
|
||||
self.assertEqual(
|
||||
{
|
||||
"outbound_detectors": ["token_patterns"],
|
||||
"inbound_detectors": ["naive_injection_detection"],
|
||||
"outbound_on_match": "redact",
|
||||
},
|
||||
d["dlp"],
|
||||
)
|
||||
|
||||
def test_matches_serialization_omits_defaults(self) -> None:
|
||||
route = Route(host="h", matches=(MatchEntry(
|
||||
paths=(
|
||||
PathMatch(type="prefix", value="/p"), # default type -> omitted
|
||||
PathMatch(type="exact", value="/e"), # non-default -> kept
|
||||
),
|
||||
methods=("GET",),
|
||||
headers=(
|
||||
HeaderMatch(name="X", value="v"), # exact -> omitted
|
||||
HeaderMatch(name="Y", value="r", type="regex"), # regex -> kept
|
||||
),
|
||||
),))
|
||||
d = route_to_yaml_dict(route)
|
||||
matches = d["matches"]
|
||||
assert isinstance(matches, list)
|
||||
entry = matches[0]
|
||||
self.assertEqual(
|
||||
[{"value": "/p"}, {"value": "/e", "type": "exact"}],
|
||||
entry["paths"],
|
||||
)
|
||||
self.assertEqual(["GET"], entry["methods"])
|
||||
self.assertEqual(
|
||||
[{"name": "X", "value": "v"}, {"name": "Y", "value": "r", "type": "regex"}],
|
||||
entry["headers"],
|
||||
)
|
||||
|
||||
|
||||
class TestEvaluateMatches(unittest.TestCase):
|
||||
def _route_with(self, entry: MatchEntry) -> Route:
|
||||
return Route(host="h", matches=(entry,))
|
||||
|
||||
def test_empty_matches_allows_all(self) -> None:
|
||||
self.assertTrue(evaluate_matches(Route(host="h"), "/anything", "GET"))
|
||||
|
||||
def test_exact_path(self) -> None:
|
||||
r = self._route_with(MatchEntry(paths=(PathMatch("exact", "/a"),)))
|
||||
self.assertTrue(evaluate_matches(r, "/a", "GET"))
|
||||
self.assertFalse(evaluate_matches(r, "/a/b", "GET"))
|
||||
|
||||
def test_prefix_path_boundary(self) -> None:
|
||||
r = self._route_with(MatchEntry(paths=(PathMatch("prefix", "/a"),)))
|
||||
self.assertTrue(evaluate_matches(r, "/a/b", "GET"))
|
||||
self.assertFalse(evaluate_matches(r, "/ab", "GET"))
|
||||
|
||||
def test_regex_path(self) -> None:
|
||||
import re
|
||||
r = self._route_with(MatchEntry(
|
||||
paths=(PathMatch("regex", r"/v\d+", compiled=re.compile(r"/v\d+")),),
|
||||
))
|
||||
self.assertTrue(evaluate_matches(r, "/v1", "GET"))
|
||||
self.assertFalse(evaluate_matches(r, "/x", "GET"))
|
||||
|
||||
def test_method_filter(self) -> None:
|
||||
r = self._route_with(MatchEntry(methods=("POST",)))
|
||||
self.assertTrue(evaluate_matches(r, "/x", "post"))
|
||||
self.assertFalse(evaluate_matches(r, "/x", "GET"))
|
||||
|
||||
def test_header_exact(self) -> None:
|
||||
r = self._route_with(MatchEntry(headers=(HeaderMatch("X-Env", "prod"),)))
|
||||
self.assertTrue(evaluate_matches(r, "/x", "GET", {"x-env": "prod"}))
|
||||
self.assertFalse(evaluate_matches(r, "/x", "GET", {"x-env": "dev"}))
|
||||
self.assertFalse(evaluate_matches(r, "/x", "GET", {}))
|
||||
|
||||
def test_header_regex(self) -> None:
|
||||
import re
|
||||
r = self._route_with(MatchEntry(
|
||||
headers=(HeaderMatch("X-Env", r"pr.*", type="regex", compiled=re.compile(r"pr.*")),),
|
||||
))
|
||||
self.assertTrue(evaluate_matches(r, "/x", "GET", {"x-env": "prod"}))
|
||||
self.assertFalse(evaluate_matches(r, "/x", "GET", {"x-env": "dev"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Unit: git_gate gitconfig rendering + deploy-key provision/revoke
|
||||
(coverage ratchet, ADR 0004).
|
||||
|
||||
Covers the pure `git_gate_render_gitconfig` renderer and the dynamic
|
||||
(gitea) deploy-key lifecycle, with the forge provisioner mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.git_gate import (
|
||||
_gitconfig_validate_value,
|
||||
_provision_dynamic_key,
|
||||
git_gate_render_gitconfig,
|
||||
revoke_git_gate_provisioned_keys,
|
||||
)
|
||||
from bot_bottle.manifest_git import ManifestGitEntry, ManifestKeyConfig
|
||||
|
||||
|
||||
def _entry(**kw: Any) -> ManifestGitEntry:
|
||||
base: dict[str, Any] = {
|
||||
"Name": "repo",
|
||||
"Upstream": "git@github.com:o/r.git",
|
||||
"UpstreamHost": "github.com",
|
||||
"UpstreamUser": "git",
|
||||
"UpstreamPath": "o/r.git",
|
||||
"UpstreamPort": "22",
|
||||
}
|
||||
base.update(kw)
|
||||
return ManifestGitEntry(**base)
|
||||
|
||||
|
||||
def _gitea_entry(**kw: Any) -> ManifestGitEntry:
|
||||
return _entry(
|
||||
Key=ManifestKeyConfig(provider="gitea", forge_token_env="GITEA_TOK"),
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
class _FakeProvisioner:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[tuple[str, str]] = []
|
||||
self.deleted: list[tuple[str, str]] = []
|
||||
|
||||
def create(self, owner_repo: str, title: str) -> tuple[str, bytes]:
|
||||
self.created.append((owner_repo, title))
|
||||
return "kid123", b"PRIVATE-KEY-BYTES"
|
||||
|
||||
def delete(self, owner_repo: str, key_id: str) -> None:
|
||||
self.deleted.append((owner_repo, key_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# git_gate_render_gitconfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderGitconfig(unittest.TestCase):
|
||||
def test_empty_entries_returns_empty_string(self) -> None:
|
||||
self.assertEqual("", git_gate_render_gitconfig((), "git-gate"))
|
||||
|
||||
def test_single_entry_renders_insteadof(self) -> None:
|
||||
out = git_gate_render_gitconfig((_entry(),), "git-gate")
|
||||
self.assertIn('[url "git://git-gate/repo.git"]', out)
|
||||
self.assertIn("insteadOf = git@github.com:o/r.git", out)
|
||||
|
||||
def test_scheme_override(self) -> None:
|
||||
out = git_gate_render_gitconfig((_entry(),), "1.2.3.4:9418", scheme="http")
|
||||
self.assertIn('[url "http://1.2.3.4:9418/repo.git"]', out)
|
||||
|
||||
def test_remote_key_alias_with_nondefault_port(self) -> None:
|
||||
out = git_gate_render_gitconfig(
|
||||
(_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate",
|
||||
)
|
||||
self.assertIn("insteadOf = ssh://git@10.0.0.5:2222/o/r.git", out)
|
||||
|
||||
def test_remote_key_alias_default_port_omits_port(self) -> None:
|
||||
out = git_gate_render_gitconfig(
|
||||
(_entry(RemoteKey="10.0.0.5", UpstreamPort="22"),), "git-gate",
|
||||
)
|
||||
self.assertIn("insteadOf = ssh://git@10.0.0.5/o/r.git", out)
|
||||
self.assertNotIn(":22/", out)
|
||||
|
||||
def test_validate_rejects_newline(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
_gitconfig_validate_value("field", "line1\nline2")
|
||||
|
||||
def test_render_rejects_newline_in_upstream(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_gate_render_gitconfig((_entry(Upstream="a\nb"),), "git-gate")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _provision_dynamic_key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProvisionDynamicKey(unittest.TestCase):
|
||||
def test_happy_path_writes_key_and_id(self) -> None:
|
||||
fake = _FakeProvisioner()
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
patch.dict("os.environ", {"GITEA_TOK": "secret-token"}), \
|
||||
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake), \
|
||||
patch("sys.stderr"):
|
||||
path = _provision_dynamic_key(_gitea_entry(), "myslug", Path(d))
|
||||
key_file = Path(path)
|
||||
self.assertEqual(b"PRIVATE-KEY-BYTES", key_file.read_bytes())
|
||||
id_file = Path(d) / "repo-deploy-key-id"
|
||||
self.assertEqual("kid123", id_file.read_text())
|
||||
# owner_repo had .git stripped; title carries slug + name
|
||||
self.assertEqual([("o/r", "bot-bottle:myslug:repo")], fake.created)
|
||||
|
||||
def test_missing_token_raises(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
patch.dict("os.environ", {}, clear=False):
|
||||
import os
|
||||
os.environ.pop("GITEA_TOK", None)
|
||||
with self.assertRaises(RuntimeError):
|
||||
_provision_dynamic_key(_gitea_entry(), "s", Path(d))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# revoke_git_gate_provisioned_keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bottle(*entries: ManifestGitEntry) -> Any:
|
||||
return cast(Any, types.SimpleNamespace(git=entries))
|
||||
|
||||
|
||||
class TestRevokeProvisionedKeys(unittest.TestCase):
|
||||
def test_revokes_gitea_key_when_id_present(self) -> None:
|
||||
fake = _FakeProvisioner()
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
patch.dict("os.environ", {"GITEA_TOK": "secret-token"}), \
|
||||
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake), \
|
||||
patch("sys.stderr"):
|
||||
(Path(d) / "repo-deploy-key-id").write_text("kid123")
|
||||
revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d))
|
||||
self.assertEqual([("o/r", "kid123")], fake.deleted)
|
||||
|
||||
def test_skips_non_gitea_entry(self) -> None:
|
||||
fake = _FakeProvisioner()
|
||||
static_entry = _entry(Key=ManifestKeyConfig(provider="static", path="/k"))
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake):
|
||||
revoke_git_gate_provisioned_keys(_bottle(static_entry), Path(d))
|
||||
self.assertEqual([], fake.deleted)
|
||||
|
||||
def test_skips_when_id_file_missing(self) -> None:
|
||||
fake = _FakeProvisioner()
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
patch("bot_bottle.deploy_key_provisioner.get_provisioner", return_value=fake):
|
||||
# no id file written -> entry skipped
|
||||
revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d))
|
||||
self.assertEqual([], fake.deleted)
|
||||
|
||||
def test_missing_token_raises(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
patch.dict("os.environ", {}, clear=False):
|
||||
import os
|
||||
os.environ.pop("GITEA_TOK", None)
|
||||
(Path(d) / "repo-deploy-key-id").write_text("kid123")
|
||||
with self.assertRaises(RuntimeError):
|
||||
revoke_git_gate_provisioned_keys(_bottle(_gitea_entry()), Path(d))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Unit: manifest + manifest_agent validation error/edge branches
|
||||
(coverage ratchet, ADR 0004).
|
||||
|
||||
Drives ManifestBottle / ManifestAgentProvider / ManifestAgent / the
|
||||
provider-settings parser and the eager ManifestIndex lookup methods
|
||||
through their rejection and edge paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.manifest import ManifestBottle, ManifestIndex
|
||||
from bot_bottle.manifest_agent import (
|
||||
ManifestAgent,
|
||||
ManifestAgentProvider,
|
||||
_parse_provider_settings,
|
||||
)
|
||||
from bot_bottle.manifest_util import ManifestError
|
||||
|
||||
|
||||
def _idx(obj: dict[str, object]) -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj(obj)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ManifestBottle.from_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBottleValidation(unittest.TestCase):
|
||||
def test_unknown_key(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestBottle.from_dict("b", {"bogus": 1})
|
||||
|
||||
def test_env_value_not_string(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestBottle.from_dict("b", {"env": {"X": 5}})
|
||||
|
||||
def test_supervise_not_bool(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestBottle.from_dict("b", {"supervise": "yes"})
|
||||
|
||||
def test_removed_runtime_field(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestBottle.from_dict("b", {"runtime": "runsc"})
|
||||
|
||||
def test_valid_minimal(self) -> None:
|
||||
b = ManifestBottle.from_dict("b", {"supervise": False, "env": {"X": "1"}})
|
||||
self.assertFalse(b.supervise)
|
||||
self.assertEqual({"X": "1"}, dict(b.env))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ManifestAgentProvider.from_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentProviderValidation(unittest.TestCase):
|
||||
def test_unknown_key(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict("b", {"bogus": 1})
|
||||
|
||||
def test_empty_template(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict("b", {"template": ""})
|
||||
|
||||
def test_dockerfile_not_string(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict("b", {"dockerfile": 5})
|
||||
|
||||
def test_auth_token_unknown_template(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict("b", {"auth_token": "x", "template": "weird"})
|
||||
|
||||
def test_auth_token_non_claude_template(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict("b", {"auth_token": "x", "template": "codex"})
|
||||
|
||||
def test_forward_creds_unknown_template(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict(
|
||||
"b", {"forward_host_credentials": True, "template": "weird"}
|
||||
)
|
||||
|
||||
def test_forward_creds_non_codex_template(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict(
|
||||
"b", {"forward_host_credentials": True, "template": "claude"}
|
||||
)
|
||||
|
||||
def test_valid_claude_auth_token(self) -> None:
|
||||
p = ManifestAgentProvider.from_dict("b", {"template": "claude", "auth_token": "T"})
|
||||
self.assertEqual("T", p.auth_token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_provider_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderSettings(unittest.TestCase):
|
||||
def test_unknown_template_passes_settings_through(self) -> None:
|
||||
out = _parse_provider_settings("b", "weird", {"anything": 1})
|
||||
self.assertEqual({"anything": 1}, out)
|
||||
|
||||
def test_startup_args_not_list(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "claude", {"startup_args": "x"})
|
||||
|
||||
def test_startup_args_empty_item(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "claude", {"startup_args": [""]})
|
||||
|
||||
def test_pi_string_field_empty(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "pi", {"provider": ""})
|
||||
|
||||
def test_pi_max_tokens_field_invalid(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "pi", {"max_tokens_field": "bogus"})
|
||||
|
||||
def test_pi_api_key_and_env_conflict(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "pi", {"api_key": "k", "api_key_env": "E"})
|
||||
|
||||
def test_pi_models_item_not_string(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "pi", {"models": [5]})
|
||||
|
||||
def test_pi_bool_field_not_bool(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "pi", {"supports_developer_role": "yes"})
|
||||
|
||||
def test_pi_context_window_not_positive(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
_parse_provider_settings("b", "pi", {"context_window": -1})
|
||||
|
||||
def test_pi_valid_settings(self) -> None:
|
||||
out = _parse_provider_settings(
|
||||
"b", "pi",
|
||||
{"provider": "openai", "models": ["gpt"], "context_window": 8000},
|
||||
)
|
||||
self.assertEqual("openai", out["provider"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ManifestAgent.from_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentValidation(unittest.TestCase):
|
||||
def test_bottle_empty_string(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"bottle": ""}, set())
|
||||
|
||||
def test_bottle_undefined(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"bottle": "x"}, set())
|
||||
|
||||
def test_skills_not_list(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"skills": "x"}, set())
|
||||
|
||||
def test_skill_item_not_string(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"skills": [5]}, set())
|
||||
|
||||
def test_prompt_not_string(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
||||
|
||||
def test_git_gate_repos_rejected_at_agent_level(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"git-gate": {"repos": {}}}, set())
|
||||
|
||||
def test_git_gate_empty_is_allowed(self) -> None:
|
||||
agent = ManifestAgent.from_dict("a", {"git-gate": {}}, set())
|
||||
self.assertTrue(agent.git_user.is_empty())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eager ManifestIndex lookup methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEagerIndexLookups(unittest.TestCase):
|
||||
def _idx(self) -> ManifestIndex:
|
||||
return _idx({
|
||||
"bottles": {"b": {"git-gate": {"user": {"name": "Bot", "email": "b@x"}}}},
|
||||
"agents": {"a": {"bottle": "b"}},
|
||||
})
|
||||
|
||||
def test_unknown_bottle_section_is_empty(self) -> None:
|
||||
# no "bottles" key -> _section_dict(None) path
|
||||
idx = _idx({"agents": {"a": {}}})
|
||||
self.assertEqual(["a"], idx.all_agent_names)
|
||||
|
||||
def test_load_unknown_agent_raises(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
self._idx().load_for_agent("nope")
|
||||
|
||||
def test_has_agent(self) -> None:
|
||||
idx = self._idx()
|
||||
self.assertTrue(idx.has_agent("a"))
|
||||
self.assertFalse(idx.has_agent("nope"))
|
||||
|
||||
def test_require_agent_known_and_unknown(self) -> None:
|
||||
idx = self._idx()
|
||||
idx.require_agent("a") # no raise
|
||||
with self.assertRaises(ManifestError):
|
||||
idx.require_agent("nope")
|
||||
|
||||
def test_git_identity_summary(self) -> None:
|
||||
m = self._idx().load_for_agent("a")
|
||||
summary = m.git_identity_summary()
|
||||
assert summary is not None
|
||||
self.assertIn("name=Bot", summary)
|
||||
self.assertIn("email=b@x", summary)
|
||||
|
||||
def test_git_identity_summary_none_when_empty(self) -> None:
|
||||
m = _idx({"bottles": {"b": {}}, "agents": {"a": {"bottle": "b"}}}).load_for_agent("a")
|
||||
self.assertIsNone(m.git_identity_summary())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -325,5 +325,137 @@ class TestFrontmatter(unittest.TestCase):
|
||||
self.assertEqual("\nline one\n\nline three\n", body)
|
||||
|
||||
|
||||
class TestEdgeAndErrorBranches(unittest.TestCase):
|
||||
"""Reachable error / edge branches of the parser (coverage ratchet)."""
|
||||
|
||||
# --- scalars / comments -------------------------------------------------
|
||||
def test_hash_not_preceded_by_space_is_literal(self) -> None:
|
||||
self.assertEqual({"k": "a#b"}, parse_yaml_subset("k: a#b\n"))
|
||||
|
||||
def test_blank_line_between_entries_skipped(self) -> None:
|
||||
self.assertEqual({"a": 1, "b": 2}, parse_yaml_subset("a: 1\n\nb: 2\n"))
|
||||
|
||||
def test_unterminated_quote_single_char(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset('k: "\n')
|
||||
|
||||
def test_bad_double_quote_escape(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset('k: "\\x"\n')
|
||||
|
||||
# --- inline list / dict -------------------------------------------------
|
||||
def test_inline_dict_empty_value_is_empty_string(self) -> None:
|
||||
self.assertEqual({"k": {"a": ""}}, parse_yaml_subset("k: {a: }\n"))
|
||||
|
||||
def test_unterminated_inline_list(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k: [a, b\n")
|
||||
|
||||
def test_empty_inline_list(self) -> None:
|
||||
self.assertEqual({"k": []}, parse_yaml_subset("k: []\n"))
|
||||
|
||||
def test_unterminated_inline_dict(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k: {a: 1\n")
|
||||
|
||||
def test_empty_inline_dict(self) -> None:
|
||||
self.assertEqual({"k": {}}, parse_yaml_subset("k: {}\n"))
|
||||
|
||||
def test_inline_dict_entry_missing_colon(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k: {a}\n")
|
||||
|
||||
def test_inline_dict_non_bare_key(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k: {$x: 1}\n")
|
||||
|
||||
def test_quoted_comma_in_flow_is_one_item(self) -> None:
|
||||
self.assertEqual({"k": ["a", "b, c"]}, parse_yaml_subset("k: [a, 'b, c']\n"))
|
||||
|
||||
# --- block mapping / list ----------------------------------------------
|
||||
def test_line_missing_colon_separator(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("justtext\n")
|
||||
|
||||
def test_single_quoted_key_rejected_as_non_bare(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("'ab': v\n")
|
||||
|
||||
def test_list_item_at_mapping_indent_rejected(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("a: 1\n- b\n")
|
||||
|
||||
def test_empty_block_value_is_none(self) -> None:
|
||||
self.assertEqual({"k": None}, parse_yaml_subset("k:\n"))
|
||||
|
||||
def test_list_item_first_key_non_bare(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k:\n - $x: 1\n")
|
||||
|
||||
def test_bare_dash_nested_block_list(self) -> None:
|
||||
self.assertEqual(
|
||||
{"k": [["nested"]]},
|
||||
parse_yaml_subset("k:\n -\n - nested\n"),
|
||||
)
|
||||
|
||||
def test_list_item_quoted_colon_is_scalar(self) -> None:
|
||||
self.assertEqual({"k": ["a:b"]}, parse_yaml_subset('k:\n - "a:b"\n'))
|
||||
|
||||
def test_list_item_mapping_with_nested_block(self) -> None:
|
||||
self.assertEqual(
|
||||
{"k": [{"a": {"b": 2}}]},
|
||||
parse_yaml_subset("k:\n - a:\n b: 2\n"),
|
||||
)
|
||||
|
||||
def test_list_item_sibling_key_empty_is_none(self) -> None:
|
||||
self.assertEqual(
|
||||
{"k": [{"a": 1, "b": None}]},
|
||||
parse_yaml_subset("k:\n - a: 1\n b:\n"),
|
||||
)
|
||||
|
||||
def test_list_item_duplicate_key(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k:\n - a: 1\n a: 2\n")
|
||||
|
||||
def test_list_item_sibling_key_non_bare(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k:\n - a: 1\n $b: 2\n")
|
||||
|
||||
# --- document-level rejections -----------------------------------------
|
||||
def test_block_scalar_folded_rejected(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset(">folded\n")
|
||||
|
||||
def test_block_scalar_literal_rejected(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("|literal\n")
|
||||
|
||||
def test_anchor_rejected(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k: &a x\n")
|
||||
|
||||
def test_ampersand_in_quoted_value_allowed(self) -> None:
|
||||
self.assertEqual({"k": "a & b"}, parse_yaml_subset('k: "a & b"\n'))
|
||||
|
||||
def test_yaml_tag_rejected(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("k: !!str x\n")
|
||||
|
||||
def test_only_comments_is_empty_mapping(self) -> None:
|
||||
self.assertEqual({}, parse_yaml_subset("# just a comment\n"))
|
||||
|
||||
def test_top_level_not_column_zero(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset(" k: 1\n")
|
||||
|
||||
def test_top_level_list_rejected(self) -> None:
|
||||
with self.assertRaises(YamlSubsetError):
|
||||
parse_yaml_subset("- a\n- b\n")
|
||||
|
||||
# --- frontmatter --------------------------------------------------------
|
||||
def test_frontmatter_empty_text(self) -> None:
|
||||
self.assertEqual(({}, ""), parse_frontmatter(""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user