imsd/tests/Messages/main.cpp

407 lines
23 KiB
C++
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Messages unit tests — every builder compared byte-for-byte against a
// reference message cross-generated from the Python VoLTE stack with pinned
// tags/branches/SPIs. A divergence here is a wire-format change, i.e. a
// registration or call that the commercial IMS core may reject; these are the
// hardest pins in the project. Two deliberate post-parity divergences:
// the REGISTER Contact carries +sip.instance and the MMTEL ICSI feature tag
// (the Python stack registered without them, which made the network divert
// incoming calls to voicemail — see ContactFeatures in Imsd:Messages), and
// every ICSI feature-tag value is percent-encoded (urn%3Aurn-7%3A...) — the
// TS 24.229 coding and what the stock modem sends on the wire; the Python
// stack's plain-colon form is a different string under RFC 3841 matching
// and left terminating access-domain selection routing incoming calls to
// CS fallback (observed live 2026-07-20).
import std;
import Imsd;
using namespace imsd::msg;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
void Eq(std::string_view got, std::string_view want, std::string_view msg) {
if (got != want) {
std::println(std::cerr, "FAIL: {}\n--- got ---\n{}\n--- want ---\n{}", msg, got, want);
++Failures;
}
}
Context MakeCtx() {
Context c;
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
c.local = "2001:db8::db43";
c.route = "<sip:[2001:db8::105]:6000;lr>";
c.ppi = "tel:+31611111111";
c.securityServer =
"ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; "
"spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000";
c.instanceId = "urn:gsma:imei:35999999-000000-1";
c.panInfo = "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0";
return c;
}
constexpr std::string_view Reg1 =
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKREGBRANCH123456;rport\r\n"
"Max-Forwards: 70\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: REGCALLID@2001:db8::db43\r\n"
"CSeq: 1 REGISTER\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:5060>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"\", response=\"\"\r\n"
"Require: sec-agree\r\n"
"Proxy-Require: sec-agree\r\n"
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
"Supported: sec-agree, path\r\n"
"Expires: 600000\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Content-Length: 0\r\n"
"\r\n";
constexpr std::string_view Reg2 =
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKREGBRANCH123456;rport\r\n"
"Max-Forwards: 70\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: REGCALLID@2001:db8::db43\r\n"
"CSeq: 2 REGISTER\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"NONCEXYZ\", qop=auth, nc=00000001, cnonce=\"CNONCE0123456789\", response=\"RESP0000\", algorithm=AKAv1-MD5\r\n"
"Require: sec-agree\r\n"
"Proxy-Require: sec-agree\r\n"
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Supported: sec-agree, path\r\n"
"Expires: 600000\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Content-Length: 0\r\n"
"\r\n";
constexpr std::string_view Sdp =
"v=0\r\n"
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 97 99 98 100\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:99 AMR/8000/1\r\n"
"a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=rtpmap:100 telephone-event/8000\r\n"
"a=fmtp:100 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
constexpr std::string_view Invite =
"INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKINVBRANCH0000000000;rport\r\n"
"Max-Forwards: 70\r\n"
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
2026-09-08 20:37:47 +02:00
"From: <tel:+31611111111>;tag=ITAG123456\r\n"
"To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
"Call-ID: CALLID789@2001:db8::db43\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Supported: 100rel, timer\r\n"
"Accept-Contact: *;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
"P-Preferred-Identity: <tel:+31611111111>\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Proxy-Require: sec-agree\r\n"
"Require: sec-agree\r\n"
"Session-Expires: 1800;refresher=uac\r\n"
"Min-SE: 90\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 466\r\n"
"\r\n"
"v=0\r\n"
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 97 99 98 100\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:99 AMR/8000/1\r\n"
"a=fmtp:99 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=rtpmap:100 telephone-event/8000\r\n"
"a=fmtp:100 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
constexpr std::string_view Subscribe =
"SUBSCRIBE sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKSUBBRANCH0000000000;rport\r\n"
"Max-Forwards: 70\r\n"
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: SUBCALLID@2001:db8::db43\r\n"
"CSeq: 1 SUBSCRIBE\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"
"Event: reg\r\n"
"Accept: application/reginfo+xml\r\n"
"Expires: 600000\r\n"
"P-Preferred-Identity: <tel:+31611111111>\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Proxy-Require: sec-agree\r\n"
"Require: sec-agree\r\n"
"Content-Length: 0\r\n"
"\r\n";
}
int main() {
Context c = MakeCtx();
// ---- REGISTER (initial + protected), byte-exact vs Python
Eq(BuildRegisterInitial(c, "REGCALLID@2001:db8::db43", "FTAG5678",
"REGBRANCH123456", 7449812u, 176466735u),
Reg1, "unprotected REGISTER");
{
std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000");
Eq(BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678",
"REGBRANCH123456", 2, 7449812u, 176466735u, auth),
Reg2, "protected REGISTER");
}
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// ---- protected-leg transport follows Context.transport (SIP_TRANSPORT=udp,
// or the auto fallback): every protected request's Via says UDP, the
// challenge's Via stays UDP either way, and TCP is the untouched default.
{
Context cu = c;
cu.transport = "UDP";
std::string auth = AuthAka(cu, "NONCEXYZ", "CNONCE0123456789", "RESP0000");
std::string r2u = BuildRegisterProtected(cu, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 2, 7449812u, 176466735u, auth);
Check(r2u.contains("\r\nVia: SIP/2.0/UDP [2001:db8::db43]:45062;branch=z9hG4bKREGBRANCH123456;rport\r\n"), "protected REGISTER Via follows transport=UDP");
Check(!r2u.contains("SIP/2.0/TCP"), "no TCP token left in a UDP protected REGISTER");
Check(BuildSubscribeReg(cu, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000").contains("\r\nVia: SIP/2.0/UDP [2001:db8::db43]:45062;"), "SUBSCRIBE Via follows transport=UDP");
Check(BuildRegisterInitial(cu, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 7449812u, 176466735u).contains("\r\nVia: SIP/2.0/UDP [2001:db8::db43]:5060;"), "initial REGISTER Via is UDP regardless");
Check(BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 2, 7449812u, 176466735u, auth).contains("\r\nVia: SIP/2.0/TCP [2001:db8::db43]:45062;"), "default transport stays TCP");
}
// ---- RuriFor variants
Eq(RuriFor("1233", c.id.domain), "sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri short code");
Eq(RuriFor("+31612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri E.164");
Eq(RuriFor("0612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri Dutch national");
Eq(RuriFor("0031612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri 00 intl");
// ---- SUBSCRIBE (reg event, RFC 3680) — no Python counterpart; pinned
// against the shape of the other protected in-flow requests
Eq(BuildSubscribeReg(c, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000"), Subscribe, "reg-event SUBSCRIBE (no aor: temporary IMPU fallback)");
{
Context c2 = MakeCtx();
c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org";
std::string sub = BuildSubscribeReg(c2, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000");
Check(sub.starts_with("SUBSCRIBE sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"), "reg-event SUBSCRIBE aor R-URI");
Check(sub.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"), "reg-event SUBSCRIBE aor From");
Check(sub.contains("\r\nTo: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>\r\n"), "reg-event SUBSCRIBE aor To");
}
{
// no identity learned yet: P-Preferred-Identity is omitted, not "<>"
Context c3 = MakeCtx();
c3.ppi.clear();
Check(!BuildSubscribeReg(c3, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000") .contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in SUBSCRIBE");
Dialog d;
d.ruri = RuriFor("1233", c3.id.domain);
d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib";
Check(!BuildInvite(c3, d, "v=0\r\n", false).contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in INVITE");
}
// ---- INVITE, byte-exact vs Python
{
Dialog d;
d.ruri = RuriFor("1233", c.id.domain);
d.callid = "CALLID789@2001:db8::db43";
d.itag = "ITAG123456";
d.invBranch = "INVBRANCH0000000000";
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer");
}
2026-09-08 20:37:47 +02:00
// ---- From carries our PUBLIC identity, never the IMSI-derived temporary
// IMPU once anything better is known (ledger F1: a Telia P-CSCF showed
// the reporter's IMSI to the callee). aor > tel: ppi > temp IMPU; the
// same URI on every request of the dialog; a UAS signs with the
// INVITE's To.
{
Dialog d;
d.ruri = RuriFor("1233", c.id.domain);
d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib";
Context c2 = MakeCtx();
c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org";
std::string inv = BuildInvite(c2, d, "v=0\r\n", false);
Check(inv.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "INVITE From is the registered sip: identity when known");
Check(!inv.contains("001010123456789@ims"), "INVITE never carries the IMSI IMPU once the aor is known");
Check(inv.contains("\r\nP-Preferred-Identity: <tel:+31611111111>\r\n"), "P-Preferred-Identity unchanged");
Check(BuildCancel(c2, d).contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "CANCEL From follows");
Check(BuildAckNon2xx(c2, d, "<sip:x@y>;tag=r").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "non-2xx ACK From follows");
d.dialogTo = "<sip:x@y>;tag=r"; d.remoteTarget = "sip:x@[2001:db8::9]:5060";
Check(BuildAck2xx(c2, d, "ab", "<sip:x@y>;tag=r").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "2xx ACK From follows");
Check(BuildInDialog(c2, d, "BYE", 2, "bb").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "BYE From follows");
// only tel: known (pre-aor): the tel: identity, as the pinned INVITE shows
Check(BuildInvite(c, d, "v=0\r\n", false).contains("\r\nFrom: <tel:+31611111111>;tag=it\r\n"), "INVITE From falls back to the tel: identity");
// nothing learned yet: the temporary IMPU is all there is
Context c4 = MakeCtx();
c4.ppi.clear();
Check(BuildInvite(c4, d, "v=0\r\n", false).contains("\r\nFrom: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"), "INVITE From is the temporary IMPU only before any identity is learned");
// UAS: the dialog's local URI is the INVITE's To, whatever we registered as
Dialog du = d;
du.localUri = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone";
Check(BuildInDialog(c4, du, "BYE", 1, "cc").contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag=it\r\n"), "UAS BYE From is the INVITE's To URI");
}
// ---- emergency INVITE: byte-identical to the plain INVITE except the
// Request-URI and To carry the service URN (RFC 5031 / TS 24.229 5.1.6)
{
Dialog d;
d.ruri = std::string(SosUrn);
d.callid = "CALLID789@2001:db8::db43";
d.itag = "ITAG123456";
d.invBranch = "INVBRANCH0000000000";
std::string want(Invite);
auto swap = [&](std::string_view from, std::string_view to) {
std::size_t at = want.find(from);
Check(at != std::string::npos, "sos pin template line present");
if (at != std::string::npos) want.replace(at, from.size(), to);
};
swap("INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n",
"INVITE urn:service:sos SIP/2.0\r\n");
swap("To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n",
"To: <urn:service:sos>\r\n");
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), want, "emergency INVITE (urn:service:sos)");
}
// ---- IsEmergency classification
Check(IsEmergency("112"), "112 is emergency");
Check(IsEmergency("911"), "911 is emergency");
Check(IsEmergency("1 1 2"), "space separators stripped");
Check(IsEmergency("1-1-2"), "dash separators stripped");
Check(!IsEmergency("0112"), "prefixed 112 is not emergency");
Check(!IsEmergency("1120"), "suffixed 112 is not emergency");
Check(!IsEmergency("+31612345678"), "E.164 number is not emergency");
Check(!IsEmergency(""), "empty string is not emergency");
Check(!IsEmergency("199"), "extra number not classified by default");
{
std::vector<std::string> extra = {"199", "088"};
Check(IsEmergency("199", extra), "EMERGENCY_NUMBERS extra matches");
Check(IsEmergency("088", extra), "second extra matches");
Check(!IsEmergency("19", extra), "extra requires exact match");
}
// ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks)
{
Dialog d;
d.ruri = RuriFor("1233", c.id.domain);
d.callid = "CID@x";
d.itag = "it";
d.invBranch = "ib";
d.dialogTo = "<sip:1233@x>;tag=remote99";
d.remoteTarget = "sip:termgw@2001:db8::9";
d.recordRoute = {"<sip:rr1;lr>", "<sip:rr2;lr>"};
// dialog route = reversed record-route
Check(DialogRoute(c, d) == "<sip:rr2;lr>, <sip:rr1;lr>", "dialog route reversed");
std::array<std::string, 1> rack = {"RAck: 42 1 INVITE"};
std::string prack = BuildInDialog(c, d, "PRACK", 2, "prbranch", rack);
Check(prack.starts_with("PRACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "PRACK request-URI = remote target");
Check(prack.contains("Route: <sip:rr2;lr>, <sip:rr1;lr>\r\n"), "PRACK dialog route");
Check(prack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "PRACK To has remote tag");
Check(prack.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq");
Check(prack.contains("RAck: 42 1 INVITE\r\n"), "PRACK RAck");
Check(prack.ends_with("Content-Length: 0\r\n\r\n"), "PRACK empty body");
std::string bye = BuildInDialog(c, d, "BYE", 3, "byebranch");
Check(bye.starts_with("BYE sip:termgw@2001:db8::9 SIP/2.0\r\n"), "BYE line");
Check(bye.contains("CSeq: 3 BYE\r\n"), "BYE CSeq");
std::string ack = BuildAck2xx(c, d, "ackbranch", "<ignored>");
Check(ack.starts_with("ACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "ACK 2xx target");
Check(ack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "ACK 2xx dialog To");
Check(ack.contains("CSeq: 1 ACK\r\n"), "ACK CSeq 1");
std::string cancel = BuildCancel(c, d);
Check(cancel.starts_with(std::format("CANCEL {} SIP/2.0\r\n", d.ruri)), "CANCEL request-URI = ruri");
Check(cancel.contains("branch=z9hG4bKib;rport\r\n"), "CANCEL reuses INVITE branch");
Check(cancel.contains("CSeq: 1 CANCEL\r\n"), "CANCEL CSeq copies INVITE");
}
{
// non-2xx ACK uses the INVITE branch + registration route + resp To
Dialog d;
d.ruri = "sip:x@y"; d.callid = "CID"; d.itag = "it"; d.invBranch = "ib9";
std::string ack = BuildAckNon2xx(c, d, "<sip:x@y>;tag=z");
Check(ack.contains("branch=z9hG4bKib9;rport\r\n"), "non-2xx ACK INVITE branch");
Check(ack.contains(std::format("Route: {}\r\n", c.route)), "non-2xx ACK registration route");
Check(ack.contains("To: <sip:x@y>;tag=z\r\n"), "non-2xx ACK echoes response To");
}
// ---- 200 OK response echoes the request's dialog headers, in order
{
std::string req =
"BYE sip:me@here SIP/2.0\r\n"
"Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"
"From: <sip:them@far>;tag=T\r\n"
"To: <sip:me@here>;tag=M\r\n"
"Call-ID: xyz@far\r\n"
"CSeq: 7 BYE\r\n"
"Content-Length: 0\r\n\r\n";
std::string r = BuildResponse200(c, req);
Check(r.starts_with("SIP/2.0 200 OK\r\n"), "200 status line");
Check(r.contains("Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"), "200 echoes Via");
Check(r.contains("Call-ID: xyz@far\r\n"), "200 echoes Call-ID");
Check(r.contains("CSeq: 7 BYE\r\n"), "200 echoes CSeq");
Check(r.ends_with("Content-Length: 0\r\n\r\n"), "200 empty body");
// with an SDP answer: our Contact + application/sdp appear
std::string r2 = BuildResponse200(c, req, Sdp);
Check(r2.contains("Content-Type: application/sdp\r\n"), "200 w/ SDP content-type");
Check(r2.contains("Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"), "200 w/ SDP includes our Contact");
Check(r2.ends_with(Sdp), "200 w/ SDP body");
}
// ---- s53 oracle diff: UUID Contact user-part + User-Agent ------------
{
Context c = MakeCtx();
c.contactUser = "029dd36c-857c-4270-ad7f-0ae70d31d50f";
c.userAgent = "Fairphone_Fairphone 6_FP6.QREL.16.82.0";
std::string r1 = BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2);
Check(r1.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:5060>"), "reg1 Contact carries contactUser, not the IMSI");
Check(!r1.contains("sip:001010123456789@[2001:db8::db43]"), "reg1 has no IMSI-user Contact when contactUser set");
Check(r1.contains("From: <sip:001010123456789@ims.mnc001.mcc001"), "reg1 From/AOR stays the IMSI IMPU");
Check(r1.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg1 User-Agent emitted");
std::string r2 = BuildRegisterProtected(c, "cid@h", "ft", "br", 2, 1, 2, "Authorization: x");
Check(r2.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:45062>"), "reg2 Contact carries contactUser at the protected port");
Check(r2.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg2 User-Agent emitted");
c.userAgent.clear();
Check(!BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2) .contains("User-Agent:"), "empty userAgent omits the header");
}
if (Failures == 0) std::println("Messages: all tests passed");
return Failures;
}