VOLTS

Voip Open Linear Tester Suite

Functional tests for VoIP systems based on voip_patrol and docker.
Some alternative introduction to the system can be found on DOU(ukrainian)

10'000 ft. view

System is designed to run simple call scenarios, that you usually do with your desk phones.
Scenarios are run one by one from scenarios folder in alphabetical order, which could be considered as a limitation, but also allows you to reuse same accounts in a different set of tests. This stands for Linear in the name ;) So, call some destination(s) with one(or more) device(s) and control call arrival on another phone(s).
But wait, there is more. VOLTS also can integrate with your MySQL and/or PostgreSQL databases to write some data there before test and remove it after.
It will make, receive calls and configure database. It's not do transfers at the moment. Sorry. I don't need it

Suite consists of 4 parts, that are running sequentially

  1. Preparation - at this part we're transforming templates to real scenarios of voip_patrol using Jinja2 template engine with jinja2_time extension to put some dynamic data based on time.
  2. Running database scripts. Usually - put some data inside some routing or subscriber data.
  3. Running voip_patrol against scenario.
  4. Again running database scripts. Usually - remove data that had been put at the stage 2.
  5. Report - at this part we're analyzing results of previous step reading and interpreting file obtained running step 3. Printing results in a desired way. Table by default. Steps 2-4 are running sequentially against scenarios files prepared at step 1. One at a time.

Getting

To get it you'll need git installed. But who don't have it nowdays?
Than just run

# git clone https://github.com/igorolhovskiy/volts.git
# cd volts

Building

Suite is designed to run locally from your Linux PC or Mac (maybe). And of course, docker should be installed. It's up to you.
To build, just run

# ./build.sh

inside volts directory. It would build 4 docker images and tag em accordingly.
In a case if voip_patrol is updated, you need to rebuild it's container again, you can do it with

# ./build.sh -r

Running

After building, just run

# ./run.sh

Simple, isn't it? This will run all scenarios found in scenarios folder one by one. To run single scenario, run

# ./run.sh <scenario_name>

or

# ./run.sh scenarios/<scenario_name>

After running of the suite you can always find a voip_patrol presented results in tmp/output folder.

But simply run something blindly is boring, so before this, best to do some

Configuration

We suppose to configure 2 parts here. First, and most complex are

Scenarios

VOLTS scenarios are combined voip_patrol and database-controlled scenarios, that are just being templatized with Jinja2 style. Mostly done not to repeat some passwords, usernames, domains, etc.
Also, due to using jinja2-time extension, it's possible to use dynamic time/date values in your scenarios, for example testing some time-based rules on your PBX. For full documentation on how to use this type of data, please refer to 'jinja2-time' documentation. Values for templates are taken from scenarios/config.yaml
One thing to mention here, that vars from global section transforms to c. and from accounts to a. in templates for shorter notation. Also all settings from global section are inherited to accounts section automatically, unless they are defined there explicitly. To get most of it, please refer to voip_patrol config, but here just some more basic examples.

config.yaml

global:
      domain:     '<SOME_DOMAIN>'
      transport:  'tls'
      srtp:       'dtls,sdes,force'
      play_file:  '/voice_ref_files/8000_12s.wav'
    databases:
      'sippproxydb':
        type:       'mysql'
        user:       'SIP Proxyrw'
        password:   'SIP Proxyrwpass'
        base:       'SIP Proxy'
        host:       'mySIP Proxydb.local'
    accounts:
      '88881':
        username:       '88881'
        auth_username:  '88881-1'
        password:       'SuperSecretPass1'
      '88882':
        username:       '88882'
        auth_username:  '88882-67345'
        password:       'SuperSecretPass2'
      '90001':
        username:       '90001'
        auth_username:  '90001'
        password:       'SuperSecretPass3'

Make a successful register

Here we assume that account data is known to our PBX.

<config>
    <actions>
        <action type="register" label="Register {{ a.88881.label }}"
            transport="{{ a.88881.transport }}"
            <!-- "account" parameter is more used in receive call on this account later -->
            account="{{ a.88881.label }}"
            <!-- username would be a part of AOR - <sip:username@realm> -->
            username="{{ a.88881.username }}"
            <!-- auth_username would be used in WWW-Authorize procedure -->
            auth_username="{{ a.88881.auth_username }}"
            password="{{ a.88881.password }}"
            registrar="{{ c.domain }}"
            realm="{{ a.88881.domain }}"
            <!-- We're expecting get 200 code here, so REGISTER is successfull -->
            expected_cause_code="200"
        />
        <!-- Just wait 2 sec for all timeouts -->
        <action type="wait" complete="true" ms="2000"/>
    </actions>
</config>

.. but what if we need to add account data to PBX dynamically? Assume, that we have SIP Proxy as a PBX here.

<!-- Test simple register -->
  <config>
      <section type="database">
          <actions>
                <!-- "sippproxydb" here is referring to entity in "databases" from config.yaml. "stage" is explained a bit below -->
              <action database="sippproxydb" stage="pre">
                  <!-- what data are we gonna insert into "subscriber" table? -->
                  <table name="subscriber" type="insert" cleanup_after_test="true">
                      <field name="username" value="{{ a.88881.username }}"/>
                      <field name="domain" value="{{ c.domain }}"/>
                      <field name="password" value="{{ a.88881.password }}"/>
                  </table>
              </action>
          </actions>
      </section>
      <section type="voip_patrol">
          <actions>
              <action type="register" label="Register {{ a.88881.label }}"
                  transport="{{ a.88881.transport }}"
                  <!-- "account" parameter is more used in receive call on this account later -->
                  account="{{ a.88881.label }}"
                  <!-- username would be a part of AOR - <sip:username@realm> -->
                  username="{{ a.88881.username }}"
                  <!-- auth_username would be used in WWW-Authorize procedure -->
                  auth_username="{{ a.88881.auth_username }}"
                  password="{{ a.88881.password }}"
                  registrar="{{ c.domain }}"
                  realm="{{ a.88881.domain }}"
                  <!-- We're expecting get 200 code here, so REGISTER is successfull -->
                  expected_cause_code="200"
              />
              <!-- Just wait 2 sec for all timeouts -->
              <action type="wait" complete="true" ms="2000"/>
          </actions>
      </section>
  </config>

Database config is also done in XML. We have 2 stages of database scripts.

So, inside database action you specify tables you're working with. Each table secton have 4 attributes.

Expect fail on register

We're deleting data from database and restoring it afterwards.

<!-- Test simple register -->
  <config>
      <section type="database">
          <actions>
                <!-- "sippproxydb" here is referring to entity in "databases" from config.yaml. "stage" is explained a bit below -->
              <action database="sippproxydb" stage="pre">
                  <!-- what data are we gonna insert into "subscriber" table? -->
                  <table name="subscriber" type="insert" cleanup_after_test="true">
                      <field name="username" value="{{ a.88881.username }}"/>
                      <field name="domain" value="{{ c.domain }}"/>
                      <field name="password" value="{{ a.88881.password }}"/>
                  </table>
              </action>
          </actions>
      </section>
      <section type="voip_patrol">
          <actions>
              <action type="register" label="Register {{ a.88881.label }}"
                  transport="{{ a.88881.transport }}"
                  <!-- "account" parameter is more used in receive call on this account later -->
                  account="{{ a.88881.label }}"
                  <!-- username would be a part of AOR - <sip:username@realm> -->
                  username="{{ a.88881.username }}"
                  <!-- auth_username would be used in WWW-Authorize procedure -->
                  auth_username="{{ a.88881.auth_username }}"
                  password="{{ a.88881.password }}"
                  registrar="{{ c.domain }}"
                  realm="{{ a.88881.domain }}"
                  <!-- We're expecting get 200 code here, so REGISTER is successfull -->
                  expected_cause_code="200"
              />
              <!-- Just wait 2 sec for all timeouts -->
              <action type="wait" complete="true" ms="2000"/>
          </actions>
      </section>
  </config>

Simple call scenario

Register with 1 account and make a call from 90001 to 88881. Max wait time to answer - 15 sec, duration of connected call - 10 sec. Point, we don't register account 90001 here, as we're not receiving a calls on it, just need to provide credentials on INVITE.
Also trick, match_account in accept perfectly links with account in register.

<config>
<actions>
    <!-- As we're using call functionality here - define list of codecs -->
    <action type="codec" disable="all"/>
    <action type="codec" enable="pcma" priority="250"/>
    <action type="codec" enable="pcmu" priority="249"/>
    <action type="codec" enable="opus" priority="248"/>
    <action type="register" label="Register {{ a.88881.label }}"
        transport="{{ a.88881.transport }}"
        account="{{ a.88881.label }}"
        username="{{ a.88881.username }}"
        auth_username="{{ a.88881.auth_username }}"
        password="{{ a.88881.password }}"
        registrar="{{ c.domain }}"
        realm="{{ c.domain }}"
        expected_cause_code="200"
        <!-- Make sure we're using SRTP on a call receive. This is done here as accounts are created before accept(answer) action -->
        srtp="{{ a.88881.srtp }}"
    />
    <action type="wait" complete="true" ms="2000"/>
    <action type="accept" label="Receive call on {{ a.88881.label }}"
        <!-- This is not a load test - so only 1 call is expected -->
        call_count="1"
        <!-- Make sure we're received a call on a previously registered account -->
        match_account="{{ a.88881.label }}"
        <!-- Hangup in 10 seconds after answer -->
        hangup="10"
        <!-- Send back "200 OK" -->
        code="200" reason="OK"
        transport="{{ a.88881.transport }}"
        <!-- Make sure we're using SRTP -->
        srtp="{{ a.88881.srtp }}"
        <!-- Play some file back to gather RTCP stats in report -->
        play="{{ c.play_file }}"
    />
    <action type="call" label="Call {{ a.90001.label }} -> {{ a.88881.label }}"
        transport="tls"
        <!-- We're waiting for an answer -->
        expected_cause_code="200"
        caller="{{ a.90001.label }}@{{ c.domain }}"
        callee="{{ a.88881.label }}@{{ c.domain }}"
        from="sip:{{ a.90001.label }}@{{ c.domain }}"
        to_uri="{{ a.88881.label }}@{{ c.domain }}"
        max_duration="20" hangup="10"
        <!-- We're specifying all auth data here for INVITE -->
        auth_username="{{ a.90001.username }}"
        password="{{ a.90001.password }}"
        realm="{{ c.domain }}"
        rtp_stats="true"
        max_ring_duration="15"
        srtp="{{ a.90001.srtp }}"
        play="{{ c.play_file }}"
    />
    <action type="wait" complete="true" ms="30000"/>
    </actions>
</config>

Advanced call scenario

Register with 2 accounts and call from third one, not answer on 1st and make sure we receive call on second. So, your PBX should be configured to make a Forward-No-Answer from 88881 to 88882.
Also make sure, that on 88882 we got the call from 90001 (based on CallerID).

<config>
    <actions>
        <action type="codec" disable="all"/>
        <action type="codec" enable="pcma" priority="250"/>
        <action type="codec" enable="pcmu" priority="249"/>
        <action type="codec" enable="opus" priority="248"/>
        <action type="register" label="Register {{ a.88881.label }}"
            transport="{{ a.88881.transport }}"
            account="{{ a.88881.label }}"
            username="{{ a.88881.username }}"
            auth_username="{{ a.88881.auth_username }}"
            password="{{ a.88881.password }}"
            registrar="{{ c.domain }}"
            realm="{{ c.domain }}"
            expected_cause_code="200"
            srtp="{{ a.88881.srtp }}"
        />
        <action type="register" label="Register {{ a.88882.label }}"
            transport="{{ a.88882.transport }}"
            account="{{ a.88882.label }}"
            username="{{ a.88882.username }}"
            auth_username="{{ a.88882.auth_username }}"
            password="{{ a.88882.password }}"
            registrar="{{ c.domain }}"
            realm="{{ c.domain }}"
            expected_cause_code="200"
            srtp="{{ a.88882.srtp }}"
        />
        <action type="wait" complete="true" ms="2000"/>
        <action type="call" label="Call from 90001 to 88881->88882"
            transport="{{ a.90001.transport }}"
            expected_cause_code="200"
            caller="{{ a.90001.label }}@{{ c.domain }}"
            callee="88881@{{ c.domain }}"
            from="sip:{{ a.90001.label }}@{{ c.domain }}"
            to_uri="88881@{{ c.domain }}"
            max_duration="20" hangup="10"
            auth_username="{{ a.90001.username }}"
            password="{{ a.90001.password }}"
            realm="{{ c.domain }}"
            rtp_stats="true"
            <!-- Set some high ring timeout, so delayed forward will happen -->
            max_ring_duration="60"
            srtp="{{ a.90001.srtp }}"
            play="{{ c.play_file }}"
        />
        <action type="accept" label="Receive call on {{ a.88881.label }}"
            match_account="{{ a.88881.label }}"
            call_count="1"
            hangup="10"
            ring_duration="30"
            <!-- We're expecting a CANCEL here. And it's not optional -->
            cancel="force"
            transport="{{ a.88881.transport }}"
            srtp="{{ a.88881.srtp }}"
        />
        <action type="accept" label="Receive call on {{ a.88882.label }}"
            match_account="{{ a.88882.label }}"
            call_count="1"
            hangup="10"
            code="200" reason="OK"
            transport="{{ a.88882.transport }}"
            srtp="{{ a.88882.srtp }}"
            play="{{ c.play_file }}">
            <!-- Check that From header matching what we need. This way we can control CallerID. Adjust domain (and whole regex) accordingly -->
            <check-header name="From" regex="^.*sip:{{ a.90001.label }}@example\.com>.*$"/>
        </action>
        <action type="wait" complete="true" ms="20000"/>
    </actions>
</config>

Advanced call scenario - 2. Now with databases.

Schema - SIP Proxy subscriber and than we have Asterisk behind as PBX.

config.yaml

global:
    domain:           '<SOME_DOMAIN>'
    transport:        'tls'
    srtp:             'dtls,sdes,force'
    play_file:        '/voice_ref_files/8000_2m30.wav'
    asterisk_context: 'default'
  databases:
    'sippproxydb':
      type:       'mysql'
      user:       'SIP Proxyrw'
      password:   'SIP Proxyrwpass'
      base:       'SIP Proxy'
      host:       'mySIP Proxydb.local'
    'astdb':
      type:       'pgsql'
      user:       'asteriskrw'
      password:   'asteriskrwpass'
      base:       'asterisk'
      host:       'myasteriskdb.local'
  accounts:
    '90011':
      username: '90011'
      password: 'SuperSecretPass1'
      ha1:      'SuperSecretHA1'
    '90012':
      username: '90012'
      password: 'SuperSecretPass2'
      ha1:      'SuperSecretHA2'

And now we need to populate all databases and make a call!

<!-- Register with 90012 and receive a call from 90011 -->
  <config>
      <section type="database">
          <actions>
          <!-- add subscribers to SIP Proxy -->
              <action database="sippproxydb" stage="pre">
                  <table name="subscriber" type="insert"  cleanup_after_test="true">
                      <field name="username" value="{{ a.90011.username }}"/>
                      <field name="domain" value="{{ c.domain }}"/>
                      <field name="ha1" value="{{ a.90011.ha1 }}"/>
                      <!-- here password due to ha1 is useless, so we can put some data based on jinja2_time.TimeExtension (https://github.com/hackebrot/jinja2-time) -->
                      <field name="password" value="{% now 'local' %}"/>
                  </table>
                  <table name="subscriber" type="insert"  cleanup_after_test="true">
                      <field name="username" value="{{ a.90012.username }}"/>
                      <field name="domain" value="{{ c.domain }}"/>
                      <field name="ha1" value="{{ a.90012.ha1 }}"/>
                      <field name="password" value="{% now 'local' + 'days=1', '%D' %}"/>
                  </table>
              </action>
              <!-- add endpoints and aors to Asterisk -->
              <action database="astdb" stage="pre">
                  <table name="ps_endpoints" type="insert"  cleanup_after_test="true">
                      <field name="id" value="{{ a.90011.label }}"/>
                      <field name="transport" value="transport-udp"/>
                      <field name="aors" value="{{ a.90011.label }}"/>
                      <field name="context" value="{{ c.asterisk_context }}"/>
                      <field name="disallow" value="all"/>
                      <field name="allow" value="!all,opus,alaw"/>
                      <field name="direct_media" value="no"/>
                      <field name="ice_support" value="no"/>
                      <field name="rtp_timeout" value="3600"/>
                  </table>
                  <table name="ps_aors" type="insert"  cleanup_after_test="true">
                      <field name="id" value="{{ a.90011.label }}"/>
                      <field name="contact" value="sip:{{ a.90011.label }}@{{ c.domain }}:5060"/>
                  </table>
                  <table name="ps_endpoints" type="insert"  cleanup_after_test="true">
                      <field name="id" value="{{ a.90012.label }}"/>
                      <field name="transport" value="transport-udp"/>
                      <field name="aors" value="{{ a.90012.label }}"/>
                      <field name="context" value="{{ c.asterisk_context }}"/>
                      <field name="disallow" value="all"/>
                      <field name="allow" value="!all,opus,alaw"/>
                      <field name="direct_media" value="no"/>
                      <field name="ice_support" value="no"/>
                      <field name="rtp_timeout" value="3600"/>
                  </table>
                  <table name="ps_aors" type="insert"  cleanup_after_test="true">
                      <field name="id" value="{{ a.90012.label }}"/>
                      <field name="contact" value="sip:{{ a.90012.label }}@{{ c.domain }}:5060"/>
                  </table>
              </action>
          </actions>
      </section>
      <section type="voip_patrol">
      <!-- Make a call from one endpoint to other -->
          <actions>
              <action type="codec" disable="all"/>
              <action type="codec" enable="pcma" priority="250"/>
              <action type="codec" enable="pcmu" priority="249"/>
              <action type="codec" enable="opus" priority="248"/>
              <action type="register" label="Register {{ a.90012.label }}"
                  transport="{{ a.90012.transport }}"
                  account="{{ a.90012.username }}"
                  username="{{ a.90012.label }}"
                  auth_username="{{ a.90012.username }}"
                  password="{{ a.90012.password }}"
                  registrar="{{ c.domain }}"
                  realm="{{ c.domain }}"
                  expected_cause_code="200"
                  srtp="{{ a.90012.srtp }}"
              />
              <action type="wait" complete="true" ms="2000"/>
              <action type="accept" label="Receive call on {{ a.90012.label }} from {{ a.90011.label }}"
                  call_count="1"
                  match_account="{{ a.90012.username }}"
                  hangup="10"
                  code="200" reason="OK"
                  transport="{{ a.90012.transport }}"
                  srtp="{{ a.90012.srtp }}"
                  play="{{ c.play_file }}">
                  <check-header name="From" regex="^.*sip:{{ a.90011.label }}@.*$"/>
              </action>
              <action type="call" label="Call {{ a.90011.label }} -> {{ a.90012.label }}"
                  transport="tls"
                  expected_cause_code="200"
                  caller="{{ a.90011.label }}@{{ c.domain }}"
                  callee="{{ a.90012.label }}@{{ c.domain }}"
                  from="sip:{{ a.90011.label }}@{{ c.domain }}"
                  to_uri="{{ a.90012.label }}@{{ c.domain }}"
                  max_duration="20" hangup="10"
                  auth_username="{{ a.90011.username }}"
                  password="{{ a.90011.password }}"
                  realm="{{ c.domain }}"
                  rtp_stats="true"
                  max_ring_duration="15"
                  srtp="{{ a.90011.srtp }}"
                  play="{{ c.play_file }}"
              />
              <action type="wait" complete="true" ms="30000"/>
          </actions>
      </section>
  </config>

run.sh script

Not that much to configure here, mostly you'll be interested in setting environement variables at the start of the script

Variable name Description
REPORT_TYPE Actually, report type, that would be provided at the end.
table - print results in table, only failed tests are pritend.
json - print results in JSON format, only failed tests are pritend.
table_full, json_full - prints results in table or JSON respectively, but print full info on tests passed
VP_LOG_LEVEL voip_patrol log level on the console

Results

As a results, you will have table like

+------------------------------------+-----------------------------------------------------------+--------+------------------+
|                           Scenario |                                                      Test | Status |             Text |
+------------------------------------+-----------------------------------------------------------+--------+------------------+
|                        01-register |                                                           |   PASS |  Scenario passed |
|                                    |                                      Register 88881-00001 |   PASS | Main test passed |
|                       02-call-echo |                                                           |   PASS |  Scenario passed |
|                                    |                                      Call to 11111 (echo) |   PASS | Main test passed |
|        03-register-wait-for-call-1 |                                                           |   PASS |  Scenario passed |
|                                    |                                      Register 88881-00001 |   PASS | Main test passed |
|                                    |                                Call to ##88881 from 88882 |   PASS | Main test passed |
|                                    |                                                   default |   PASS | Main test passed |
|        04-register-wait-for-call-2 |                                                           |   PASS |  Scenario passed |
|                                    |                                      Register 90002-00002 |   PASS | Main test passed |
|                                    |                                       Call 90001 -> 90002 |   PASS | Main test passed |
|                                    |                    Receive call on 90002-00002 from 90001 |   PASS | Main test passed |
|          05-immediate-call-forward |                                                           |   PASS |  Scenario passed |
|                                    |                                      Register 90002-00002 |   PASS | Main test passed |
|                                    |                           Call from 90001 to 91002->90002 |   PASS | Main test passed |
|                                    |                               Receive call on 90002-00002 |   PASS | Main test passed |
....
|    37-team-call-from-paused-member |                                                           |   PASS |  Scenario passed |
|                                    |                                      Register 90003-20614 |   PASS | Main test passed |
|                                    |                                      Register 90001-22466 |   PASS | Main test passed |
|                                    |                                      Register 90002-00002 |   PASS | Main test passed |
|                                    |                    Receive call on 90003-20614 and CANCEL |   PASS |    Call canceled |
|                                    |                      Call from team member 90001 -> 90543 |   PASS | Main test passed |
|                                    |                    Receive call on 90002-00002 and answer |   PASS | Main test passed |
+------------------------------------+-----------------------------------------------------------+--------+------------------+
        All scenarios are OK!

Not really much to describe here, just read info on the console