feat: deliver versioned SIP config to Cells
This commit is contained in:
@@ -338,6 +338,38 @@ class AgentCallTests(unittest.TestCase):
|
||||
"agent-call.commands.v1", routing_key("tenant#c"), {"message": 2}
|
||||
)
|
||||
|
||||
def test_published_sip_directory_overrides_static_route(self) -> None:
|
||||
class Directory:
|
||||
def get_trunk(self, trunk_id: str) -> dict:
|
||||
return {
|
||||
"trunk_id": trunk_id,
|
||||
"status": "published",
|
||||
"revision": 7,
|
||||
"config": {
|
||||
"enabled": True,
|
||||
"codec_profile": {
|
||||
"allowed": ["PCMA"],
|
||||
"preferred": "PCMA",
|
||||
},
|
||||
"caller_ids": ["BD93205882"],
|
||||
"dial_prefix": "9001",
|
||||
"egress_pool_id": "egress-mock",
|
||||
"max_concurrency": 12,
|
||||
"max_cps": 4,
|
||||
"sip": {"transport": "udp"},
|
||||
},
|
||||
}
|
||||
|
||||
self.service.sip_readonly = Directory()
|
||||
route = self.service._route_config("route_policy_test")
|
||||
self.assertEqual(route["trunk_revision"], 7)
|
||||
self.assertEqual(route["dial_prefix"], "9001")
|
||||
self.assertEqual(route["max_concurrency"], 12)
|
||||
cell = self.service._resource_cell(route)
|
||||
self.assertIsNotNone(cell)
|
||||
if cell is not None:
|
||||
self.assertEqual(cell[0], "cell-a")
|
||||
|
||||
def test_profile_never_claims_real_ai(self) -> None:
|
||||
metrics = self.service.metrics()
|
||||
self.assertIn(
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from agent_call.cell_agent import (
|
||||
AsteriskConfigApplier,
|
||||
CellAgentError,
|
||||
CellAgentService,
|
||||
make_server,
|
||||
)
|
||||
from agent_call.sip_management import (
|
||||
SipManagementError,
|
||||
SipManagementService,
|
||||
_validate_trunk,
|
||||
)
|
||||
|
||||
|
||||
class FakeApplier:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.fail = False
|
||||
|
||||
def apply(self, trunk_id: str, config: dict[str, Any]) -> None:
|
||||
if self.fail:
|
||||
raise CellAgentError(502, "ASTERISK_RELOAD_FAILED", "reload failed")
|
||||
self.calls.append((trunk_id, config))
|
||||
|
||||
|
||||
class CellAgentTests(unittest.TestCase):
|
||||
def test_revision_hash_idempotency_and_gap_protection(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
applier = FakeApplier()
|
||||
service = CellAgentService(
|
||||
Path(directory) / "cell.sqlite3", "cell-a", applier
|
||||
)
|
||||
try:
|
||||
body = self._publication("cell-a", "trunk:a", 1)
|
||||
first = service.apply(body, "request-1")
|
||||
second = service.apply(body, "request-1-retry")
|
||||
self.assertFalse(first["idempotent"])
|
||||
self.assertTrue(second["idempotent"])
|
||||
self.assertEqual(len(applier.calls), 1)
|
||||
with self.assertRaisesRegex(CellAgentError, "next revision"):
|
||||
service.apply(
|
||||
self._publication("cell-a", "trunk:a", 3), "request-3"
|
||||
)
|
||||
bad = self._publication("cell-a", "trunk:a", 2)
|
||||
bad["config_sha256"] = "0" * 64
|
||||
with self.assertRaisesRegex(CellAgentError, "does not match"):
|
||||
service.apply(bad, "request-bad")
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
def test_http_endpoint_unquotes_path_and_rejects_mismatch(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = CellAgentService(
|
||||
Path(directory) / "cell.sqlite3", "cell-a", FakeApplier()
|
||||
)
|
||||
server = make_server(service, "127.0.0.1", 0)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
body = self._publication("cell-a", "trunk:a", 1)
|
||||
response = self._request(
|
||||
server.server_port,
|
||||
f"/v1/sip/trunks/{quote('trunk:a', safe='')}/apply",
|
||||
body,
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
mismatch = self._publication("cell-a", "other", 2)
|
||||
response = self._request(
|
||||
server.server_port,
|
||||
"/v1/sip/trunks/trunk%3Aa/apply",
|
||||
mismatch,
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
service.close()
|
||||
|
||||
def test_asterisk_applier_writes_codec_and_restores_on_reload_failure(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
|
||||
class RecordingApplier(AsteriskConfigApplier):
|
||||
def __init__(self, config_dir: str) -> None:
|
||||
super().__init__(config_dir)
|
||||
self.fail = False
|
||||
|
||||
def _reload(self) -> None:
|
||||
if self.fail:
|
||||
raise CellAgentError(
|
||||
502, "ASTERISK_RELOAD_FAILED", "reload failed"
|
||||
)
|
||||
|
||||
applier = RecordingApplier(directory)
|
||||
config = self._config()
|
||||
applier.apply("trunk:a", config)
|
||||
target = next(Path(directory).glob("*.conf"))
|
||||
original = target.read_text(encoding="utf-8")
|
||||
self.assertIn("allow=alaw", original)
|
||||
self.assertIn("from_user=BD93205882", original)
|
||||
applier.fail = True
|
||||
with self.assertRaises(CellAgentError):
|
||||
applier.apply(
|
||||
"trunk:a",
|
||||
{
|
||||
**config,
|
||||
"codec_profile": {"allowed": ["PCMU"], "preferred": "PCMU"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(target.read_text(encoding="utf-8"), original)
|
||||
|
||||
def test_real_management_activates_only_after_cell_ack(self) -> None:
|
||||
class Publisher:
|
||||
def __init__(self) -> None:
|
||||
self.fail = False
|
||||
self.calls: list[int] = []
|
||||
|
||||
def publish(
|
||||
self,
|
||||
cell: dict[str, Any],
|
||||
trunk_id: str,
|
||||
revision: int,
|
||||
config: dict[str, Any],
|
||||
request_id: str,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(revision)
|
||||
if self.fail:
|
||||
raise OSError("offline")
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
config,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
).hexdigest()
|
||||
return {
|
||||
"mode": "real",
|
||||
"cell_id": cell["cell_id"],
|
||||
"trunk_id": trunk_id,
|
||||
"revision": revision,
|
||||
"config_sha256": digest,
|
||||
"status": "applied",
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
publisher = Publisher()
|
||||
service = SipManagementService(
|
||||
Path(directory) / "sip.sqlite3", mode="real", publisher=publisher
|
||||
)
|
||||
try:
|
||||
service.upsert_cell(
|
||||
"cell-a",
|
||||
{
|
||||
"egress_pool_id": "egress-a",
|
||||
"codec_capabilities": ["PCMA"],
|
||||
"status": "healthy",
|
||||
"max_concurrency": 10,
|
||||
"management_url": "https://cell-a.internal:9443",
|
||||
},
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="cell-1",
|
||||
)
|
||||
service.upsert_trunk(
|
||||
"trunk:a",
|
||||
self._config(),
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="trunk-1",
|
||||
)
|
||||
published = service.publish_trunk(
|
||||
"trunk:a", expected_revision=1, actor="ops", request_id="pub-1"
|
||||
)
|
||||
self.assertEqual(published["active_revision"], 1)
|
||||
self.assertEqual(
|
||||
service.list_publications("trunk:a")[0]["status"], "applied"
|
||||
)
|
||||
publisher.fail = True
|
||||
service.upsert_trunk(
|
||||
"trunk:a",
|
||||
{**self._config(), "display_name": "Provider B"},
|
||||
expected_revision=1,
|
||||
actor="ops",
|
||||
request_id="trunk-2",
|
||||
)
|
||||
with self.assertRaisesRegex(SipManagementError, "every Cell"):
|
||||
service.publish_trunk(
|
||||
"trunk:a", expected_revision=2, actor="ops", request_id="pub-2"
|
||||
)
|
||||
self.assertEqual(service.get_trunk("trunk:a")["active_revision"], 1)
|
||||
self.assertEqual(
|
||||
service.list_publications("trunk:a")[0]["status"], "applied"
|
||||
)
|
||||
self.assertEqual(
|
||||
service.list_publications("trunk:a")[1]["status"], "failed"
|
||||
)
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
def test_real_publication_resumes_after_process_crash(self) -> None:
|
||||
class CrashOncePublisher:
|
||||
def __init__(self) -> None:
|
||||
self.crashed = False
|
||||
|
||||
def publish(
|
||||
self,
|
||||
cell: dict[str, Any],
|
||||
trunk_id: str,
|
||||
revision: int,
|
||||
config: dict[str, Any],
|
||||
request_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if not self.crashed:
|
||||
self.crashed = True
|
||||
raise KeyboardInterrupt
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
config,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
).hexdigest()
|
||||
return {
|
||||
"mode": "real",
|
||||
"cell_id": cell["cell_id"],
|
||||
"trunk_id": trunk_id,
|
||||
"revision": revision,
|
||||
"config_sha256": digest,
|
||||
"status": "applied",
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
publisher = CrashOncePublisher()
|
||||
service = SipManagementService(
|
||||
Path(directory) / "sip.sqlite3", mode="real", publisher=publisher
|
||||
)
|
||||
try:
|
||||
service.upsert_cell(
|
||||
"cell-a",
|
||||
{
|
||||
"egress_pool_id": "egress-a",
|
||||
"codec_capabilities": ["PCMA"],
|
||||
"status": "healthy",
|
||||
"max_concurrency": 10,
|
||||
"management_url": "https://cell-a.internal:9443",
|
||||
},
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="cell-1",
|
||||
)
|
||||
service.upsert_trunk(
|
||||
"trunk:a",
|
||||
self._config(),
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="trunk-1",
|
||||
)
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
service.publish_trunk(
|
||||
"trunk:a", expected_revision=1, actor="ops", request_id="pub-1"
|
||||
)
|
||||
self.assertEqual(service.get_trunk("trunk:a")["active_revision"], 0)
|
||||
resumed = service.publish_trunk(
|
||||
"trunk:a", expected_revision=1, actor="ops", request_id="pub-2"
|
||||
)
|
||||
self.assertEqual(resumed["active_revision"], 1)
|
||||
self.assertEqual(
|
||||
service.list_publications("trunk:a")[0]["status"], "applied"
|
||||
)
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
@staticmethod
|
||||
def _config() -> dict[str, Any]:
|
||||
return {
|
||||
"display_name": "Provider A",
|
||||
"enabled": True,
|
||||
"sip": {
|
||||
"host": "61.132.228.221",
|
||||
"port": 5060,
|
||||
"transport": "udp",
|
||||
"auth_mode": "ip",
|
||||
"register": False,
|
||||
},
|
||||
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
|
||||
"caller_ids": ["BD93205882"],
|
||||
"dial_prefix": "7089",
|
||||
"egress_pool_id": "egress-a",
|
||||
"max_concurrency": 100,
|
||||
"max_cps": 10,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _publication(cls, cell_id: str, trunk_id: str, revision: int) -> dict[str, Any]:
|
||||
config = _validate_trunk(trunk_id, cls._config())
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
config, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode()
|
||||
).hexdigest()
|
||||
return {
|
||||
"mode": "real",
|
||||
"cell_id": cell_id,
|
||||
"trunk_id": trunk_id,
|
||||
"revision": revision,
|
||||
"config": config,
|
||||
"config_sha256": digest,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _request(port: int, path: str, body: dict[str, Any]):
|
||||
encoded = json.dumps(body, ensure_ascii=False).encode()
|
||||
connection = HTTPConnection("127.0.0.1", port, timeout=3)
|
||||
connection.request(
|
||||
"POST",
|
||||
path,
|
||||
body=encoded,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": str(len(encoded)),
|
||||
"X-Request-ID": "http-test",
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
connection.close()
|
||||
return response
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,8 +22,22 @@ class ContractTests(unittest.TestCase):
|
||||
encoding="utf-8"
|
||||
)
|
||||
saas = (ROOT / "docs/contracts/saas.openapi.yaml").read_text(encoding="utf-8")
|
||||
sip_management = (
|
||||
ROOT / "docs/contracts/sip-management.openapi.yaml"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("openapi: 3.1.0", executor)
|
||||
self.assertIn("openapi: 3.1.0", saas)
|
||||
cell_agent = (ROOT / "docs/contracts/cell-agent.openapi.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("openapi: 3.1.0", sip_management)
|
||||
self.assertIn("openapi: 3.1.0", cell_agent)
|
||||
self.assertIn("CellManagementMtls", cell_agent)
|
||||
self.assertIn("/v1/sip/trunks/{trunk_id}/apply:", cell_agent)
|
||||
self.assertIn("SipAdminBearer", sip_management)
|
||||
self.assertIn("SaasTrunkReadBearer", sip_management)
|
||||
self.assertIn("/admin/v1/trunks/{trunk_id}/publish:", sip_management)
|
||||
self.assertIn("/readonly/v1/sip/trunks:", sip_management)
|
||||
self.assertNotIn("call.execute", executor)
|
||||
self.assertIn("/internal/v1/outbound/tasks/{task_id}/controls:", executor)
|
||||
self.assertIn(
|
||||
|
||||
@@ -247,8 +247,16 @@ class AsteriskTests(unittest.TestCase):
|
||||
"public_ip": cloud.PUBLIC_IP,
|
||||
"transport": "udp",
|
||||
"local_net": "10.1.0.0/16",
|
||||
"primary": {"host": "sip-a.test", "auth_mode": "ip"},
|
||||
"backup": {"host": "sip-b.test", "auth_mode": "ip"},
|
||||
"primary": {
|
||||
"host": "sip-a.test",
|
||||
"auth_mode": "ip",
|
||||
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
|
||||
},
|
||||
"backup": {
|
||||
"host": "sip-b.test",
|
||||
"auth_mode": "ip",
|
||||
"codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"},
|
||||
},
|
||||
}
|
||||
|
||||
def test_build_user_data_pins_image_and_does_not_print_secret(self):
|
||||
@@ -258,11 +266,27 @@ class AsteriskTests(unittest.TestCase):
|
||||
"x" * 32,
|
||||
)
|
||||
self.assertIn("ASTERISK_IMAGE=registry.example/asterisk@sha256:", content)
|
||||
self.assertIn("pjsip set logger on", content)
|
||||
self.assertIn("HTTP_CONF_B64=", content)
|
||||
self.assertNotIn("password=" + "x" * 32, content)
|
||||
with self.assertRaises(ValueError):
|
||||
user_data.immutable_image("registry.example/asterisk:latest")
|
||||
|
||||
def test_codec_profile_is_rendered_without_global_fallback(self):
|
||||
cfg = self.cfg()
|
||||
cfg["primary"]["codec_profile"] = {
|
||||
"allowed": ["PCMU", "PCMA"],
|
||||
"preferred": "PCMA",
|
||||
}
|
||||
files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
||||
self.assertIn("allow=alaw,ulaw", files["pjsip.conf"])
|
||||
cfg["primary"]["codec_profile"] = {
|
||||
"allowed": ["G729"],
|
||||
"preferred": "G729",
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
ast.render(cfg, {"ARI_PASSWORD": "x" * 32})
|
||||
|
||||
def test_private_ari_fixed_nat_and_recording_config(self):
|
||||
files = ast.render(self.cfg(), {"ARI_PASSWORD": "x" * 32})
|
||||
self.assertEqual(len(files), 5)
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import HTTPConnection
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from agent_call.sip_management import (
|
||||
ADMIN_AUDIENCE,
|
||||
READ_AUDIENCE,
|
||||
ConfigurationError,
|
||||
Principal,
|
||||
SipManagementService,
|
||||
load_token_map,
|
||||
make_server,
|
||||
validate_token_separation,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ADMIN_BEARER = "admin-test"
|
||||
READ_BEARER = "saas-test"
|
||||
|
||||
|
||||
class SipManagementTests(unittest.TestCase):
|
||||
def test_codec_mapping_and_token_domains_are_separate(self) -> None:
|
||||
admin = load_token_map(
|
||||
json.dumps(
|
||||
{
|
||||
ADMIN_BEARER: {
|
||||
"subject": "ops",
|
||||
"issuer": "ops-issuer",
|
||||
"audience": ADMIN_AUDIENCE,
|
||||
"scopes": ["*"],
|
||||
"trunk_ids": "*",
|
||||
}
|
||||
}
|
||||
),
|
||||
"admin",
|
||||
)
|
||||
read = load_token_map(
|
||||
json.dumps(
|
||||
{
|
||||
READ_BEARER: {
|
||||
"subject": "saas",
|
||||
"issuer": "saas-issuer",
|
||||
"audience": READ_AUDIENCE,
|
||||
"scopes": ["sip.trunk.read"],
|
||||
"trunk_ids": ["trunk-a"],
|
||||
}
|
||||
}
|
||||
),
|
||||
"read",
|
||||
)
|
||||
validate_token_separation(admin, read, {"scheduler-test": {}})
|
||||
self.assertNotEqual(set(admin), set(read))
|
||||
with self.assertRaises(ConfigurationError):
|
||||
validate_token_separation(admin, read, {ADMIN_BEARER: {}})
|
||||
|
||||
def test_persistent_versioned_publish_readonly_and_rollback(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SipManagementService(Path(directory) / "sip.sqlite3")
|
||||
try:
|
||||
service.upsert_cell(
|
||||
"cell-a",
|
||||
{
|
||||
"egress_pool_id": "egress-a",
|
||||
"codec_capabilities": ["PCMA", "PCMU"],
|
||||
"status": "healthy",
|
||||
"max_concurrency": 100,
|
||||
},
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="cell-1",
|
||||
)
|
||||
created = service.upsert_trunk(
|
||||
"trunk-a",
|
||||
self._trunk_payload(["PCMA"]),
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="trunk-1",
|
||||
)
|
||||
self.assertEqual(created["latest_revision"], 1)
|
||||
published = service.publish_trunk(
|
||||
"trunk-a", expected_revision=1, actor="ops", request_id="publish-1"
|
||||
)
|
||||
self.assertEqual(published["status"], "published")
|
||||
self.assertEqual(published["compatible_cell_ids"], ["cell-a"])
|
||||
self.assertEqual(
|
||||
service.list_publications("trunk-a")[0]["status"], "pending"
|
||||
)
|
||||
|
||||
readonly = service.get_readonly_trunk("trunk-a", frozenset({"trunk-a"}))
|
||||
self.assertEqual(
|
||||
readonly["config"]["codec_profile"]["allowed"], ["PCMA"]
|
||||
)
|
||||
self.assertNotIn("credential_ref", readonly["config"]["sip"])
|
||||
|
||||
updated = service.upsert_trunk(
|
||||
"trunk-a",
|
||||
self._trunk_payload(["PCMU"]),
|
||||
expected_revision=1,
|
||||
actor="ops",
|
||||
request_id="trunk-2",
|
||||
)
|
||||
self.assertEqual(updated["latest_revision"], 2)
|
||||
with self.assertRaisesRegex(Exception, "revision changed"):
|
||||
service.publish_trunk(
|
||||
"trunk-a",
|
||||
expected_revision=1,
|
||||
actor="ops",
|
||||
request_id="publish-old",
|
||||
)
|
||||
service.publish_trunk(
|
||||
"trunk-a", expected_revision=2, actor="ops", request_id="publish-2"
|
||||
)
|
||||
rolled_back = service.rollback_trunk(
|
||||
"trunk-a",
|
||||
1,
|
||||
expected_revision=2,
|
||||
actor="ops",
|
||||
request_id="rollback-1",
|
||||
)
|
||||
self.assertEqual(rolled_back["active_revision"], 3)
|
||||
self.assertEqual(
|
||||
rolled_back["active"]["codec_profile"]["allowed"], ["PCMA"]
|
||||
)
|
||||
self.assertEqual(
|
||||
[entry["action"] for entry in service.list_audit("trunk-a")],
|
||||
["upsert", "publish", "upsert", "publish", "rollback"],
|
||||
)
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
def test_state_survives_restart_and_secret_refs_are_not_exposed(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
database = Path(directory) / "sip.sqlite3"
|
||||
service = SipManagementService(database)
|
||||
service.upsert_cell(
|
||||
"cell-a",
|
||||
{
|
||||
"egress_pool_id": "egress-a",
|
||||
"codec_capabilities": ["PCMA"],
|
||||
"status": "healthy",
|
||||
"max_concurrency": 10,
|
||||
},
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="cell-1",
|
||||
)
|
||||
service.upsert_trunk(
|
||||
"trunk-a",
|
||||
{
|
||||
**self._trunk_payload(["PCMA"]),
|
||||
"sip": {
|
||||
**self._trunk_payload(["PCMA"])["sip"],
|
||||
"auth_mode": "digest",
|
||||
"credential_ref": "secret://provider-a",
|
||||
},
|
||||
},
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="trunk-1",
|
||||
)
|
||||
service.publish_trunk(
|
||||
"trunk-a", expected_revision=1, actor="ops", request_id="publish-1"
|
||||
)
|
||||
service.close()
|
||||
|
||||
reopened = SipManagementService(database)
|
||||
try:
|
||||
admin = reopened.get_trunk("trunk-a")
|
||||
readonly = reopened.get_readonly_trunk(
|
||||
"trunk-a", frozenset({"trunk-a"})
|
||||
)
|
||||
self.assertTrue(admin["active"]["credential_configured"])
|
||||
self.assertNotIn("credential_ref", admin["active"]["sip"])
|
||||
self.assertNotIn("credential_ref", readonly["config"]["sip"])
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
def test_openapi_separates_admin_and_saas_readonly_surfaces(self) -> None:
|
||||
contract = (ROOT / "docs/contracts/sip-management.openapi.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("SipAdminBearer", contract)
|
||||
self.assertIn("SaasTrunkReadBearer", contract)
|
||||
self.assertIn("/admin/v1/trunks/{trunk_id}/publish:", contract)
|
||||
self.assertIn("/readonly/v1/sip/trunks:", contract)
|
||||
self.assertNotIn("HTTP_TOKENS", contract)
|
||||
|
||||
def test_publish_rejects_without_compatible_cell(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SipManagementService(Path(directory) / "sip.sqlite3")
|
||||
try:
|
||||
service.upsert_cell(
|
||||
"cell-pcmu",
|
||||
{
|
||||
"egress_pool_id": "egress-a",
|
||||
"codec_capabilities": ["PCMU"],
|
||||
"status": "healthy",
|
||||
"max_concurrency": 10,
|
||||
},
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="cell-1",
|
||||
)
|
||||
service.upsert_trunk(
|
||||
"trunk-a",
|
||||
self._trunk_payload(["PCMA"]),
|
||||
expected_revision=0,
|
||||
actor="ops",
|
||||
request_id="trunk-1",
|
||||
)
|
||||
with self.assertRaisesRegex(Exception, "no healthy Cell"):
|
||||
service.publish_trunk(
|
||||
"trunk-a",
|
||||
expected_revision=1,
|
||||
actor="ops",
|
||||
request_id="publish-1",
|
||||
)
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
def test_http_readonly_token_cannot_write_or_read_admin_api(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SipManagementService(Path(directory) / "sip.sqlite3")
|
||||
admin_tokens = {
|
||||
ADMIN_BEARER: Principal(
|
||||
"ops",
|
||||
"ops-issuer",
|
||||
ADMIN_AUDIENCE,
|
||||
frozenset({"*"}),
|
||||
frozenset({"*"}),
|
||||
"admin",
|
||||
)
|
||||
}
|
||||
read_tokens = {
|
||||
READ_BEARER: Principal(
|
||||
"saas",
|
||||
"saas-issuer",
|
||||
READ_AUDIENCE,
|
||||
frozenset({"sip.trunk.read"}),
|
||||
frozenset({"trunk-a"}),
|
||||
"read",
|
||||
)
|
||||
}
|
||||
server = make_server(
|
||||
service,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
admin_tokens=admin_tokens,
|
||||
read_tokens=read_tokens,
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
status, body = self._request(
|
||||
base,
|
||||
"GET",
|
||||
"/healthz/live",
|
||||
bearer=None,
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["mode"], "mock")
|
||||
|
||||
status, _ = self._request(
|
||||
base,
|
||||
"PUT",
|
||||
"/admin/v1/trunks/trunk-a",
|
||||
bearer=READ_BEARER,
|
||||
headers={"If-Match": "0", "X-Request-ID": "write-1"},
|
||||
body=self._trunk_payload(["PCMA"]),
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
status, _ = self._request(
|
||||
base,
|
||||
"GET",
|
||||
"/readonly/v1/sip/trunks",
|
||||
bearer=ADMIN_BEARER,
|
||||
)
|
||||
self.assertEqual(status, 401)
|
||||
status, _ = self._request(
|
||||
base,
|
||||
"POST",
|
||||
"/readonly/v1/sip/trunks/trunk-a",
|
||||
bearer=READ_BEARER,
|
||||
body={},
|
||||
)
|
||||
self.assertEqual(status, 405)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
service.close()
|
||||
|
||||
def test_http_admin_publish_and_readonly_uses_active_revision(self) -> None:
|
||||
with TemporaryDirectory() as directory:
|
||||
service = SipManagementService(Path(directory) / "sip.sqlite3")
|
||||
admin_tokens = {
|
||||
ADMIN_BEARER: Principal(
|
||||
"ops",
|
||||
"ops-issuer",
|
||||
ADMIN_AUDIENCE,
|
||||
frozenset({"*"}),
|
||||
frozenset({"*"}),
|
||||
"admin",
|
||||
)
|
||||
}
|
||||
read_tokens = {
|
||||
READ_BEARER: Principal(
|
||||
"saas",
|
||||
"saas-issuer",
|
||||
READ_AUDIENCE,
|
||||
frozenset({"sip.trunk.read"}),
|
||||
frozenset({"trunk-a"}),
|
||||
"read",
|
||||
)
|
||||
}
|
||||
server = make_server(
|
||||
service,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
admin_tokens=admin_tokens,
|
||||
read_tokens=read_tokens,
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
status, _ = self._request(
|
||||
base,
|
||||
"PUT",
|
||||
"/admin/v1/cells/cell-a",
|
||||
bearer=ADMIN_BEARER,
|
||||
headers={"If-Match": "0", "X-Request-ID": "cell-1"},
|
||||
body={
|
||||
"egress_pool_id": "egress-a",
|
||||
"codec_capabilities": ["PCMA", "PCMU"],
|
||||
"status": "healthy",
|
||||
"max_concurrency": 10,
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
status, _ = self._request(
|
||||
base,
|
||||
"PUT",
|
||||
"/admin/v1/trunks/trunk-a",
|
||||
bearer=ADMIN_BEARER,
|
||||
headers={"If-Match": "0", "X-Request-ID": "trunk-1"},
|
||||
body=self._trunk_payload(["PCMA"]),
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
status, _ = self._request(
|
||||
base,
|
||||
"POST",
|
||||
"/admin/v1/trunks/trunk-a/publish",
|
||||
bearer=ADMIN_BEARER,
|
||||
headers={"If-Match": "1", "X-Request-ID": "publish-1"},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
status, body = self._request(
|
||||
base,
|
||||
"GET",
|
||||
"/readonly/v1/sip/trunks/trunk-a",
|
||||
bearer=READ_BEARER,
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["revision"], 1)
|
||||
self.assertEqual(body["config"]["codec_profile"]["allowed"], ["PCMA"])
|
||||
self.assertNotIn("latest", body)
|
||||
self.assertNotIn("compatible_cell_ids", body)
|
||||
|
||||
status, _ = self._request(
|
||||
base,
|
||||
"PUT",
|
||||
"/admin/v1/trunks/trunk-a",
|
||||
bearer=ADMIN_BEARER,
|
||||
headers={"If-Match": "1", "X-Request-ID": "trunk-2"},
|
||||
body=self._trunk_payload(["PCMU"]),
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
status, body = self._request(
|
||||
base,
|
||||
"GET",
|
||||
"/readonly/v1/sip/trunks/trunk-a",
|
||||
bearer=READ_BEARER,
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["revision"], 1)
|
||||
self.assertEqual(body["config"]["codec_profile"]["allowed"], ["PCMA"])
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
service.close()
|
||||
|
||||
@staticmethod
|
||||
def _trunk_payload(codecs: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"display_name": "Provider A",
|
||||
"enabled": True,
|
||||
"sip": {
|
||||
"host": "61.132.228.221",
|
||||
"port": 5060,
|
||||
"transport": "udp",
|
||||
"auth_mode": "ip",
|
||||
"register": False,
|
||||
},
|
||||
"codec_profile": {"allowed": codecs, "preferred": codecs[0]},
|
||||
"caller_ids": ["BD93205882"],
|
||||
"dial_prefix": "7089",
|
||||
"egress_pool_id": "egress-a",
|
||||
"max_concurrency": 100,
|
||||
"max_cps": 10,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _request(
|
||||
base: str,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
bearer: str | None,
|
||||
headers: dict[str, str] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
request_headers = {"Accept": "application/json"}
|
||||
if bearer:
|
||||
request_headers["Authorization"] = f"Bearer {bearer}"
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
request_headers["Content-Type"] = "application/json"
|
||||
parsed = urlsplit(base)
|
||||
host = parsed.hostname
|
||||
port = parsed.port
|
||||
if parsed.scheme != "http" or host != "127.0.0.1" or port is None:
|
||||
raise AssertionError("test helper only permits a local HTTP server")
|
||||
connection = HTTPConnection(host, port, timeout=3)
|
||||
try:
|
||||
connection.request(method, path, body=data, headers=request_headers)
|
||||
response = connection.getresponse()
|
||||
return response.status, json.loads(response.read())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user