You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
rtpengine/t/auto-daemon-tests-rollback.pl

487 lines
20 KiB

#!/usr/bin/perl
use strict;
use warnings;
use NGCP::Rtpengine::Test;
use NGCP::Rtpengine::AutoTest;
use NGCP::Rtpclient::ICE;
use NGCP::Rtpclient::DTLS;
use IO::Multiplex;
use Socket qw(MSG_DONTWAIT);
use Test::More;
autotest_start(qw(--config-file=none -t -1 -i 203.0.113.1
-n 2223 -c 12345 -f -L 7 -E -u 2222)) or die;
sub sdp {
my ($address, $port, $payload, $direction, $extra_media) = @_;
my $codec = $payload == 8 ? 'PCMA' : 'PCMU';
my $ret = "v=0\r\no=- 1 1 IN IP4 $address\r\ns=rollback\r\n"
. "c=IN IP4 $address\r\nt=0 0\r\nm=audio $port RTP/AVP $payload\r\n"
. "a=rtpmap:$payload $codec/8000\r\na=$direction\r\n";
$ret .= "m=video " . ($port + 2) . " RTP/AVP 96\r\n"
. "a=rtpmap:96 VP8/90000\r\na=$direction\r\n" if $extra_media;
return $ret;
}
sub rollback {
my ($via_branch) = @_;
my %req = ('call-id' => cid(), 'from-tag' => ft(), 'to-tag' => tt());
$req{'via-branch'} = $via_branch if defined $via_branch;
return rtpe_req('rollback', 'rollback state', \%req);
}
sub negotiated_tags {
my ($state) = @_;
# __fill_stream() refreshes ps->last_packet_us on each offer. Query exposes
# that value, truncated to seconds, as both "last packet" and "last user
# packet". It is liveness state rather than negotiated media state.
# Whole-tag comparisons are safe only while no media has flowed: once packets
# arrive, the per-media SSRC lists also contain traffic-derived statistics.
# Such tests must compare only the negotiated fields they intend to restore.
for my $tag (values %{$state->{tags}}) {
for my $media (@{$tag->{medias}}) {
for my $stream (@{$media->{streams}}) {
delete $stream->{'last packet'};
delete $stream->{'last user packet'};
}
}
}
return $state->{tags};
}
sub secure_sdp {
my ($address, $port, $ufrag, $pwd, $key, $direction) = @_;
return "v=0\r\no=- 2 2 IN IP4 $address\r\ns=rollback-secure\r\n"
. "c=IN IP4 $address\r\nt=0 0\r\nm=audio $port RTP/SAVP 0\r\n"
. "a=rtpmap:0 PCMU/8000\r\na=$direction\r\n"
. "a=ice-ufrag:$ufrag\r\na=ice-pwd:$pwd\r\n"
. "a=candidate:1 1 UDP 2130706431 $address $port typ host\r\n"
. "a=candidate:1 2 UDP 2130706430 $address " . ($port + 1) . " typ host\r\n"
. "a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:$key\r\n";
}
sub dtls_sdp {
my ($address, $port, $fingerprint, $tls_id, $setup) = @_;
return "v=0\r\no=- 3 3 IN IP4 $address\r\ns=rollback-dtls\r\n"
. "c=IN IP4 $address\r\nt=0 0\r\nm=audio $port UDP/TLS/RTP/SAVP 0\r\n"
. "a=rtpmap:0 PCMU/8000\r\na=setup:$setup\r\n"
. "a=fingerprint:sha-256 $fingerprint\r\na=tls-id:$tls_id\r\n";
}
sub secure_parameters {
my ($sdp) = @_;
my @parameters = $sdp =~ /^(a=(?:ice-ufrag|ice-pwd):.*|a=crypto:1 .*)$/mg;
return \@parameters;
}
my ($rollback_dtls, $rollback_dtls_mux, $rollback_dtls_connected,
@rollback_dtls_components);
my $rollback_dtls_output = sub {
my ($component, $data) = @_;
my ($socket, $port) = @{$rollback_dtls_components[$component]};
snd($socket, $port, $data);
};
sub mux_input {
my ($self, $mux, $fh, $input) = @_;
my $peer = $mux->udp_peer($fh);
$rollback_dtls->input($fh, $input, $peer);
for my $component (@$rollback_dtls) {
return unless $component->{_connected};
}
return if $rollback_dtls_connected;
$rollback_dtls_connected = 1;
pass('DTLS re-handshake succeeds with restored configuration');
$mux->endloop();
}
sub consecutive_offer_rollback {
my ($label) = @_;
new_call;
rtpe_req('offer', "$label initial offer", {
'from-tag' => ft(), flags => ['track-state'],
sdp => sdp('198.51.100.60', 10000, 0, 'sendrecv'),
});
rtpe_req('answer', "$label initial answer", {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.61', 11000, 0, 'sendrecv'),
});
my $committed_state = rtpe_req('query', "$label committed state", {});
my $first = rtpe_req('offer', "$label first pending offer", {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.62', 10020, 8, 'sendonly'),
});
my $second = rtpe_req('offer', "$label second pending offer", {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.63', 10030, 8, 'recvonly', 1),
});
my $rolled_back = rollback();
is($rolled_back->{'rolled-back'}, 1, "$label rollback consumes the original snapshot");
my $restored_state = rtpe_req('query', "$label restored state", {});
is_deeply(negotiated_tags($restored_state), negotiated_tags($committed_state),
"$label rollback restores the originally committed state");
}
new_call;
my $resp = rtpe_req('offer', 'tracked initial offer', {
'from-tag' => ft(), flags => ['track-state'], supports => ['rollback'],
sdp => sdp('198.51.100.10', 4000, 0, 'sendrecv'),
});
is_deeply($resp->{supported}, ['rollback'], 'rollback capability advertised');
$resp = rtpe_req('answer', 'tracked initial answer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.20', 5000, 0, 'sendrecv'),
});
my $committed = rtpe_req('query', 'query committed state', {});
$resp = rtpe_req('offer', 'failed renegotiation', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.11', 4010, 8, 'sendonly', 1),
});
$resp = rollback();
is($resp->{'rolled-back'}, 1, 'pending checkpoint rolls back');
my $restored = rtpe_req('query', 'query restored state', {});
is_deeply(negotiated_tags($restored), negotiated_tags($committed),
'query media state restored');
$resp = rollback();
is($resp->{'rolled-back'}, 0, 'repeated rollback is a no-op');
rtpe_req('offer', 'completed renegotiation offer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.12', 4020, 8, 'sendrecv'),
});
$resp = rtpe_req('answer', 'completed renegotiation answer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.22', 5020, 8, 'sendrecv'),
});
$resp = rollback(2);
is($resp->{'rolled-back'}, 0, 'completed exchange cannot be rolled back');
consecutive_offer_rollback('consecutive offers');
my ($subscription_a, $subscription_b, $subscription_sink) = new_call(
[qw(198.51.100.80 12000)],
[qw(198.51.100.80 12010)],
[qw(198.51.100.80 12020)],
);
my $subscription_offer = rtpe_req('offer', 'subscription rollback initial offer', {
'from-tag' => ft(), flags => ['track-state'],
sdp => sdp('198.51.100.80', 12000, 0, 'sendrecv'),
});
my ($subscription_port_a) = $subscription_offer->{sdp} =~ /^m=audio (\d+)/m;
my $subscription_answer = rtpe_req('answer', 'subscription rollback initial answer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.80', 12010, 0, 'sendrecv'),
});
my ($subscription_port_b) = $subscription_answer->{sdp} =~ /^m=audio (\d+)/m;
my $subscription = rtpe_req('subscribe request', 'subscription before rollback', {
'from-tag' => ft(),
});
my ($subscription_port_sink) = $subscription->{sdp} =~ /^m=audio (\d+)/m;
ok($subscription_port_a && $subscription_port_b && $subscription_port_sink,
'subscription relay ports captured');
rtpe_req('subscribe answer', 'subscription before rollback answer', {
'from-tag' => $subscription->{'from-tag'},
'to-tag' => $subscription->{'to-tag'},
sdp => sdp('198.51.100.80', 12020, 0, 'recvonly'),
});
snd($subscription_a, $subscription_port_b,
rtp(0, 5000, 8000, 0x7890, "\x55" x 160));
rcv($subscription_b, $subscription_port_a,
rtpm(0, 5000, 8000, 0x7890, "\x55" x 160));
rcv($subscription_sink, $subscription_port_sink,
rtpm(0, 5000, 8000, 0x7890, "\x55" x 160));
my $subscription_committed = rtpe_req('query', 'subscription committed state', {});
my $subscription_pending = rtpe_req('offer', 'subscription rejected renegotiation', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.81', 12030, 8, 'sendonly'),
});
my $subscription_rollback = rollback();
is($subscription_rollback->{'rolled-back'}, 1,
'subscription call rolls back rejected renegotiation');
my $subscription_restored = rtpe_req('query', 'subscription restored state', {});
# Do not compare the complete query: restoring the committed remote endpoint is
# itself an endpoint change and therefore triggers call_stream_crypto_reset(),
# which intentionally resets the SSRC's ext_seq along with the crypto context.
for my $tag (keys %{$subscription_committed->{tags}}) {
is_deeply($subscription_restored->{tags}{$tag}{subscriptions},
$subscription_committed->{tags}{$tag}{subscriptions},
"rollback preserves subscriptions for $tag");
is_deeply($subscription_restored->{tags}{$tag}{subscribers},
$subscription_committed->{tags}{$tag}{subscribers},
"rollback preserves subscribers for $tag");
}
is_deeply($subscription_restored->{tags}{ft()}{medias}[0]{streams}[0]{endpoint},
$subscription_committed->{tags}{ft()}{medias}[0]{streams}[0]{endpoint},
'active subscription resolves to the restored source endpoint');
snd($subscription_a, $subscription_port_b,
rtp(0, 5001, 8160, 0x7890, "\x66" x 160));
rcv($subscription_b, $subscription_port_a,
rtpm(0, 5001, 8160, 0x7890, "\x66" x 160));
rcv($subscription_sink, $subscription_port_sink,
rtpm(0, 5001, 8160, 0x7890, "\x66" x 160));
new_call;
rtpe_req('offer', 'untracked offer', {
'from-tag' => ft(), sdp => sdp('198.51.100.30', 6000, 0, 'sendrecv'),
});
rtpe_req('answer', 'untracked answer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.40', 7000, 0, 'sendrecv'),
});
$resp = rollback();
is($resp->{'rolled-back'}, 0, 'untracked call has no checkpoint');
my ($secure_sock) = new_call([qw(198.51.100.50 8000)]);
my $secure_offer = secure_sdp('198.51.100.50', 8000, 'oldUfrag',
'oldPassword0123456789012', 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw', 'sendrecv');
$resp = rtpe_req('offer', 'tracked ICE and SDES offer', {
'from-tag' => ft(), 'via-branch' => 'rollback-branch',
flags => ['track-state'], sdp => $secure_offer,
});
my $secure_parameters = secure_parameters($resp->{sdp});
rtpe_req('answer', 'tracked ICE and SDES answer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => secure_sdp('198.51.100.51', 9000, 'answerUfrag',
'answerPassword0123456789', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2', 'sendrecv'),
});
rtpe_req('offer', 'ICE restart and SDES rekey', {
'from-tag' => ft(), 'to-tag' => tt(), 'via-branch' => 'rollback-branch',
sdp => secure_sdp('198.51.100.52', 8010, 'newUfrag',
'newPassword0123456789012', 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIz', 'sendonly'),
});
my $branch_error = rtpe_raw_req({command => 'rollback', 'call-id' => cid(),
'from-tag' => ft(), 'to-tag' => tt(), 'via-branch' => 'wrong-branch'});
like($branch_error, qr/Unknown dialogue/, 'incorrect via-branch does not select the dialogue');
$resp = rollback(2, 'rollback-branch');
is($resp->{'rolled-back'}, 1, 'ICE restart and SDES rekey roll back');
$resp = rtpe_req('offer', 'replay pre-restart ICE and SDES offer', {
'from-tag' => ft(), 'to-tag' => tt(), 'via-branch' => 'rollback-branch', sdp => $secure_offer,
});
is_deeply(secure_parameters($resp->{sdp}), $secure_parameters,
'local ICE credentials and SDES key are restored');
my ($secure_port) = $resp->{sdp} =~ /^m=audio (\d+)/m;
my ($local_ufrag) = $resp->{sdp} =~ /^a=ice-ufrag:(\S+)/m;
my ($local_pwd) = $resp->{sdp} =~ /^a=ice-pwd:(\S+)/m;
my @restored_check = rcv($secure_sock, -1,
qr/^\x00\x01\x00.\x21\x12\xa4\x42(............)/s);
snd($secure_sock, $secure_port, NGCP::Rtpclient::ICE::stun_succ(
$secure_port, $restored_check[2], 'oldPassword0123456789012'));
while (1) {
my $discard = '';
last unless defined $secure_sock->recv($discard, 65535, MSG_DONTWAIT);
}
my ($stun_packet) = NGCP::Rtpclient::ICE::stun_req(0, 65527, 1,
'oldUfrag', $local_ufrag, $local_pwd);
snd($secure_sock, $secure_port, $stun_packet);
rcv($secure_sock, -1, qr/^\x01\x01\x00.\x21\x12\xa4\x42/s);
pass('restored ICE credentials authenticate a connectivity check');
rollback(2, 'rollback-branch');
my ($dtls_sock) = new_call([qw(198.51.100.55 9500)]);
$rollback_dtls_mux = IO::Multiplex->new();
$rollback_dtls_mux->set_callback_object(__PACKAGE__);
$rollback_dtls = NGCP::Rtpclient::DTLS::Group->new($rollback_dtls_mux,
$rollback_dtls_output, [[$dtls_sock]]);
my $original_fingerprint = $rollback_dtls->[0]->fingerprint();
my $original_dtls_offer = dtls_sdp('198.51.100.55', 9500, $original_fingerprint,
'rollback-original', 'passive');
rtpe_req('offer', 'tracked DTLS offer', {
'from-tag' => ft(), flags => ['track-state'], SDES => 'off',
sdp => $original_dtls_offer,
});
my $dtls_answer = rtpe_req('answer', 'tracked DTLS answer', {
'from-tag' => ft(), 'to-tag' => tt(), SDES => 'off',
sdp => dtls_sdp('198.51.100.56', 9510, join(':', ('BB') x 32),
'rollback-answer', 'active'),
});
my ($restored_dtls_port) = $dtls_answer->{sdp} =~ /^m=audio (\d+)/m;
ok($restored_dtls_port, 'committed DTLS relay port captured');
rtpe_req('offer', 'DTLS fingerprint and role change later rejected', {
'from-tag' => ft(), 'to-tag' => tt(), SDES => 'off',
sdp => dtls_sdp('198.51.100.55', 9500, join(':', ('AA') x 32),
'rollback-rejected', 'active'),
});
my $dtls_rollback = rollback(2);
is($dtls_rollback->{'rolled-back'}, 1, 'DTLS configuration rolls back');
$rollback_dtls_mux->add($dtls_sock);
@rollback_dtls_components = ([$dtls_sock, $restored_dtls_port]);
$rollback_dtls->accept();
$rollback_dtls_mux->loop();
rtpe_req('delete', 'delete DTLS rollback call', {
'from-tag' => ft(), 'to-tag' => tt(),
});
new_call;
my $fork_from = ft();
my $fork_a = 'fork-a-' . tt();
my $fork_b = 'fork-b-' . tt();
rtpe_req('offer', 'fork A initial offer', {
'from-tag' => $fork_from, 'via-branch' => 'fork-a', flags => ['track-state'],
sdp => sdp('198.51.100.60', 10000, 0, 'sendrecv'),
});
rtpe_req('answer', 'fork A initial answer', {
'from-tag' => $fork_from, 'to-tag' => $fork_a, 'via-branch' => 'fork-a',
sdp => sdp('198.51.100.61', 10010, 0, 'sendrecv'),
});
rtpe_req('offer', 'fork B initial offer', {
'from-tag' => $fork_from, 'via-branch' => 'fork-b', flags => ['track-state'],
sdp => sdp('198.51.100.62', 10020, 0, 'sendrecv'),
});
rtpe_req('answer', 'fork B initial answer', {
'from-tag' => $fork_from, 'to-tag' => $fork_b, 'via-branch' => 'fork-b',
sdp => sdp('198.51.100.63', 10030, 0, 'sendrecv'),
});
my $fork_a_offer = rtpe_req('offer', 'fork A rejected renegotiation', {
'from-tag' => $fork_from, 'to-tag' => $fork_a, 'via-branch' => 'fork-a',
sdp => sdp('198.51.100.64', 10040, 8, 'sendonly'),
});
my $fork_b_offer = rtpe_req('offer', 'fork B rejected renegotiation', {
'from-tag' => $fork_from, 'to-tag' => $fork_b, 'via-branch' => 'fork-b',
sdp => sdp('198.51.100.65', 10050, 8, 'recvonly'),
});
my $fork_error = rtpe_raw_req({command => 'rollback', 'call-id' => cid(),
'from-tag' => $fork_from, 'to-tag' => $fork_b, 'via-branch' => 'fork-a'});
like($fork_error, qr/Unknown dialogue/, 'branch and to-tag must identify the same fork');
my $fork_a_rollback = rtpe_req('rollback', 'rollback fork A', {
'from-tag' => $fork_from, 'to-tag' => $fork_a, 'via-branch' => 'fork-a',
});
is($fork_a_rollback->{'rolled-back'}, 1, 'fork A rolls back independently');
my $fork_a_repeat = rtpe_req('rollback', 'repeat rollback fork A', {
'from-tag' => $fork_from, 'to-tag' => $fork_a, 'via-branch' => 'fork-a',
});
is($fork_a_repeat->{'rolled-back'}, 0, 'fork A checkpoint was consumed');
my $fork_committed = rtpe_req('query', 'fork state before second rollback', {});
my $fork_b_rollback = rtpe_req('rollback', 'rollback fork B', {
'from-tag' => $fork_from, 'to-tag' => $fork_b, 'via-branch' => 'fork-b',
});
is($fork_b_rollback->{'rolled-back'}, 1, 'fork B checkpoint remains pending');
# The caller monologue is shared between branches. Rolling back the second one
# must not reinstate what rolling back the first one undid.
my $fork_after = rtpe_req('query', 'fork state after both rollbacks', {});
is_deeply($fork_after->{tags}{$fork_from}{medias},
$fork_committed->{tags}{$fork_from}{medias},
'rolling back the second fork leaves the caller alone');
new_call;
rtpe_req('offer', 'stress initial offer', {
'from-tag' => ft(), flags => ['track-state'],
sdp => sdp('198.51.100.70', 11000, 0, 'sendrecv'),
});
rtpe_req('answer', 'stress initial answer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.71', 11010, 0, 'sendrecv'),
});
for my $iteration (1 .. 10) {
rtpe_req('offer', "stress offer $iteration", {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.72', 11020 + $iteration * 2,
$iteration % 2 ? 8 : 0, $iteration % 2 ? 'sendonly' : 'recvonly'),
});
my $rolled_back = rollback();
is($rolled_back->{'rolled-back'}, 1, "stress rollback $iteration consumes checkpoint");
}
my $pending_delete = rtpe_req('offer', 'leave checkpoint pending for delete', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.73', 11100, 8, 'sendonly'),
});
rtpe_req('delete', 'delete call after repeated rollbacks', {
'from-tag' => ft(), 'to-tag' => tt(),
});
my $error = rtpe_raw_req({command => 'rollback', 'call-id' => 'unknown-call',
'from-tag' => 'from', 'to-tag' => 'to'});
like($error, qr/Unknown call-id/, 'unknown call is an error');
$error = rtpe_raw_req({command => 'rollback', 'call-id' => cid(),
'from-tag' => 'unknown-tag', 'to-tag' => tt()});
like($error, qr/Unknown dialogue/, 'unknown dialogue is an error');
# --- a merged call must not claim to have rolled back ---
#
# call_merge() renumbers every unique id, and a snapshot is keyed on them, so a
# checkpoint taken before the merge no longer describes anything. It is dropped,
# and rollback says so rather than reporting a success that restored nothing.
new_call;
my $mg_cid = cid();
my $mg_ft = ft();
my $mg_tt = tt();
rtpe_req('offer', 'merge: tracked offer', {
'from-tag' => $mg_ft, flags => ['track-state'],
sdp => sdp('198.51.100.120', 17000, 0, 'sendrecv'),
});
rtpe_req('answer', 'merge: answer', {
'from-tag' => $mg_ft, 'to-tag' => $mg_tt,
sdp => sdp('198.51.100.121', 17010, 0, 'sendrecv'),
});
rtpe_req('offer', 'merge: rejected offer', {
'from-tag' => $mg_ft, 'to-tag' => $mg_tt,
sdp => sdp('198.51.100.122', 17020, 8, 'sendonly'),
});
new_call;
rtpe_req('offer', 'merge: other call offer', {
'from-tag' => ft(), sdp => sdp('198.51.100.123', 17030, 0, 'sendrecv'),
});
rtpe_req('answer', 'merge: other call answer', {
'from-tag' => ft(), 'to-tag' => tt(),
sdp => sdp('198.51.100.124', 17040, 0, 'sendrecv'),
});
# the first call listed survives, so the tracked one is the side being renumbered
rtpe_req('mesh', 'merge the two calls', {
flags => [],
calls => [cid(), $mg_cid],
tags => [
{ from => $mg_ft, to => [$mg_tt] },
{ from => $mg_tt, to => [$mg_ft] },
],
});
my $mg_resp = rtpe_raw_req({command => 'rollback', 'call-id' => $mg_cid,
'from-tag' => $mg_ft, 'to-tag' => $mg_tt});
ok(ref($mg_resp) ne 'HASH' || !$mg_resp->{'rolled-back'},
'a merged call does not report a rollback it did not perform');
# --- both sides of a dialogue are checkpointed together ---
#
# A monologue is shared between forked branches, so one side can already hold a
# checkpoint while the other has never been tracked. Both are taken together, or
# a rollback restores half a dialogue and still reports success.
new_call;
my $sym_from = ft();
my $sym_a = 'sym-a-' . tt();
my $sym_b = 'sym-b-' . tt();
rtpe_req('offer', 'symmetry: branch A tracked offer', {
'from-tag' => $sym_from, 'via-branch' => 'sym-a', flags => ['track-state'],
sdp => sdp('198.51.100.130', 18000, 0, 'sendrecv'),
});
rtpe_req('answer', 'symmetry: branch A answer', {
'from-tag' => $sym_from, 'to-tag' => $sym_a, 'via-branch' => 'sym-a',
sdp => sdp('198.51.100.131', 18010, 0, 'sendrecv'),
});
# branch B never asks for tracking, but shares the caller monologue with A
rtpe_req('offer', 'symmetry: branch B untracked offer', {
'from-tag' => $sym_from, 'via-branch' => 'sym-b',
sdp => sdp('198.51.100.132', 18020, 0, 'sendrecv'),
});
rtpe_req('answer', 'symmetry: branch B answer', {
'from-tag' => $sym_from, 'to-tag' => $sym_b, 'via-branch' => 'sym-b',
sdp => sdp('198.51.100.133', 18030, 0, 'sendrecv'),
});
my $sym_committed = rtpe_req('query', 'symmetry: committed state', {});
rtpe_req('offer', 'symmetry: branch B rejected offer', {
'from-tag' => $sym_from, 'to-tag' => $sym_b, 'via-branch' => 'sym-b',
sdp => sdp('198.51.100.134', 18040, 8, 'sendonly'),
});
my $sym_rollback = rtpe_req('rollback', 'symmetry: rollback branch B', {
'from-tag' => $sym_from, 'to-tag' => $sym_b, 'via-branch' => 'sym-b',
});
my $sym_restored = rtpe_req('query', 'symmetry: restored state', {});
is_deeply($sym_restored->{tags}{$sym_b}{medias},
$sym_committed->{tags}{$sym_b}{medias},
'the far side is restored, not just the shared caller');
done_testing;