Testing harness¶
The siphon_sdk.testing module lets you unit-test SIPhon scripts with pytest,
no Rust binary required. The harness installs the mock siphon module, loads
your script, feeds it simulated SIP messages, and returns a result object that
records what the handler did.
from siphon_sdk.testing import SipTestHarness
def test_register_challenges_unauthenticated():
harness = SipTestHarness()
harness.load_script("scripts/proxy_default.py")
result = harness.send_request("REGISTER", "sip:alice@example.com")
assert result.action == "reply"
assert result.status_code == 401
SipTestHarness¶
High-level test harness for SIPhon scripts.
Usage::
harness = SipTestHarness(local_domains=["example.com"])
harness.load_script("scripts/proxy_default.py")
result = harness.send_request("REGISTER", "sip:alice@example.com",
from_uri="sip:alice@example.com")
assert result.status_code == 401
The harness:
1. Installs the mock siphon module
2. Loads and executes user scripts (registering their decorators)
3. Dispatches mock SIP messages to registered handlers
4. Returns structured results for assertion
hss
property
¶
Access a convenience MockHss wired to this harness.
Lazily created on first access.
pcrf
property
¶
Access a convenience MockPcrf wired to this harness.
Lazily created on first access.
reset
¶
Reset all mock state between tests.
Clears: handlers, registrar, auth, cache, log, rtpengine.
load_script
¶
Load and execute a SIPhon script, registering its handlers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Path to the Python script file. |
required |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the script file doesn't exist. |
load_source
¶
Load a script from a string (useful for inline test scripts).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str
|
Python source code. |
required |
name
|
str
|
Module name for error messages. |
'<test>'
|
send_request
¶
send_request(
method: str = "INVITE",
ruri: Union[str, SipUri] = "sip:bob@example.com",
*,
from_uri: Union[
str, SipUri, None
] = "sip:alice@example.com",
to_uri: Union[str, SipUri, None] = None,
from_tag: Optional[str] = None,
to_tag: Optional[str] = None,
call_id: Optional[str] = None,
cseq: Optional[tuple[int, str]] = None,
max_forwards: int = 70,
body: Optional[bytes] = None,
content_type: Optional[str] = None,
transport: str = "udp",
source_ip: str = "127.0.0.1",
user_agent: Optional[str] = None,
auth_user: Optional[str] = None,
contact_expires: Optional[int] = None,
event: Optional[str] = None,
headers: Optional[dict[str, str]] = None
) -> RequestResult
Send a mock SIP request and return the handler's result.
Dispatches to handlers registered via @proxy.on_request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
SIP method (e.g. |
'INVITE'
|
ruri
|
Union[str, SipUri]
|
Request-URI. |
'sip:bob@example.com'
|
from_uri
|
Union[str, SipUri, None]
|
From header URI. |
'sip:alice@example.com'
|
to_uri
|
Union[str, SipUri, None]
|
To header URI (defaults to |
None
|
from_tag
|
Optional[str]
|
From-tag (auto-generated if |
None
|
to_tag
|
Optional[str]
|
To-tag ( |
None
|
call_id
|
Optional[str]
|
Call-ID (auto-generated if |
None
|
cseq
|
Optional[tuple[int, str]]
|
CSeq tuple (auto-generated if |
None
|
max_forwards
|
int
|
Max-Forwards value. |
70
|
body
|
Optional[bytes]
|
Message body bytes. |
None
|
content_type
|
Optional[str]
|
Content-Type header. |
None
|
transport
|
str
|
Transport protocol. |
'udp'
|
source_ip
|
str
|
Source IP address. |
'127.0.0.1'
|
user_agent
|
Optional[str]
|
User-Agent header. |
None
|
auth_user
|
Optional[str]
|
Pre-authenticated username. |
None
|
contact_expires
|
Optional[int]
|
Contact expires value. |
None
|
event
|
Optional[str]
|
Event header value. |
None
|
headers
|
Optional[dict[str, str]]
|
Additional headers dict. |
None
|
Returns:
| Type | Description |
|---|---|
RequestResult
|
class: |
send_reply
¶
send_reply(
request: Optional[Request] = None,
status_code: int = 200,
reason: str = "OK",
*,
from_uri: Union[str, SipUri, None] = None,
to_uri: Union[str, SipUri, None] = None,
call_id: Optional[str] = None,
body: Optional[bytes] = None,
content_type: Optional[str] = None,
headers: Optional[dict[str, str]] = None
) -> ReplyResult
Send a mock SIP reply and return the handler's result.
Dispatches to handlers registered via @proxy.on_reply.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Optional[Request]
|
Original request (auto-created if |
None
|
status_code
|
int
|
SIP status code (e.g. 200, 404). |
200
|
reason
|
str
|
Reason phrase. |
'OK'
|
from_uri
|
Union[str, SipUri, None]
|
From URI. |
None
|
to_uri
|
Union[str, SipUri, None]
|
To URI. |
None
|
call_id
|
Optional[str]
|
Call-ID. |
None
|
body
|
Optional[bytes]
|
Response body. |
None
|
content_type
|
Optional[str]
|
Content-Type. |
None
|
headers
|
Optional[dict[str, str]]
|
Additional headers. |
None
|
Returns:
| Type | Description |
|---|---|
ReplyResult
|
class: |
send_cancel
¶
Dispatch a CANCEL teardown to @proxy.on_cancel handlers.
Simulates a relayed INVITE being CANCELled before any final response.
The handler receives the original INVITE request. Fire-and-forget:
there is no reply/relay gating — assert on side effects (e.g.
harness.rtpengine deletes, harness.diameter Rx STR).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Optional[Request]
|
Original INVITE (auto-created with |
None
|
**kwargs
|
Any
|
Passed to the :class: |
{}
|
send_invite
¶
Send a B2BUA INVITE event.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call
|
Optional[Call]
|
Call object (auto-created if |
None
|
**kwargs
|
Any
|
Passed to :class: |
{}
|
Returns:
| Type | Description |
|---|---|
CallResult
|
class: |
send_answer
¶
Send a B2BUA answer event.
send_failure
¶
send_failure(
call: Optional[Call] = None,
code: int = 486,
reason: str = "Busy Here",
**kwargs: Any
) -> CallResult
Send a B2BUA failure event.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call
|
Optional[Call]
|
Call object. |
None
|
code
|
int
|
Failure status code. |
486
|
reason
|
str
|
Failure reason phrase. |
'Busy Here'
|
send_bye
¶
Send a B2BUA BYE event.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call
|
Optional[Call]
|
Call object. |
None
|
initiator_side
|
str
|
|
'a'
|
send_call_cancel
¶
Dispatch a CANCEL teardown to @b2bua.on_cancel handlers.
Simulates an unanswered call (Calling/Ringing) being CANCELled. The
handler receives the :class:Call. Fire-and-forget — assert on side
effects (e.g. harness.rtpengine deletes).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call
|
Optional[Call]
|
Call object (auto-created in |
None
|
**kwargs
|
Any
|
Passed to the :class: |
{}
|
send_refer
¶
send_refer(
call: Optional[Call] = None,
refer_to: str = "sip:+15550111@example.com",
refer_replaces: Optional[dict] = None,
**kwargs: Any
) -> CallResult
Dispatch a REFER (call transfer) to @b2bua.on_refer handlers.
Mirrors :meth:send_invite for the transfer path. Builds an answered
:class:Call carrying the transfer target on :attr:Call.refer_to
(and :attr:Call.refer_replaces for an attended transfer), then
dispatches it to the registered @b2bua.on_refer handler with a
single (call,) argument — a REFER is a request, so there is no
reply object. Supports both sync and async handlers.
Note
With no registered handler this returns an empty
:class:CallResult (no action recorded). In production the
Rust dispatcher locally rejects an un-handled REFER (501 / 603) —
that default is a dispatcher concern, out of scope for the mock.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call
|
Optional[Call]
|
Call object (auto-created answered if |
None
|
refer_to
|
str
|
The Refer-To URI (transfer target). |
'sip:+15550111@example.com'
|
refer_replaces
|
Optional[dict]
|
Optional attended-transfer dict with |
None
|
**kwargs
|
Any
|
Passed to the :class: |
{}
|
Returns:
| Type | Description |
|---|---|
CallResult
|
class: |
RequestResult¶
The result of harness.send_request(...).
Result of sending a request through the harness.
Provides convenient access to the primary action the handler took, plus the full list of actions and the request/reply objects.
ReplyResult¶
The result of feeding a response through @proxy.on_reply.
Result of sending a reply through the harness.
CallResult¶
The result of driving a B2BUA call.
Result of sending a B2BUA event through the harness.
MockHss¶
An in-process HSS stub for exercising Diameter Cx/Sh flows.
High-level mock HSS for IMS script testing.
Combines subscriber data management with automated Diameter Cx response
generation. Wires into MockDiameter and MockAuth so test scripts
get realistic IMS registration and routing flows.
Usage::
harness = SipTestHarness(local_domains=["ims.example.com"])
hss = harness.hss
hss.add_subscriber(
impi="alice@ims.example.com",
impu="sip:alice@ims.example.com",
server_name="sip:scscf.ims.example.com:6060",
)
harness.load_script("examples/ims_icscf.py")
result = harness.send_request("REGISTER", "sip:ims.example.com",
from_uri="sip:alice@ims.example.com")
assert result.was_relayed
assert result.next_hop == "sip:scscf.ims.example.com:6060"
add_subscriber
¶
add_subscriber(
impi: str,
impu: str,
server_name: Optional[str] = None,
ifc_xml: Optional[str] = None,
) -> None
Register a subscriber in the mock HSS.
This configures the mock Diameter Cx responses so that:
- diameter.cx_uar(impu) returns the assigned server_name
- diameter.cx_lir(impu) returns the serving server_name
- diameter.cx_sar(impu) returns success with ifc_xml
- auth.require_aka_digest() / auth.require_ims_digest()
auto-passes (returns True)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
impi
|
str
|
IMS Private Identity (e.g. |
required |
impu
|
str
|
IMS Public Identity (e.g. |
required |
server_name
|
Optional[str]
|
Assigned S-CSCF URI. |
None
|
ifc_xml
|
Optional[str]
|
Initial Filter Criteria XML for the user profile. |
None
|
remove_subscriber
¶
Remove a subscriber from the mock HSS.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
impu
|
str
|
IMS Public Identity to remove. |
required |
set_auth_failure
¶
Configure authentication to fail for a subscriber.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
impu
|
str
|
IMS Public Identity. |
required |
MockPcrf¶
An in-process PCRF stub for exercising Diameter Rx flows.
High-level mock PCRF for IMS P-CSCF testing.
Simulates a Policy and Charging Rules Function for Rx interface testing.
Configures MockDiameter Rx responses for QoS resource operations.
Usage::
harness = SipTestHarness(local_domains=["ims.example.com"])
pcrf = harness.pcrf
# PCRF will accept all AAR requests
pcrf.accept_all()
# Or reject specific sessions
pcrf.reject_session("rx-sess-1", result_code=5003)
reject_all
¶
Configure the PCRF to reject all Rx AAR requests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result_code
|
int
|
Diameter result code for rejection (default 5003 = DIAMETER_AUTHORIZATION_REJECTED). |
5003
|
reject_session
¶
Reject a specific Rx session.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Rx session ID to reject. |
required |
result_code
|
int
|
Diameter result code. |
5003
|