imsd 0.2.7 — initial public snapshot
Userspace VoLTE/IMS daemon for mainline Linux phones (developed on the Fairphone 6): a GLib-free core (SIP, SDP, USIM AKA, IPsec SA setup, RTP media, call engine) behind a GDBus control daemon, a standalone AMR-WB media leg, and a Plasma Dialer backend. C++26 modules built with Crafter Build; the tree is clean under the project's house-style linter (crafter-build lint) across all three build products. Assisted-by: Claude:claude-fable-5 Signed-off-by: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
This commit is contained in:
commit
6e77823933
31 changed files with 8761 additions and 0 deletions
150
tests/Aka/main.cpp
Normal file
150
tests/Aka/main.cpp
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Aka unit tests — the AKAv1-MD5 digest against a vector cross-computed
|
||||
// with the Python reference, identity assembly, the qmicli/mmcli text
|
||||
// parsers (fed real device output), and the AUTHENTICATE APDU build/parse.
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
using namespace imsd::aka;
|
||||
|
||||
namespace {
|
||||
int Failures = 0;
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
// `qmicli --uim-get-card-status` in the shape a real FP6 eSIM (slot 2)
|
||||
// produces.
|
||||
constexpr std::string_view CardStatus =
|
||||
"[qrtr://0] Successfully got card status\n"
|
||||
"Provisioning applications:\n"
|
||||
"\tPrimary GW: slot '2', application '1'\n"
|
||||
"Slot [1]:\n"
|
||||
"\tCard state: 'error: no-atr-received (3)'\n"
|
||||
"\tUPIN state: 'not-initialized'\n"
|
||||
"Slot [2]:\n"
|
||||
"\tCard state: 'present'\n"
|
||||
"\tApplication [1]:\n"
|
||||
"\t\tApplication type: 'usim (2)'\n"
|
||||
"\t\tApplication state: 'ready'\n"
|
||||
"\t\tApplication ID:\n"
|
||||
"\t\t\tA0:00:00:00:87:10:02:FF:31:F0:04:89:0E:00:00:01\n"
|
||||
"\t\tPIN1 state: 'disabled'\n";
|
||||
|
||||
constexpr std::string_view MmcliSim =
|
||||
" Properties | active: yes\n"
|
||||
" | imsi: 001010123456789\n"
|
||||
" | iccid: 8900101234567890123\n"
|
||||
" | operator id: 00101\n"
|
||||
" | operator name: Test PLMN 1-1\n";
|
||||
}
|
||||
|
||||
int main() {
|
||||
// ---- AKAv1-MD5 digest: pinned vector (the algorithm was cross-checked
|
||||
// against the Python reference; this vector is recomputed for the
|
||||
// synthetic test identity)
|
||||
{
|
||||
std::array<std::uint8_t, 8> res{0xa1,0xb2,0xc3,0xd4,0xe5,0xf6,0x07,0x18};
|
||||
std::string d = DigestAkav1("abc123nonce==", res, "cnonce0123456789", "sip:ims.mnc001.mcc001.3gppnetwork.org", "001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "ims.mnc001.mcc001.3gppnetwork.org");
|
||||
Check(d == "09fd8f8f7ef716c029666a7c6df1db1e", "AKAv1-MD5 digest matches pinned vector");
|
||||
}
|
||||
|
||||
// ---- identity assembly
|
||||
{
|
||||
Identity id = MakeIdentity("001010123456789", "001", "01");
|
||||
Check(id.mnc == "001", "MNC zero-padded to 3");
|
||||
Check(id.domain == "ims.mnc001.mcc001.3gppnetwork.org", "domain");
|
||||
Check(id.impi == "001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "IMPI");
|
||||
Check(id.impu == "sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "IMPU");
|
||||
Check(id.regUri == "sip:ims.mnc001.mcc001.3gppnetwork.org", "reg URI");
|
||||
}
|
||||
|
||||
// ---- card-status parse (real device output)
|
||||
{
|
||||
auto sel = ParseCardStatus(CardStatus);
|
||||
Check(sel.has_value(), "card status yields a ready USIM");
|
||||
Check(sel && sel->slot == 2, "slot 2 selected (slot 1 is error/no-atr)");
|
||||
Check(sel && sel->aid == "A0000000871002FF31F004890E000001", "AID uppercased, colons stripped");
|
||||
}
|
||||
Check(!ParseCardStatus("Slot [1]:\n\tCard state: 'absent'\n").has_value(), "no ready USIM -> nullopt");
|
||||
|
||||
// ---- mmcli parsers
|
||||
Check(ParsePrimarySimPath(" SIM | primary sim path: /org/freedesktop/ModemManager1/SIM/0\n") == "/org/freedesktop/ModemManager1/SIM/0", "primary sim path");
|
||||
{
|
||||
auto si = ParseSimInfo(MmcliSim);
|
||||
Check(si.has_value(), "sim info parses");
|
||||
Check(si && si->imsi == "001010123456789", "imsi");
|
||||
Check(si && si->mcc == "001" && si->mnc == "01", "mcc/mnc split");
|
||||
// full pipeline: parsed info -> identity
|
||||
Identity id = MakeIdentity(si->imsi, si->mcc, si->mnc);
|
||||
Check(id.domain == "ims.mnc001.mcc001.3gppnetwork.org", "info->identity domain");
|
||||
}
|
||||
Check(ParseEquipmentId(" Hardware | equipment id: 359999990000001\n") == "359999990000001", "equipment id");
|
||||
Check(ImeiUrn("359999990000001") == "urn:gsma:imei:35999999-000000-1", "IMEI -> RFC 7254 URN");
|
||||
Check(!ImeiUrn("35999999000000").has_value(), "14-digit IMEI rejected");
|
||||
Check(!ImeiUrn("35999999000000x").has_value(), "non-digit IMEI rejected");
|
||||
|
||||
// ---- AUTHENTICATE APDU build + response parse
|
||||
{
|
||||
std::vector<std::uint8_t> rnd(16, 0x11), autn(16, 0x22);
|
||||
std::string apdu = BuildAkaApdu(0x01, rnd, autn);
|
||||
// CLA=01 INS=88 P1=00 P2=81 Lc=22(34) 10 <rand> 10 <autn>
|
||||
Check(apdu.starts_with("0188008122"), "APDU header CLA/INS/P1/P2/Lc");
|
||||
Check(apdu == "0188008122" "10" "11111111111111111111111111111111" "10" "22222222222222222222222222222222", "APDU byte-exact");
|
||||
Check(BuildGetResponseApdu(0x01, 0x2E) == "01c000002e", "GET RESPONSE APDU");
|
||||
}
|
||||
{
|
||||
// DB | 08 RES | 10 CK | 10 IK | SW1 SW2(9000)
|
||||
std::vector<std::uint8_t> body = {0xDB, 0x08};
|
||||
for (int i = 0; i < 8; i++) body.push_back(0xA0 + i); // RES
|
||||
body.push_back(0x10);
|
||||
for (int i = 0; i < 16; i++) body.push_back(0xC0 + i); // CK
|
||||
body.push_back(0x10);
|
||||
for (int i = 0; i < 16; i++) body.push_back(0xD0 + i); // IK
|
||||
body.push_back(0x90); body.push_back(0x00); // SW
|
||||
auto r = ParseAkaResponse(body);
|
||||
Check(r.has_value(), "AKA response parses");
|
||||
Check(r && r->res.size() == 8 && r->res[0] == 0xA0, "RES extracted");
|
||||
Check(r && r->ck.size() == 16 && r->ck[0] == 0xC0, "CK extracted");
|
||||
Check(r && r->ik.size() == 16 && r->ik[0] == 0xD0, "IK extracted");
|
||||
}
|
||||
{
|
||||
// sync-failure tag (0xDC) or truncated body -> nullopt
|
||||
std::vector<std::uint8_t> dc = {0xDC, 0x0E, 0x90, 0x00};
|
||||
Check(!ParseAkaResponse(dc).has_value(), "non-DB tag rejected");
|
||||
std::vector<std::uint8_t> tr = {0xDB, 0x08, 0x00, 0x90, 0x00};
|
||||
Check(!ParseAkaResponse(tr).has_value(), "length past end rejected");
|
||||
}
|
||||
{
|
||||
// P-Access-Network-Info from `mmcli --location-get` (real-device
|
||||
// output shape; TAC/CI zero-padded wider than the 4/7 hex digits
|
||||
// PANI uses). The mcc+mnc+TAC+ECI concatenation was validated
|
||||
// against a stock modem's on-wire PANI; values here are synthetic.
|
||||
constexpr std::string_view Loc =
|
||||
" --------------------------\n"
|
||||
" 3GPP | operator mcc: 001\n"
|
||||
" | operator mnc: 01\n"
|
||||
" | location area code: 0000\n"
|
||||
" | tracking area code: 001234\n"
|
||||
" | cell id: 00ABCDE0\n";
|
||||
auto p = ParsePani(Loc);
|
||||
Check(p.has_value(), "PANI parses");
|
||||
Check(p == "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0", "PANI concatenation mcc+mnc+TAC+ECI");
|
||||
Check(!ParsePani("no location here").has_value(), "PANI absent fields rejected");
|
||||
constexpr std::string_view Zeros =
|
||||
" 3GPP | operator mcc: 001\n"
|
||||
" | operator mnc: 01\n"
|
||||
" | tracking area code: 000000\n"
|
||||
" | cell id: 00000000\n";
|
||||
Check(!ParsePani(Zeros).has_value(), "PANI all-zero cell rejected");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Aka: all tests passed");
|
||||
return Failures;
|
||||
}
|
||||
530
tests/Engine/main.cpp
Normal file
530
tests/Engine/main.cpp
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Engine unit tests — the call state machine driven through recorded
|
||||
// network dialogs. Outgoing: reliable-18x PRACK, 2xx ACK + media start,
|
||||
// media-plane far-end hangup, local hang-up (CANCEL before answer, BYE
|
||||
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
|
||||
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
|
||||
// with the SDP answer at the offer's payload types + media, ACK silence,
|
||||
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring
|
||||
// timeout -> 480, BYE both ways. Pure reducer, no I/O.
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
using namespace imsd::engine;
|
||||
using imsd::msg::Context;
|
||||
|
||||
namespace {
|
||||
int Failures = 0;
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++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; spi-c=1; spi-s=2; port-c=3; port-s=4";
|
||||
return c;
|
||||
}
|
||||
|
||||
bool Has(const std::vector<Action>& a, Action::Type t) {
|
||||
for (const auto& x : a) if (x.type == t) return true;
|
||||
return false;
|
||||
}
|
||||
const Action* Find(const std::vector<Action>& a, Action::Type t) {
|
||||
for (const auto& x : a) if (x.type == t) return &x;
|
||||
return nullptr;
|
||||
}
|
||||
// First SendClient whose text starts with `method `.
|
||||
const Action* Sent(const std::vector<Action>& a, std::string_view method) {
|
||||
for (const auto& x : a)
|
||||
if (x.type == Action::Type::SendClient && x.text.starts_with(method)) return &x;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Build a response with the machine's own Call-ID (so dialog matching in a
|
||||
// real shell would hold; the reducer itself doesn't re-check).
|
||||
std::string R183(std::string_view callid) {
|
||||
return std::format(
|
||||
"SIP/2.0 183 Session Progress\r\n"
|
||||
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
|
||||
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
|
||||
"To: <sip:1233@ims>;tag=remote7\r\n"
|
||||
"Call-ID: {}\r\n"
|
||||
"CSeq: 1 INVITE\r\n"
|
||||
"Record-Route: <sip:[2001:db8::105]:6000;lr>\r\n"
|
||||
"Require: 100rel\r\n"
|
||||
"RSeq: 1\r\n"
|
||||
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
|
||||
"Content-Type: application/sdp\r\n"
|
||||
"Content-Length: 120\r\n\r\n"
|
||||
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
|
||||
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
|
||||
}
|
||||
std::string R200(std::string_view callid) {
|
||||
return std::format(
|
||||
"SIP/2.0 200 OK\r\n"
|
||||
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
|
||||
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
|
||||
"To: <sip:1233@ims>;tag=remote7\r\n"
|
||||
"Call-ID: {}\r\n"
|
||||
"CSeq: 1 INVITE\r\n"
|
||||
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
|
||||
"Content-Type: application/sdp\r\n"
|
||||
"Content-Length: 120\r\n\r\n"
|
||||
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
|
||||
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
|
||||
}
|
||||
std::string Final(std::string_view callid, int code, std::string_view text) {
|
||||
return std::format(
|
||||
"SIP/2.0 {} {}\r\n"
|
||||
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
|
||||
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
|
||||
"To: <sip:1233@ims>;tag=remote7\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n",
|
||||
code, text, callid);
|
||||
}
|
||||
|
||||
// First SendResponse whose text starts with `prefix`.
|
||||
const Action* Responded(const std::vector<Action>& a, std::string_view prefix) {
|
||||
for (const auto& x : a)
|
||||
if (x.type == Action::Type::SendResponse && x.text.starts_with(prefix)) return &x;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// An inbound (terminating) INVITE the way KPN's P-CSCF delivers one:
|
||||
// two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer
|
||||
// at non-default payload types (104/105) with telephone-event.
|
||||
std::string MtInvite(std::string_view extraHeaders = "") {
|
||||
std::string sdp =
|
||||
"v=0\r\no=- 55 55 IN IP6 2001:db8::30c\r\ns=-\r\n"
|
||||
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
|
||||
"m=audio 27864 RTP/AVP 104 105\r\n"
|
||||
"a=rtpmap:104 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:104 octet-align=1;mode-change-capability=2\r\n"
|
||||
"a=rtpmap:105 telephone-event/16000\r\n"
|
||||
"a=fmtp:105 0-15\r\n"
|
||||
"a=ptime:20\r\na=sendrecv\r\n";
|
||||
return std::format(
|
||||
"INVITE sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
|
||||
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
|
||||
"Via: SIP/2.0/UDP [2001:db8::9]:5060;branch=z9hG4bKscscf1\r\n"
|
||||
"Record-Route: <sip:[2001:db8::105]:5100;lr>\r\n"
|
||||
"Record-Route: <sip:[2001:db8::9]:5060;lr>\r\n"
|
||||
"Max-Forwards: 68\r\n"
|
||||
"From: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
|
||||
"Call-ID: mt-call-abc123\r\n"
|
||||
"CSeq: 1 INVITE\r\n"
|
||||
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
|
||||
"P-Asserted-Identity: <tel:+31612345678>\r\n"
|
||||
"Session-Expires: 1800\r\n"
|
||||
"{}"
|
||||
"Content-Type: application/sdp\r\n"
|
||||
"Content-Length: {}\r\n\r\n{}",
|
||||
extraHeaders, sdp.size(), sdp);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
Context c = MakeCtx();
|
||||
|
||||
// ---- full answered call: INVITE -> 100 -> 183(rel)+PRACK -> 200 -> ACK
|
||||
// + media -> far-end hangup
|
||||
{
|
||||
imsd::util::Rng rng(42);
|
||||
CallMachine m(c, rng, "ims-call-1", "1233", 50004, false);
|
||||
auto start = m.Start();
|
||||
Check(Sent(start, "INVITE ") != nullptr, "Start sends INVITE");
|
||||
Check(Has(start, Action::Type::SetDeadline), "Start arms ring timeout");
|
||||
Check(m.State() == CallState::Dialing, "starts dialing");
|
||||
|
||||
// 100 Trying: nothing changes
|
||||
auto a100 = m.OnInviteResponse(Final(m.CallId(), 100, "Trying"));
|
||||
Check(a100.empty(), "100 Trying is a no-op");
|
||||
Check(m.State() == CallState::Dialing, "still dialing after 100");
|
||||
|
||||
// 183 with reliable 100rel + SDP: -> ringing, PRACK
|
||||
auto a183 = m.OnInviteResponse(R183(m.CallId()));
|
||||
Check(m.State() == CallState::Ringing, "183 -> ringing");
|
||||
const Action* stAct = Find(a183, Action::Type::State);
|
||||
Check(stAct && stAct->state == "ringing" && stAct->reason == "outgoing", "183 emits ringing/outgoing");
|
||||
const Action* prack = Sent(a183, "PRACK ");
|
||||
Check(prack != nullptr, "reliable 183 triggers PRACK");
|
||||
Check(prack && prack->text.contains("RAck: 1 1 INVITE\r\n"), "PRACK RAck");
|
||||
Check(prack && prack->text.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq 2");
|
||||
// PRACK targets the remote Contact and routes via the Record-Route
|
||||
Check(prack && prack->text.starts_with("PRACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "PRACK targets remote Contact");
|
||||
Check(prack && prack->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "PRACK routes via reversed Record-Route");
|
||||
|
||||
// a second identical 183 must NOT re-PRACK the same RSeq
|
||||
auto a183b = m.OnInviteResponse(R183(m.CallId()));
|
||||
Check(Sent(a183b, "PRACK ") == nullptr, "duplicate RSeq not re-PRACKed");
|
||||
|
||||
// 200 OK: ACK + StartMedia + active
|
||||
auto a200 = m.OnInviteResponse(R200(m.CallId()));
|
||||
const Action* ack = Sent(a200, "ACK ");
|
||||
Check(ack != nullptr, "200 -> ACK");
|
||||
Check(ack && ack->text.starts_with("ACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "ACK targets remote Contact");
|
||||
Check(ack && ack->text.contains("To: <sip:1233@ims>;tag=remote7\r\n"), "ACK carries dialog To tag");
|
||||
const Action* media = Find(a200, Action::Type::StartMedia);
|
||||
Check(media != nullptr, "200 starts media");
|
||||
Check(media && media->media.Valid(), "media leg valid");
|
||||
Check(media && media->media.remoteIp == "2001:db8::9", "media remote ip");
|
||||
Check(media && media->media.remotePort == 6004, "media remote port");
|
||||
Check(media && media->media.payloadType == 97, "media pt");
|
||||
Check(media && media->media.codec == "AMR-WB", "media codec");
|
||||
const Action* active = Find(a200, Action::Type::State);
|
||||
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
|
||||
Check(m.State() == CallState::Active, "state active");
|
||||
|
||||
// media leg exits code 3 (downlink dried up) -> BYE + terminated
|
||||
auto amx = m.OnMediaExit(3);
|
||||
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
|
||||
const Action* term = Find(amx, Action::Type::State);
|
||||
Check(term && term->state == "terminated" && term->reason == "remote-hangup", "media exit -> terminated/remote-hangup");
|
||||
Check(Has(amx, Action::Type::StopMedia), "stops media");
|
||||
Check(Has(amx, Action::Type::Deleted), "emits deleted");
|
||||
Check(m.Terminated(), "terminated");
|
||||
}
|
||||
|
||||
// ---- remote BYE mid-call
|
||||
{
|
||||
imsd::util::Rng rng(7);
|
||||
CallMachine m(c, rng, "ims-call-2", "1233", 50004, false);
|
||||
m.Start();
|
||||
m.OnInviteResponse(R200(m.CallId()));
|
||||
Check(m.State() == CallState::Active, "answered");
|
||||
std::string bye = std::format(
|
||||
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/TCP [x]:1;branch=z9hG4bKb\r\n"
|
||||
"From: <sip:them>;tag=remote7\r\nTo: <sip:me>;tag=it\r\n"
|
||||
"Reason: SIP;cause=503;text=\"PT: Session timer expired.\"\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 9 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
|
||||
auto ab = m.OnRequest(bye);
|
||||
const Action* resp = Find(ab, Action::Type::SendResponse);
|
||||
Check(resp && resp->text.starts_with("SIP/2.0 200 OK"), "remote BYE answered 200");
|
||||
Check(resp && resp->text.contains("CSeq: 9 BYE\r\n"), "200 echoes BYE CSeq");
|
||||
const Action* t = Find(ab, Action::Type::State);
|
||||
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
|
||||
const Action* lg = Find(ab, Action::Type::Log);
|
||||
Check(lg && lg->text.contains("ended: remote-hangup") && lg->text.contains("Reason: SIP;cause=503"), "teardown log names the peer's Reason");
|
||||
Check(m.Terminated(), "terminated after remote BYE");
|
||||
}
|
||||
|
||||
// ---- local hang-up before answer sends CANCEL; the 200/487 race
|
||||
{
|
||||
imsd::util::Rng rng(9);
|
||||
CallMachine m(c, rng, "ims-call-3", "1233", 50004, false);
|
||||
m.Start();
|
||||
m.OnInviteResponse(R183(m.CallId())); // ringing
|
||||
auto ah = m.OnHangup();
|
||||
Check(Sent(ah, "CANCEL ") != nullptr, "hangup while ringing sends CANCEL");
|
||||
Check(Has(ah, Action::Type::SetDeadline), "arms 487 timeout after CANCEL");
|
||||
Check(!m.Terminated(), "not terminated until 487");
|
||||
// a late 200 wins the race: ACK, then BYE, then terminate local-hangup
|
||||
auto ar = m.OnInviteResponse(R200(m.CallId()));
|
||||
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
|
||||
Check(Sent(ar, "BYE ") != nullptr, "race 200 then BYE (we cancelled)");
|
||||
const Action* t = Find(ar, Action::Type::State);
|
||||
Check(t && t->reason == "local-hangup", "cancel race -> local-hangup");
|
||||
// no media for a cancelled call
|
||||
Check(!Has(ar, Action::Type::StartMedia), "cancelled call starts no media");
|
||||
}
|
||||
|
||||
// ---- 487 after CANCEL
|
||||
{
|
||||
imsd::util::Rng rng(11);
|
||||
CallMachine m(c, rng, "ims-call-4", "1233", 50004, false);
|
||||
m.Start();
|
||||
m.OnHangup(); // CANCEL while dialing
|
||||
auto a487 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
|
||||
Check(Sent(a487, "ACK ") != nullptr, "487 is ACKed (non-2xx)");
|
||||
const Action* t = Find(a487, Action::Type::State);
|
||||
Check(t && t->reason == "local-hangup", "487 -> local-hangup");
|
||||
Check(m.Terminated(), "terminated on 487");
|
||||
}
|
||||
|
||||
// ---- busy / declined finals
|
||||
{
|
||||
for (auto [code, want] : std::vector<std::pair<int, std::string>>{
|
||||
{486, "refused-or-busy"}, {480, "refused-or-busy"},
|
||||
{603, "refused-or-busy"}, {500, "error"}, {403, "error"}}) {
|
||||
imsd::util::Rng rng(code);
|
||||
CallMachine m(c, rng, "u", "1233", 50004, false);
|
||||
m.Start();
|
||||
auto a = m.OnInviteResponse(Final(m.CallId(), code, "x"));
|
||||
Check(Sent(a, "ACK ") != nullptr, std::format("{} ACKed", code));
|
||||
const Action* t = Find(a, Action::Type::State);
|
||||
Check(t && t->reason == want, std::format("{} -> {}", code, want));
|
||||
Check(m.Terminated(), std::format("{} terminates", code));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- BYE after answer (local hangup during active call)
|
||||
{
|
||||
imsd::util::Rng rng(13);
|
||||
CallMachine m(c, rng, "ims-call-5", "1233", 50004, false);
|
||||
m.Start();
|
||||
m.OnInviteResponse(R200(m.CallId()));
|
||||
auto a = m.OnHangup();
|
||||
const Action* bye = Sent(a, "BYE ");
|
||||
Check(bye != nullptr, "active hangup sends BYE");
|
||||
Check(bye && bye->text.contains("CSeq: 3 BYE\r\n"), "BYE CSeq after ACK=1");
|
||||
const Action* t = Find(a, Action::Type::State);
|
||||
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
|
||||
}
|
||||
|
||||
// ---- setup timeout -> CANCEL, then error
|
||||
{
|
||||
imsd::util::Rng rng(15);
|
||||
CallMachine m(c, rng, "ims-call-6", "1233", 50004, false);
|
||||
m.Start();
|
||||
auto a1 = m.OnDeadline(); // ring timeout
|
||||
Check(Sent(a1, "CANCEL ") != nullptr, "ring timeout sends CANCEL");
|
||||
Check(!m.Terminated(), "not terminated after first timeout");
|
||||
auto a2 = m.OnDeadline(); // 487 never came
|
||||
const Action* t = Find(a2, Action::Type::State);
|
||||
Check(t && t->reason == "error", "second timeout -> error");
|
||||
Check(m.Terminated(), "terminated on second timeout");
|
||||
}
|
||||
|
||||
// ================= incoming (terminating / UAS) =======================
|
||||
|
||||
// ---- full accepted incoming call: INVITE -> 100+180, Accept -> 200 with
|
||||
// the answer at the offer's payload types + media, ACK, remote BYE
|
||||
{
|
||||
imsd::util::Rng rng(21);
|
||||
CallMachine m(c, rng, "ims-call-7", IncomingInvite{MtInvite()}, 50004);
|
||||
Check(m.Dir() == Direction::Incoming, "direction incoming");
|
||||
Check(m.State() == CallState::Incoming, "starts incoming");
|
||||
Check(m.Number() == "+31612345678", "caller id from P-Asserted-Identity");
|
||||
Check(m.CallId() == "mt-call-abc123", "call id from INVITE");
|
||||
|
||||
auto ai = m.OnInvite();
|
||||
const Action* trying = Responded(ai, "SIP/2.0 100 Trying");
|
||||
Check(trying != nullptr, "INVITE -> 100 Trying");
|
||||
Check(trying && trying->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"), "100 leaves To untagged");
|
||||
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
|
||||
Check(ringing != nullptr, "INVITE -> 180 Ringing");
|
||||
Check(ringing && !ringing->text.contains("RSeq:"), "180 unreliable when 100rel not required");
|
||||
Check(Has(ai, Action::Type::SetDeadline), "arms ring timeout");
|
||||
// both Vias echoed, in order
|
||||
Check(ringing && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::105]:5100") && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::9]:5060"), "180 echoes all Vias");
|
||||
Check(ringing && ringing->text.find("[2001:db8::105]:5100") < ringing->text.find("[2001:db8::9]:5060"), "180 Via order preserved");
|
||||
Check(ringing && ringing->text.contains("Record-Route: <sip:[2001:db8::105]:5100;lr>"), "180 returns the Record-Route set");
|
||||
Check(ringing && ringing->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001." "3gppnetwork.org;user=phone>;tag="), "180 mints a To tag");
|
||||
|
||||
// retransmitted INVITE (lost provisional): repeat the 180
|
||||
auto art = m.OnRequest(MtInvite());
|
||||
Check(Responded(art, "SIP/2.0 180 Ringing") != nullptr, "INVITE retransmit re-sends the 180");
|
||||
Check(m.State() == CallState::Incoming, "still incoming");
|
||||
|
||||
auto aa = m.OnAccept();
|
||||
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
|
||||
Check(ok != nullptr, "Accept -> 200 OK");
|
||||
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 104 105\r\n"), "answer uses the offer's payload types");
|
||||
Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt");
|
||||
Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align");
|
||||
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
|
||||
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
|
||||
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
|
||||
// the 180 and 200 carry the SAME To tag (one dialog)
|
||||
if (ringing && ok) {
|
||||
auto tag = [](const std::string& s) {
|
||||
std::size_t at = s.find(";tag=", s.find("To:"));
|
||||
return s.substr(at, s.find('\r', at) - at);
|
||||
};
|
||||
Check(tag(ringing->text) == tag(ok->text), "180/200 share the To tag");
|
||||
}
|
||||
const Action* media = Find(aa, Action::Type::StartMedia);
|
||||
Check(media != nullptr, "Accept starts media");
|
||||
Check(media && media->media.remoteIp == "2001:db8::30c", "media ip from offer");
|
||||
Check(media && media->media.remotePort == 27864, "media port from offer");
|
||||
Check(media && media->media.payloadType == 104, "media pt from offer");
|
||||
const Action* st = Find(aa, Action::Type::State);
|
||||
Check(st && st->state == "active" && st->reason == "accepted", "Accept emits active/accepted");
|
||||
Check(m.State() == CallState::Active, "active after Accept");
|
||||
|
||||
// a second Accept is a no-op
|
||||
Check(m.OnAccept().empty(), "double Accept is a no-op");
|
||||
|
||||
// the caller's ACK is consumed silently
|
||||
std::string ack = std::format(
|
||||
"ACK sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
|
||||
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKack\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 1 ACK\r\nContent-Length: 0\r\n\r\n", m.CallId());
|
||||
Check(m.OnRequest(ack).empty(), "ACK gets no response");
|
||||
|
||||
// session-refresh UPDATE without a body: bodyless 200
|
||||
std::string upd = std::format(
|
||||
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 2 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
|
||||
auto au = m.OnRequest(upd);
|
||||
const Action* u200 = Responded(au, "SIP/2.0 200 OK");
|
||||
Check(u200 != nullptr, "bodyless UPDATE answered 200");
|
||||
Check(u200 && u200->text.contains("Content-Length: 0"), "bodyless UPDATE gets a bodyless 200");
|
||||
Check(u200 && !u200->text.contains("Session-Expires"), "no Session-Expires in the UPDATE -> none invented in the 200");
|
||||
|
||||
// session-audit UPDATE with Session-Expires (KPN B2BUA, ~55 s into
|
||||
// every call): the 200 MUST echo it (RFC 4028 §9) or the network
|
||||
// releases with "Session timer expired"
|
||||
std::string se = std::format(
|
||||
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu2\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
|
||||
"Min-SE: 90\r\nSession-Expires: 1800;refresher=uac\r\n"
|
||||
"Supported: timer\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 3 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
|
||||
auto ase = m.OnRequest(se);
|
||||
const Action* se200 = Responded(ase, "SIP/2.0 200 OK");
|
||||
Check(se200 != nullptr, "refresh UPDATE answered 200");
|
||||
Check(se200 && se200->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires");
|
||||
Check(se200 && se200->text.contains("Require: timer\r\n"), "200 carries Require: timer");
|
||||
|
||||
// remote BYE ends it
|
||||
std::string bye = std::format(
|
||||
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKb\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 4 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
|
||||
auto ab = m.OnRequest(bye);
|
||||
Check(Responded(ab, "SIP/2.0 200 OK") != nullptr, "remote BYE answered");
|
||||
const Action* t = Find(ab, Action::Type::State);
|
||||
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
|
||||
Check(m.Terminated(), "terminated");
|
||||
}
|
||||
|
||||
// ---- reliable 180 when the caller requires 100rel; PRACK answered
|
||||
{
|
||||
imsd::util::Rng rng(23);
|
||||
CallMachine m(c, rng, "ims-call-8", IncomingInvite{MtInvite("Require: 100rel\r\n")}, 50004);
|
||||
auto ai = m.OnInvite();
|
||||
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
|
||||
Check(ringing && ringing->text.contains("Require: 100rel\r\n") && ringing->text.contains("RSeq: 1\r\n"), "Require: 100rel makes the 180 reliable");
|
||||
std::string prack = std::format(
|
||||
"PRACK sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKp\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 2 PRACK\r\nRAck: 1 1 INVITE\r\n"
|
||||
"Content-Length: 0\r\n\r\n", m.CallId());
|
||||
auto ap = m.OnRequest(prack);
|
||||
const Action* p200 = Responded(ap, "SIP/2.0 200 OK");
|
||||
Check(p200 != nullptr, "PRACK answered 200");
|
||||
Check(p200 && p200->text.contains("CSeq: 2 PRACK\r\n"), "PRACK 200 echoes CSeq");
|
||||
Check(m.State() == CallState::Incoming, "PRACK leaves state incoming");
|
||||
}
|
||||
|
||||
// ---- local reject: HangUp while ringing -> 486
|
||||
{
|
||||
imsd::util::Rng rng(25);
|
||||
CallMachine m(c, rng, "ims-call-9", IncomingInvite{MtInvite()}, 50004);
|
||||
m.OnInvite();
|
||||
auto ah = m.OnHangup();
|
||||
const Action* busy = Responded(ah, "SIP/2.0 486 Busy Here");
|
||||
Check(busy != nullptr, "reject sends 486");
|
||||
Check(busy && busy->text.contains("CSeq: 1 INVITE\r\n"), "486 answers the INVITE");
|
||||
const Action* t = Find(ah, Action::Type::State);
|
||||
Check(t && t->reason == "local-hangup", "reject -> local-hangup");
|
||||
Check(m.Terminated(), "terminated after reject");
|
||||
}
|
||||
|
||||
// ---- remote CANCEL while ringing: 200 the CANCEL, 487 the INVITE
|
||||
{
|
||||
imsd::util::Rng rng(27);
|
||||
CallMachine m(c, rng, "ims-call-10", IncomingInvite{MtInvite()}, 50004);
|
||||
m.OnInvite();
|
||||
std::string cancel = std::format(
|
||||
"CANCEL sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
|
||||
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>\r\n"
|
||||
"Call-ID: {}\r\nCSeq: 1 CANCEL\r\nContent-Length: 0\r\n\r\n", m.CallId());
|
||||
auto ac = m.OnRequest(cancel);
|
||||
const Action* c200 = Responded(ac, "SIP/2.0 200 OK");
|
||||
Check(c200 && c200->text.contains("CSeq: 1 CANCEL\r\n"), "CANCEL answered 200 on its own transaction");
|
||||
const Action* r487 = Responded(ac, "SIP/2.0 487 Request Terminated");
|
||||
Check(r487 && r487->text.contains("CSeq: 1 INVITE\r\n"), "the INVITE transaction gets the 487");
|
||||
const Action* t = Find(ac, Action::Type::State);
|
||||
Check(t && t->reason == "remote-hangup", "remote CANCEL -> remote-hangup");
|
||||
Check(m.Terminated(), "terminated after remote CANCEL");
|
||||
}
|
||||
|
||||
// ---- offer we cannot serve (no AMR-WB): 488 before ringing
|
||||
{
|
||||
imsd::util::Rng rng(29);
|
||||
std::string sdp =
|
||||
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n"
|
||||
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
|
||||
"m=audio 27864 RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n";
|
||||
std::string inv = std::format(
|
||||
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
|
||||
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
|
||||
"To: <sip:+31611111111@ims;user=phone>\r\n"
|
||||
"Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n"
|
||||
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
|
||||
"Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}",
|
||||
sdp.size(), sdp);
|
||||
CallMachine m(c, rng, "ims-call-11", IncomingInvite{inv}, 50004);
|
||||
auto ai = m.OnInvite();
|
||||
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "non-AMR-WB offer refused with 488");
|
||||
Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
|
||||
Check(m.Terminated(), "terminated on 488");
|
||||
}
|
||||
|
||||
// ---- ring timeout: 480, missed call
|
||||
{
|
||||
imsd::util::Rng rng(31);
|
||||
CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInvite()}, 50004);
|
||||
m.OnInvite();
|
||||
auto ad = m.OnDeadline();
|
||||
Check(Responded(ad, "SIP/2.0 480 Temporarily Unavailable") != nullptr, "ring timeout sends 480");
|
||||
const Action* t = Find(ad, Action::Type::State);
|
||||
Check(t && t->reason == "remote-hangup", "timeout reads as missed");
|
||||
Check(m.Terminated(), "terminated on ring timeout");
|
||||
}
|
||||
|
||||
// ---- our BYE after accepting: UAS dialog fields hold
|
||||
{
|
||||
imsd::util::Rng rng(33);
|
||||
CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInvite()}, 50004);
|
||||
m.OnInvite();
|
||||
m.OnAccept();
|
||||
auto ah = m.OnHangup();
|
||||
const Action* bye = Sent(ah, "BYE ");
|
||||
Check(bye != nullptr, "hangup after accept sends BYE");
|
||||
Check(bye && bye->text.starts_with("BYE sip:origgw@[2001:db8::9]:5060 SIP/2.0"), "BYE targets the caller's Contact");
|
||||
Check(bye && bye->text.contains("CSeq: 1 BYE\r\n"), "our UAS CSeq space starts at 1");
|
||||
Check(bye && bye->text.contains("To: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>" ";tag=caller1\r\n"), "BYE To is the caller (their tag)");
|
||||
Check(bye && bye->text.contains("From: <sip:001010123456789@ims.mnc001.mcc001." "3gppnetwork.org>;tag="), "BYE From is us (our tag)");
|
||||
// UAS route set: Record-Route in RECEIVED order
|
||||
Check(bye && bye->text.contains("Route: <sip:[2001:db8::105]:5100;lr>, " "<sip:[2001:db8::9]:5060;lr>\r\n"), "BYE routes via Record-Route in received order");
|
||||
const Action* t = Find(ah, Action::Type::State);
|
||||
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
|
||||
}
|
||||
|
||||
// ---- media death after accept releases the call
|
||||
{
|
||||
imsd::util::Rng rng(35);
|
||||
CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInvite()}, 50004);
|
||||
m.OnInvite();
|
||||
m.OnAccept();
|
||||
auto amx = m.OnMediaExit(3);
|
||||
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE (incoming too)");
|
||||
Check(m.Terminated(), "terminated");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Engine: all tests passed");
|
||||
return Failures;
|
||||
}
|
||||
124
tests/Ipsec/main.cpp
Normal file
124
tests/Ipsec/main.cpp
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Ipsec unit tests — the `ip xfrm` setup command sequence (byte-exact
|
||||
// argv) and the warm-SA reader, fed `ip xfrm state`/`policy` output in the
|
||||
// exact shape captured from a registered FP6 (addresses/keys synthetic).
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
using namespace imsd::ipsec;
|
||||
|
||||
namespace {
|
||||
int Failures = 0;
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
std::string Join(const std::vector<std::string>& v) {
|
||||
std::string s;
|
||||
for (const auto& t : v) { s += t; s += ' '; }
|
||||
if (!s.empty()) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// `ip xfrm state` in the shape of a registered FP6's kernel state
|
||||
// (P-CSCF ::105, UE ...db43).
|
||||
constexpr std::string_view State =
|
||||
"src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
|
||||
"\tproto esp spi 0x0a84ab2f reqid 2 mode transport\n"
|
||||
"\treplay-window 0 \n"
|
||||
"\tauth-trunc hmac(sha1) 0x00112233445566778899aabbccddeeff 96\n"
|
||||
"\tenc cbc(aes) 0xffeeddccbbaa99887766554433221100\n"
|
||||
"src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
|
||||
"\tproto esp spi 0x0526f2c1 reqid 2 mode transport\n"
|
||||
"\tenc cbc(aes) 0xffeeddccbbaa99887766554433221100\n"
|
||||
"src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
|
||||
"\tproto esp spi 0x0071acd4 reqid 1 mode transport\n"
|
||||
"\tlastused 2026-07-19 20:42:01\n"
|
||||
"src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
|
||||
"\tproto esp spi 0x0526f2c0 reqid 1 mode transport\n"
|
||||
"\tlastused 2026-07-19 20:42:01\n";
|
||||
|
||||
constexpr std::string_view Policy =
|
||||
"src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 33421 dport 45062 \n"
|
||||
"\tdir in priority 0 \n"
|
||||
"\ttmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
|
||||
"\t\tproto esp reqid 2 mode transport\n"
|
||||
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45062 dport 33421 \n"
|
||||
"\tdir out priority 0 \n"
|
||||
"\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
|
||||
"\t\tproto esp reqid 2 mode transport\n"
|
||||
"src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 6000 dport 45061 \n"
|
||||
"\tdir in priority 0 \n"
|
||||
"\ttmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
|
||||
"\t\tproto esp reqid 1 mode transport\n"
|
||||
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45061 dport 6000 \n"
|
||||
"\tdir out priority 0 \n"
|
||||
"\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
|
||||
"\t\tproto esp reqid 1 mode transport\n";
|
||||
}
|
||||
|
||||
int main() {
|
||||
// ---- warm-SA reader against real device output
|
||||
{
|
||||
auto sa = ParseExistingSa(State, Policy, "2001:db8::105", "2001:db8:1111:2222:3333:4444:5555:db43");
|
||||
Check(sa.has_value(), "existing SA parses");
|
||||
Check(sa && sa->portPs == 6000, "port-s (reqid1 out dport) = 6000");
|
||||
Check(sa && sa->portPc == 33421, "port-c (reqid2 out dport) = 33421");
|
||||
Check(sa && sa->spiUc == 0x0071acd4u, "spi_uc (P->UE reqid1)");
|
||||
Check(sa && sa->spiUs == 0x0a84ab2fu, "spi_us (P->UE reqid2)");
|
||||
Check(sa && sa->spiPs == 0x0526f2c0u, "spi_ps (UE->P reqid1)");
|
||||
Check(sa && sa->spiPc == 0x0526f2c1u, "spi_pc (UE->P reqid2)");
|
||||
Check(sa && sa->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", "reconstructed Security-Server value");
|
||||
}
|
||||
// incomplete input -> nullopt
|
||||
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt");
|
||||
|
||||
// ---- fresh setup command sequence
|
||||
{
|
||||
SaParams p;
|
||||
p.local = "2001:db8::db43";
|
||||
p.pcscf = "2001:db8::105";
|
||||
p.ik = std::vector<std::uint8_t>(16, 0xAB);
|
||||
p.ck = std::vector<std::uint8_t>(16, 0xCD);
|
||||
p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400;
|
||||
p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421;
|
||||
auto cmds = BuildSetupCommands(p);
|
||||
Check(cmds.size() == 10, "flush x2 + 4 states + 4 policies");
|
||||
Check(Join(cmds[0]) == "ip xfrm state flush", "state flush first");
|
||||
Check(Join(cmds[1]) == "ip xfrm policy flush", "policy flush second");
|
||||
// reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc
|
||||
Check(Join(cmds[2]) ==
|
||||
"ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 "
|
||||
"proto esp spi 400 mode transport reqid 1 "
|
||||
"auth-trunc hmac(sha1) 0xabababababababababababababababab 96 "
|
||||
"enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
|
||||
"state add reqid1 outbound byte-exact");
|
||||
// first out policy: sport PORT_UC dport PORT_PS reqid 1
|
||||
Check(Join(cmds[6]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact");
|
||||
// v4 uses /32
|
||||
SaParams p4 = p;
|
||||
p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1";
|
||||
auto c4 = BuildSetupCommands(p4);
|
||||
Check(c4[6][5] == "10.0.0.2/32", "v4 prefix length /32");
|
||||
}
|
||||
// null cipher -> cipher_null + empty key arg
|
||||
{
|
||||
SaParams p;
|
||||
p.local = "::2"; p.pcscf = "::1";
|
||||
p.ik = std::vector<std::uint8_t>(16, 0x01);
|
||||
p.ck = {};
|
||||
p.ealg = "null";
|
||||
auto cmds = BuildSetupCommands(p);
|
||||
auto& st = cmds[2];
|
||||
Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Ipsec: all tests passed");
|
||||
return Failures;
|
||||
}
|
||||
314
tests/Messages/main.cpp
Normal file
314
tests/Messages/main.cpp
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// 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 98\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:98 telephone-event/16000\r\n"
|
||||
"a=fmtp:98 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"
|
||||
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;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: 323\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 98\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:98 telephone-event/16000\r\n"
|
||||
"a=fmtp:98 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");
|
||||
}
|
||||
|
||||
// ---- 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");
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
204
tests/Sdp/main.cpp
Normal file
204
tests/Sdp/main.cpp
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Sdp unit tests — offer building (byte-exact) and answer parsing
|
||||
// (payload-type preference, octet-align, address family). The IPv6 answer
|
||||
// mirrors what a live commercial IMS core actually returns (AMR-WB pt=97
|
||||
// octet-aligned, IPv6 media gateway).
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
using namespace imsd::sdp;
|
||||
|
||||
namespace {
|
||||
int Failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
// ---- BuildOffer, IPv6 + preconditions (the shape a VoLTE UE sends)
|
||||
{
|
||||
std::string sdp = BuildOffer({
|
||||
.local = "2001:db8:29e9:a05f::1",
|
||||
.rtpPort = 22222,
|
||||
.sessionId = 1234567,
|
||||
.precond = true,
|
||||
});
|
||||
std::string expected =
|
||||
"v=0\r\n"
|
||||
"o=- 1234567 1234567 IN IP6 2001:db8:29e9:a05f::1\r\n"
|
||||
"s=-\r\n"
|
||||
"c=IN IP6 2001:db8:29e9:a05f::1\r\n"
|
||||
"t=0 0\r\n"
|
||||
"m=audio 22222 RTP/AVP 97 98\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:98 telephone-event/16000\r\n"
|
||||
"a=fmtp:98 0-15\r\n"
|
||||
"a=ptime:20\r\n"
|
||||
"a=maxptime:240\r\n"
|
||||
"a=curr:qos local sendrecv\r\n"
|
||||
"a=curr:qos remote none\r\n"
|
||||
"a=des:qos mandatory local sendrecv\r\n"
|
||||
"a=des:qos mandatory remote sendrecv\r\n"
|
||||
"a=sendrecv\r\n";
|
||||
Check(sdp == expected, "IPv6 precondition offer is byte-exact");
|
||||
}
|
||||
// ---- BuildOffer, IPv4 without preconditions
|
||||
{
|
||||
std::string sdp = BuildOffer({
|
||||
.local = "10.0.0.2",
|
||||
.rtpPort = 4000,
|
||||
.sessionId = 42,
|
||||
.precond = false,
|
||||
});
|
||||
Check(sdp.contains("o=- 42 42 IN IP4 10.0.0.2\r\n"), "IPv4 origin line");
|
||||
Check(sdp.contains("c=IN IP4 10.0.0.2\r\n"), "IPv4 connection line");
|
||||
Check(!sdp.contains("a=curr:qos"), "no precondition block");
|
||||
Check(sdp.ends_with("a=sendrecv\r\n"), "trailing sendrecv + CRLF");
|
||||
}
|
||||
|
||||
// ---- ParseAnswer, real-world IPv6 answer
|
||||
{
|
||||
Answer a = ParseAnswer(
|
||||
"v=0\r\n"
|
||||
"o=- 99 99 IN IP6 2001:db8::307\r\n"
|
||||
"s=-\r\n"
|
||||
"c=IN IP6 2001:db8::307\r\n"
|
||||
"t=0 0\r\n"
|
||||
"m=audio 35868 RTP/AVP 97 98\r\n"
|
||||
"a=rtpmap:97 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:97 octet-align=1;mode-change-capability=2\r\n"
|
||||
"a=rtpmap:98 telephone-event/16000\r\n"
|
||||
"a=ptime:20\r\n");
|
||||
Check(a.ip == "2001:db8::307", "answer IP6");
|
||||
Check(a.port == 35868, "answer port");
|
||||
Check(a.payloadType == 97, "answer payload type");
|
||||
Check(a.octetAlign, "answer octet-align");
|
||||
Check(a.codec == "AMR-WB", "answer codec");
|
||||
}
|
||||
// ---- payload-type preference: AMR remembered, later AMR-WB overrides
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 96 97\r\n" "a=rtpmap:96 AMR/8000/1\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n");
|
||||
Check(a.payloadType == 97, "AMR-WB preferred over earlier AMR");
|
||||
Check(a.codec == "AMR-WB", "codec follows the preferred rtpmap");
|
||||
}
|
||||
// ---- octet-aligned AMR-WB preferred over an earlier BE AMR-WB
|
||||
// (the s56 MT-static shape: an offer listing bandwidth-efficient first)
|
||||
{
|
||||
Answer a = ParseAnswer(
|
||||
"c=IN IP6 2001:db8::1\r\n"
|
||||
"m=audio 5000 RTP/AVP 116 118 110\r\n"
|
||||
"a=rtpmap:116 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:116 mode-change-capability=2;max-red=0\r\n"
|
||||
"a=rtpmap:118 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:118 octet-align=1;mode-change-capability=2\r\n"
|
||||
"a=rtpmap:110 telephone-event/16000\r\n");
|
||||
Check(a.payloadType == 118, "octet-aligned AMR-WB wins over earlier BE");
|
||||
Check(a.octetAlign, "octet-align follows the chosen pt");
|
||||
Check(a.codec == "AMR-WB", "codec is AMR-WB");
|
||||
}
|
||||
// ---- BE-only AMR-WB offer stays BE at the first pt
|
||||
{
|
||||
Answer a = ParseAnswer(
|
||||
"c=IN IP6 2001:db8::1\r\n"
|
||||
"m=audio 5000 RTP/AVP 116 96\r\n"
|
||||
"a=rtpmap:116 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:116 mode-change-capability=2\r\n"
|
||||
"a=rtpmap:96 AMR/8000/1\r\n"
|
||||
"a=fmtp:96 octet-align=1\r\n");
|
||||
Check(a.payloadType == 116, "BE-only AMR-WB beats octet-aligned AMR");
|
||||
Check(!a.octetAlign, "BE-only offer negotiates bandwidth-efficient");
|
||||
}
|
||||
// ---- AMR-only answer
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 96\r\n" "a=rtpmap:96 AMR/8000/1\r\n" "a=fmtp:96 octet-align=1\r\n");
|
||||
Check(a.payloadType == 96 && a.codec == "AMR", "plain AMR fallback");
|
||||
Check(a.octetAlign, "octet-align seen for AMR too");
|
||||
}
|
||||
// ---- no rtpmap at all -> first payload type, bandwidth-efficient
|
||||
{
|
||||
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 0\r\n");
|
||||
Check(a.payloadType == 8, "no rtpmap -> first payload type");
|
||||
Check(!a.octetAlign, "no fmtp -> bandwidth-efficient");
|
||||
Check(a.codec.empty(), "no AMR rtpmap -> codec unknown, not assumed");
|
||||
}
|
||||
// ---- degenerate body
|
||||
{
|
||||
Answer a = ParseAnswer("v=0\r\n");
|
||||
Check(a.ip.empty() && a.port == -1 && a.payloadType == -1, "empty answer parses to absent fields");
|
||||
}
|
||||
|
||||
// ---- TelephoneEventPt
|
||||
{
|
||||
std::string_view offer =
|
||||
"m=audio 27864 RTP/AVP 104 105\r\n"
|
||||
"a=rtpmap:104 AMR-WB/16000/1\r\n"
|
||||
"a=rtpmap:105 telephone-event/16000\r\n"
|
||||
"a=rtpmap:106 telephone-event/8000\r\n";
|
||||
Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate");
|
||||
Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt");
|
||||
Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt");
|
||||
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt");
|
||||
}
|
||||
|
||||
// ---- BuildAnswer: the offer's own payload types, octet-align mirrored
|
||||
{
|
||||
std::string sdp = BuildAnswer({
|
||||
.local = "2001:db8:29e9:a05f::1",
|
||||
.rtpPort = 50004,
|
||||
.sessionId = 777,
|
||||
.payloadType = 104,
|
||||
.octetAlign = true,
|
||||
.codec = "AMR-WB",
|
||||
.dtmfPt = 105,
|
||||
});
|
||||
std::string expected =
|
||||
"v=0\r\n"
|
||||
"o=- 777 777 IN IP6 2001:db8:29e9:a05f::1\r\n"
|
||||
"s=-\r\n"
|
||||
"c=IN IP6 2001:db8:29e9:a05f::1\r\n"
|
||||
"t=0 0\r\n"
|
||||
"m=audio 50004 RTP/AVP 104 105\r\n"
|
||||
"b=AS:41\r\n"
|
||||
"b=RS:512\r\n"
|
||||
"b=RR:1536\r\n"
|
||||
"a=rtpmap:104 AMR-WB/16000/1\r\n"
|
||||
"a=fmtp:104 octet-align=1\r\n"
|
||||
"a=rtpmap:105 telephone-event/16000\r\n"
|
||||
"a=fmtp:105 0-15\r\n"
|
||||
"a=ptime:20\r\n"
|
||||
"a=maxptime:240\r\n"
|
||||
"a=sendrecv\r\n";
|
||||
Check(sdp == expected, "AMR-WB answer is byte-exact");
|
||||
}
|
||||
// ---- BuildAnswer: bandwidth-efficient offer, no DTMF, narrowband
|
||||
{
|
||||
std::string sdp = BuildAnswer({
|
||||
.local = "10.0.0.2",
|
||||
.rtpPort = 4000,
|
||||
.sessionId = 8,
|
||||
.payloadType = 96,
|
||||
.octetAlign = false,
|
||||
.codec = "AMR",
|
||||
});
|
||||
Check(sdp.contains("m=audio 4000 RTP/AVP 96\r\n"), "single-pt m-line");
|
||||
Check(sdp.contains("a=rtpmap:96 AMR/8000/1\r\n"), "narrowband rtpmap");
|
||||
Check(!sdp.contains("octet-align"), "bandwidth-efficient stays unmarked");
|
||||
Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none");
|
||||
Check(sdp.contains("b=AS:30\r\n"), "narrowband bandwidth");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Sdp: all tests passed");
|
||||
return Failures;
|
||||
}
|
||||
179
tests/Sip/main.cpp
Normal file
179
tests/Sip/main.cpp
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Sip unit tests — header access, status-line parsing, granted-expires
|
||||
// extraction, caller-identity extraction, and the TCP frame buffer (split
|
||||
// delivery, keepalive pongs, pipelined messages). These pin the module's
|
||||
// edge-case behavior; see the module header before "fixing" one.
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
using namespace imsd::sip;
|
||||
|
||||
namespace {
|
||||
int Failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string Resp200 =
|
||||
"SIP/2.0 200 OK\r\n"
|
||||
"Via: SIP/2.0/TCP [2001:db8::1]:45061;branch=z9hG4bK1\r\n"
|
||||
"Via: SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2\r\n"
|
||||
"From: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=abc\r\n"
|
||||
"To: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=def\r\n"
|
||||
"Call-ID: 12345@host\r\n"
|
||||
"CSeq: 2 REGISTER\r\n"
|
||||
"contact: <sip:user@[2001:db8::1]:45061>;expires=600000\r\n"
|
||||
"P-Associated-URI: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
|
||||
"Content-Length: 0\r\n"
|
||||
"\r\n";
|
||||
}
|
||||
|
||||
int main() {
|
||||
// ---- Status
|
||||
Check(Status(Resp200) == 200, "Status parses 200");
|
||||
Check(Status("SIP/2.0 183 Session Progress\r\n\r\n") == 183, "Status parses 183");
|
||||
Check(!Status("INVITE sip:+31@x SIP/2.0\r\n\r\n").has_value(), "Status rejects a request start-line");
|
||||
Check(!Status("SIP/2.0 xx3\r\n").has_value(), "Status rejects non-digits");
|
||||
|
||||
// ---- Header / Headers
|
||||
Check(Header(Resp200, "Call-ID") == "12345@host", "Header finds Call-ID");
|
||||
Check(Header(Resp200, "call-id") == "12345@host", "Header is case-insensitive (query)");
|
||||
Check(Header(Resp200, "Contact") == "<sip:user@[2001:db8::1]:45061>;expires=600000", "Header is case-insensitive (message)");
|
||||
Check(!Header(Resp200, "Route").has_value(), "Header absent -> nullopt");
|
||||
Check(Header(Resp200, "CSeq") == "2 REGISTER", "Header trims the value");
|
||||
Check(Headers(Resp200, "Via").size() == 2, "Headers returns every Via");
|
||||
Check(Headers(Resp200, "Via")[1] == "SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2", "Headers keeps order");
|
||||
// name must sit at line start: "P-Associated-URI" must not match "URI"
|
||||
Check(!Header(Resp200, "URI").has_value(), "Header anchors at line start");
|
||||
// bare "Name:" (only CR after the colon) matches with empty value
|
||||
Check(Header("X-Empty:\r\nX-Real: v\r\n\r\n", "X-Empty") == "", "bare header matches with empty value");
|
||||
|
||||
// ---- GrantedExpires
|
||||
Check(GrantedExpires(Resp200) == 600000, "GrantedExpires from Contact expires=");
|
||||
Check(GrantedExpires("SIP/2.0 200 OK\r\nExpires: 3600\r\n\r\n") == 3600, "GrantedExpires falls back to Expires");
|
||||
Check(!GrantedExpires("SIP/2.0 200 OK\r\nExpires: abc\r\n\r\n").has_value(), "non-numeric Expires -> nullopt");
|
||||
Check(!GrantedExpires("SIP/2.0 200 OK\r\n\r\n").has_value(), "no lifetime -> nullopt");
|
||||
Check(GrantedExpires("SIP/2.0 200 OK\r\n" "Contact: <sip:a@b>\r\n" "Contact: <sip:c@d>;expires=7200\r\n\r\n") == 7200, "GrantedExpires scans all Contacts");
|
||||
|
||||
// ---- FrameBuffer
|
||||
{
|
||||
FrameBuffer fb;
|
||||
fb.Append(Resp200);
|
||||
auto m = fb.TryExtract();
|
||||
Check(m == Resp200, "whole message extracts intact");
|
||||
Check(!fb.TryExtract().has_value(), "buffer empty after extract");
|
||||
}
|
||||
{
|
||||
// split mid-header and mid-body
|
||||
std::string body = "v=0\r\no=- 1 1 IN IP6 ::1\r\n";
|
||||
std::string msg = std::format("SIP/2.0 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.size(), body);
|
||||
FrameBuffer fb;
|
||||
fb.Append(msg.substr(0, 20));
|
||||
Check(!fb.TryExtract().has_value(), "incomplete head -> nullopt");
|
||||
fb.Append(msg.substr(20, msg.size() - 20 - 5));
|
||||
Check(!fb.TryExtract().has_value(), "incomplete body -> nullopt");
|
||||
fb.Append(msg.substr(msg.size() - 5));
|
||||
Check(fb.TryExtract() == msg, "split message reassembles");
|
||||
}
|
||||
{
|
||||
// leading CRLF keepalive pongs are discarded (RFC 5626)
|
||||
FrameBuffer fb;
|
||||
fb.Append("\r\n\r\n");
|
||||
Check(!fb.TryExtract().has_value(), "pure keepalive -> nullopt");
|
||||
Check(fb.Size() == 0, "keepalive bytes discarded");
|
||||
fb.Append("\r\n");
|
||||
fb.Append(Resp200);
|
||||
Check(fb.TryExtract() == Resp200, "message after keepalive extracts");
|
||||
}
|
||||
{
|
||||
// two pipelined messages come out one at a time, in order
|
||||
std::string first = "SIP/2.0 100 Trying\r\nContent-Length: 0\r\n\r\n";
|
||||
FrameBuffer fb;
|
||||
fb.Append(first + Resp200);
|
||||
Check(fb.TryExtract() == first, "pipelined: first message");
|
||||
Check(fb.TryExtract() == Resp200, "pipelined: second message");
|
||||
Check(!fb.TryExtract().has_value(), "pipelined: then empty");
|
||||
}
|
||||
{
|
||||
// missing Content-Length counts as 0
|
||||
std::string msg = "SIP/2.0 200 OK\r\nCall-ID: x\r\n\r\n";
|
||||
FrameBuffer fb;
|
||||
fb.Append(std::format("{}LEFTOVER", msg));
|
||||
Check(fb.TryExtract() == msg, "no Content-Length -> body length 0");
|
||||
Check(fb.Size() == 8, "trailing bytes stay buffered");
|
||||
}
|
||||
|
||||
// ---- CallerId: P-Asserted-Identity preferred, From fallback
|
||||
{
|
||||
std::string inv =
|
||||
"INVITE sip:me SIP/2.0\r\n"
|
||||
"From: \"Someone\" <sip:+31687654321@ims.example;user=phone>;tag=f\r\n"
|
||||
"P-Asserted-Identity: <tel:+31612345678>\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(CallerId(inv) == "+31612345678", "tel: P-Asserted-Identity wins");
|
||||
}
|
||||
{
|
||||
std::string inv =
|
||||
"INVITE sip:me SIP/2.0\r\n"
|
||||
"P-Asserted-Identity: <sip:+31612345678@ims.example;user=phone>\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(CallerId(inv) == "+31612345678", "sip: identity yields the user part");
|
||||
}
|
||||
{
|
||||
std::string inv =
|
||||
"INVITE sip:me SIP/2.0\r\n"
|
||||
"From: <sip:+31687654321@ims.example;user=phone>;tag=f\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(CallerId(inv) == "+31687654321", "From fallback without P-Asserted-Identity");
|
||||
}
|
||||
{
|
||||
std::string inv =
|
||||
"INVITE sip:me SIP/2.0\r\n"
|
||||
"From: <sip:anonymous@anonymous.invalid>;tag=f\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(CallerId(inv) == "anonymous", "anonymous caller passes through");
|
||||
}
|
||||
{
|
||||
std::string inv =
|
||||
"INVITE sip:me SIP/2.0\r\n"
|
||||
"From: tel:+31600000001;tag=f\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(CallerId(inv) == "+31600000001", "bare (bracketless) tel URI");
|
||||
Check(CallerId("INVITE sip:me SIP/2.0\r\nContent-Length: 0\r\n\r\n").empty(), "no identity headers -> empty");
|
||||
}
|
||||
|
||||
// ---- ViaResponsePort: RFC 3261 18.2.2 UDP response routing
|
||||
{
|
||||
std::string upd =
|
||||
"UPDATE sip:me SIP/2.0\r\n"
|
||||
"Via: SIP/2.0/UDP [2001:db8::105]:6000;branch=z9hG4bKx;_aluscr_\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(ViaResponsePort(upd) == 6000, "v6 sent-by port extracted");
|
||||
std::string rp =
|
||||
"UPDATE sip:me SIP/2.0\r\n"
|
||||
"Via: SIP/2.0/UDP [2001:db8::105]:6000;rport;branch=z9hG4bKx\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(!ViaResponsePort(rp).has_value(), "rport -> symmetric (nullopt)");
|
||||
std::string v4 =
|
||||
"UPDATE sip:me SIP/2.0\r\n"
|
||||
"Via: SIP/2.0/UDP 192.0.2.1:5062;branch=z9hG4bKx\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(ViaResponsePort(v4) == 5062, "v4 sent-by port extracted");
|
||||
std::string np =
|
||||
"UPDATE sip:me SIP/2.0\r\n"
|
||||
"Via: SIP/2.0/UDP [2001:db8::105];branch=z9hG4bKx\r\n"
|
||||
"Content-Length: 0\r\n\r\n";
|
||||
Check(!ViaResponsePort(np).has_value(), "portless v6 sent-by -> nullopt");
|
||||
Check(!ViaResponsePort("UPDATE sip:me SIP/2.0\r\n\r\n").has_value(), "no Via -> nullopt");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Sip: all tests passed");
|
||||
return Failures;
|
||||
}
|
||||
100
tests/Util/main.cpp
Normal file
100
tests/Util/main.cpp
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex
|
||||
// decoding, host/port formatting, and the deterministic-seed RNG. MD5 and
|
||||
// base64 are our own (the standard library ships neither) and the AKAv1-MD5
|
||||
// digest depends on them exactly, so these are pinned hard.
|
||||
import std;
|
||||
import Imsd;
|
||||
|
||||
using namespace imsd::util;
|
||||
|
||||
namespace {
|
||||
int Failures = 0;
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
// ---- MD5: the RFC 1321 appendix A.5 test suite
|
||||
Check(Md5::HexDigest("") == "d41d8cd98f00b204e9800998ecf8427e", "md5 empty");
|
||||
Check(Md5::HexDigest("a") == "0cc175b9c0f1b6a831c399e269772661", "md5 a");
|
||||
Check(Md5::HexDigest("abc") == "900150983cd24fb0d6963f7d28e17f72", "md5 abc");
|
||||
Check(Md5::HexDigest("message digest") == "f96b697d7cb7938d525a2f31aaf161d0", "md5 message digest");
|
||||
Check(Md5::HexDigest("abcdefghijklmnopqrstuvwxyz") == "c3fcd3d76192e4007dfb496cca67e13b", "md5 alphabet");
|
||||
Check(Md5::HexDigest("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") == "d174ab98d277d9f5a5611c2c9f419d9f", "md5 alnum");
|
||||
Check(Md5::HexDigest("12345678901234567890123456789012345678901234567890" "123456789012345678901234567890") == "57edf4a22be3c955ac49da2e2107b67a", "md5 80 digits");
|
||||
// streaming across a block boundary must equal a single-shot digest
|
||||
{
|
||||
std::string big(200, 'x');
|
||||
Md5 m;
|
||||
m.Update(std::string_view(big).substr(0, 13));
|
||||
m.Update(std::string_view(big).substr(13, 100));
|
||||
m.Update(std::string_view(big).substr(113));
|
||||
Check(ToHex(m.Digest()) == Md5::HexDigest(big), "md5 streaming == oneshot");
|
||||
}
|
||||
// ---- base64 decode
|
||||
{
|
||||
auto d = FromBase64("aGVsbG8=");
|
||||
Check(d.has_value(), "b64 decodes");
|
||||
Check(std::string(d->begin(), d->end()) == "hello", "b64 hello");
|
||||
}
|
||||
{
|
||||
// 32-byte RAND||AUTN style payload round-trips through hex->b64->decode
|
||||
auto d = FromBase64("TWFuIGlzIGRpc3Rpbmd1aXNoZWQ=");
|
||||
Check(d && std::string(d->begin(), d->end()) == "Man is distinguished", "b64 longer");
|
||||
}
|
||||
Check(!FromBase64("****").has_value(), "b64 rejects junk");
|
||||
|
||||
// ---- hex
|
||||
{
|
||||
auto d = FromHex("00ff10");
|
||||
Check(d && d->size() == 3 && (*d)[1] == 0xFF, "hex decodes");
|
||||
auto c = FromHex("A0:00:00:00:87");
|
||||
Check(c && c->size() == 5 && (*c)[0] == 0xA0, "hex skips colons (APDU form)");
|
||||
Check(!FromHex("abc").has_value(), "hex rejects odd length");
|
||||
Check(!FromHex("zz").has_value(), "hex rejects bad char");
|
||||
std::array<std::uint8_t, 3> b{0x0a, 0x84, 0xab};
|
||||
Check(ToHex(b) == "0a84ab", "tohex");
|
||||
}
|
||||
|
||||
// ---- host/port + is6
|
||||
Check(Is6("2001:db8::1") && !Is6("10.0.0.1"), "is6");
|
||||
Check(HostPort("2001:db8::1", 45061) == "[2001:db8::1]:45061", "hostport v6");
|
||||
Check(HostPort("10.0.0.1", 5060) == "10.0.0.1:5060", "hostport v4");
|
||||
|
||||
// ---- RNG: deterministic seed reproducibility + alphabet + length
|
||||
{
|
||||
Rng a(1234), b(1234);
|
||||
std::string ta = a.Token(20), tb = b.Token(20);
|
||||
Check(ta.size() == 20, "token length");
|
||||
Check(ta == tb, "same seed -> same token");
|
||||
for (char c : ta)
|
||||
Check((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'), "token alphabet");
|
||||
Rng c(9999);
|
||||
Check(c.Token(20) != ta, "different seed -> different token");
|
||||
}
|
||||
|
||||
// ---- Uuid4: RFC 4122 v4 shape
|
||||
{
|
||||
Rng r(42);
|
||||
std::string u = Uuid4(r);
|
||||
Check(u.size() == 36, "uuid length");
|
||||
Check(u[8] == '-' && u[13] == '-' && u[18] == '-' && u[23] == '-', "uuid dashes");
|
||||
Check(u[14] == '4', "uuid version nibble");
|
||||
Check(u[19] == '8' || u[19] == '9' || u[19] == 'a' || u[19] == 'b', "uuid variant nibble");
|
||||
for (std::size_t i = 0; i < u.size(); i++)
|
||||
if (i != 8 && i != 13 && i != 18 && i != 23) Check((u[i] >= '0' && u[i] <= '9') || (u[i] >= 'a' && u[i] <= 'f'), "uuid hex alphabet");
|
||||
Rng r2(42);
|
||||
Check(Uuid4(r2) == u, "same seed -> same uuid");
|
||||
}
|
||||
|
||||
if (Failures == 0) std::println("Util: all tests passed");
|
||||
return Failures;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue