diff --git a/.github/workflows/ruby-ci.yml b/.github/workflows/ruby-ci.yml index da4aa91..d0efa83 100644 --- a/.github/workflows/ruby-ci.yml +++ b/.github/workflows/ruby-ci.yml @@ -8,13 +8,32 @@ on: - master jobs: + ruby-ci-lint: + name: Ruby CI - lint + runs-on: ubuntu-latest + + strategy: + matrix: + ruby-version: [3.1] + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + - name: Install dependencies + run: bundle install + - name: Lint + run: rake lint + ruby-ci: name: Ruby CI - test runs-on: ubuntu-latest strategy: matrix: - ruby-version: [3.0, 3.1, 3.2] + ruby-version: [3.0, 3.1, 3.2, 3.3, 3.4] steps: - uses: actions/checkout@v4 @@ -25,4 +44,4 @@ jobs: - name: Install dependencies run: bundle install - name: Test - run: rake + run: rake test diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..9203b3c --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,78 @@ +## +# AllCops (Global) +# +AllCops: + NewCops: enable + SuggestExtensions: false + Exclude: + - vendor/**/* + +## +# Layout +# +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Layout/HashAlignment: + EnforcedLastArgumentHashStyle: ignore_implicit + +Layout/SpaceBeforeBlockBraces: + EnforcedStyle: no_space + +## +# Metrics +# +Metrics/AbcSize: + Max: 20 + CountRepeatedAttributes: false + Exclude: + - test/**/* + AllowedMethods: + - request + - get_all + +Metrics/BlockLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + AllowedMethods: + - get_all + +Metrics/MethodLength: + Enabled: false + +Metrics/ParameterLists: + Max: 6 + CountKeywordArgs: false + +Metrics/PerceivedComplexity: + Enabled: false + +## +# Naming +# +Naming/AccessorMethodName: + Exclude: + - lib/duo_api/* + +## +# Style +# +Style/NumericLiterals: + Enabled: false + +Style/Documentation: + Exclude: + - test/**/* + +## +# Gemspec +# +Gemspec/DevelopmentDependencies: + EnforcedStyle: gemspec + +Gemspec/RequireMFA: + Enabled: false diff --git a/Gemfile b/Gemfile index fa75df1..7f4f5e9 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec diff --git a/README.md b/README.md index 5a4ffc1..a455aa6 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,22 @@ **Accounts** - https://www.duosecurity.com/docs/accountsapi -## Tested Against Ruby Versions: -* 3.0 +# Compatibility +While the gem should work for Ruby versions >= 2.5, tests and linting may only work properly on Ruby versions >= 3.0. + +Tests are only run on currently supported Ruby versions. + +### Tested Against Ruby Versions: * 3.1 * 3.2 +* 3.3 +* 3.4 -## TLS 1.2 and 1.3 Support +### TLS 1.2 and 1.3 Support -Duo_api_ruby uses the Ruby openssl extension for TLS operations. +duo_api_ruby uses the Ruby openssl extension for TLS operations. -All currently supported Ruby versions (2.7 and higher) support TLS 1.2 and 1.3. +All Ruby versions compatible with this gem (2.5 and higher) support TLS 1.2 and 1.3. # Installing @@ -45,27 +51,18 @@ gem 'duo_api', '~> 1.0' ``` # Using - -TODO + - Examples of doing things [the hard way](/examples/the_hard_way.md) + - Examples of doing things [the less hard way](/examples/the_less_hard_way.md) + - Examples of doing things [the simple way](/examples/the_simple_way.md) # Testing - +###### (Testing and Linting can be done simultaneously by running `rake` without specifying a task) ``` -$ rake -Loaded suite /usr/lib/ruby/vendor_ruby/rake/rake_test_loader -Started -........ - -Finished in 0.002024715 seconds. --------------------------------------------------------------------------------------------------------- -8 tests, 10 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications -100% passed --------------------------------------------------------------------------------------------------------- -3951.17 tests/s, 4938.97 assertions/s +rake test ``` # Linting - +###### (Testing and Linting can be done simultaneously by running `rake` without specifying a task) ``` -$ rubocop +rake lint ``` diff --git a/Rakefile b/Rakefile index 5814915..f8f58a5 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,14 @@ +# frozen_string_literal: true + require 'rake/testtask' +require 'rubocop/rake_task' + +desc('Run tests & linting') +task(default: %i[test lint]) -Rake::TestTask.new do |t| - t.libs << 'test' -end +desc('Run tests') +task(:test){ Rake::TestTask.new{ |t| t.libs << 'test' } } -desc 'Run tests' -task :default => :test +desc('Run linting') +task(lint: %i[rubocop]) +task(:rubocop){ RuboCop::RakeTask.new } diff --git a/ca_certs.pem b/ca_certs.pem index 8e282ab..13138a8 100644 --- a/ca_certs.pem +++ b/ca_certs.pem @@ -1,52 +1,167 @@ -subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Assured ID Root CA +# Source URL: https://www.amazontrust.com/repository/AmazonRootCA1.cer +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=Amazon Root CA 1,O=Amazon,C=US +# Issuer: CN=Amazon Root CA 1,O=Amazon,C=US +# Expiration Date: 2038-01-17 00:00:00 +# Serial Number: 66C9FCF99BF8C0A39E2F0788A43E696365BCA +# SHA256 Fingerprint: 8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e + -----BEGIN CERTIFICATE----- -MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBl -MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 -d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJv -b3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzExMTEwMDAwMDAwWjBlMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl -cnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7c -JpSIqvTO9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYP -mDI2dsze3Tyoou9q+yHyUmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+ -wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4 -VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpyoeb6pNnVFzF1roV9Iq4/ -AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whfGHdPAgMB -AAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW -BBRF66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYun -pyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRC -dWKuh+vy1dneVrOfzM4UKLkNl2BcEkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTf -fwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38FnSbNd67IJKusm7Xi+fT8r87cm -NW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i8b5QZ7dsvfPx -H2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe -+o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA +A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI +U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs +N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv +o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU +5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy +rqXRfboQnoZsG4q5WTP468SQvvG5 -----END CERTIFICATE----- -subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert Global Root CA + +# Source URL: https://www.amazontrust.com/repository/AmazonRootCA2.cer +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=Amazon Root CA 2,O=Amazon,C=US +# Issuer: CN=Amazon Root CA 2,O=Amazon,C=US +# Expiration Date: 2040-05-26 00:00:00 +# Serial Number: 66C9FD29635869F0A0FE58678F85B26BB8A37 +# SHA256 Fingerprint: 1ba5b2aa8c65401a82960118f80bec4f62304d83cec4713a19c39c011ea46db4 + -----BEGIN CERTIFICATE----- -MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh -MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 -d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD -QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT -MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j -b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB -CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 -nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt -43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P -T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 -gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO -BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR -TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw -DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr -hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg -06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF -PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls -YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk -CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK +gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ +W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg +1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K +8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r +2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me +z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR +8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj +mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz +7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6 ++XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI +0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm +UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 +LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY ++gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS +k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl +7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm +btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl +urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+ +fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63 +n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE +76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H +9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT +4PsJYGw= +-----END CERTIFICATE----- + + +# Source URL: https://www.amazontrust.com/repository/AmazonRootCA3.cer +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=Amazon Root CA 3,O=Amazon,C=US +# Issuer: CN=Amazon Root CA 3,O=Amazon,C=US +# Expiration Date: 2040-05-26 00:00:00 +# Serial Number: 66C9FD5749736663F3B0B9AD9E89E7603F24A +# SHA256 Fingerprint: 18ce6cfe7bf14e60b2e347b8dfe868cb31d02ebb3ada271569f50343b46db3a4 + +-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g +Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG +A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg +Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl +ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j +QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr +ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr +BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM +YyRIHN8wfdVoOw== -----END CERTIFICATE----- -subject= /C=US/O=DigiCert Inc/OU=www.digicert.com/CN=DigiCert High Assurance EV Root CA + +# Source URL: https://www.amazontrust.com/repository/AmazonRootCA4.cer +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=Amazon Root CA 4,O=Amazon,C=US +# Issuer: CN=Amazon Root CA 4,O=Amazon,C=US +# Expiration Date: 2040-05-26 00:00:00 +# Serial Number: 66C9FD7C1BB104C2943E5717B7B2CC81AC10E +# SHA256 Fingerprint: e35d28419ed02025cfa69038cd623962458da5c695fbdea3c22b0bfb25897092 + +-----BEGIN CERTIFICATE----- +MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5 +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g +Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG +A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg +Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi +9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk +M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB +MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw +CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW +1KyLa2tJElMzrdfkviT8tQp21KW8EA== +-----END CERTIFICATE----- + + +# Source URL: https://www.amazontrust.com/repository/SFSRootCAG2.cer +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=Starfield Services Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US +# Issuer: CN=Starfield Services Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US +# Expiration Date: 2037-12-31 23:59:59 +# Serial Number: 0 +# SHA256 Fingerprint: 568d6905a2c88708a4b3025190edcfedb1974a606a13c6e5290fcb2ae63edab5 + +-----BEGIN CERTIFICATE----- +MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT +HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs +ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 +MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD +VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy +ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy +dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p +OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2 +8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K +Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe +hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk +6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw +DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q +AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI +bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB +ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z +qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd +iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn +0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN +sSi6 +-----END CERTIFICATE----- + + +# Source URL: https://cacerts.digicert.com/DigiCertHighAssuranceEVRootCA.crt +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=DigiCert High Assurance EV Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US +# Issuer: CN=DigiCert High Assurance EV Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US +# Expiration Date: 2031-11-10 00:00:00 +# Serial Number: 2AC5C266A0B409B8F0B79F2AE462577 +# SHA256 Fingerprint: 7431e5f4c3c1ce4690774f0b61e05440883ba9a01ed00ba6abd7806ed3b118cf + -----BEGIN CERTIFICATE----- MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 @@ -71,50 +186,303 @@ vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep +OkuE6N36B9K -----END CERTIFICATE----- -subject= /C=US/O=SecureTrust Corporation/CN=SecureTrust CA + +# Source URL: https://cacerts.digicert.com/DigiCertTLSECCP384RootG5.crt +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=DigiCert TLS ECC P384 Root G5,O=DigiCert\, Inc.,C=US +# Issuer: CN=DigiCert TLS ECC P384 Root G5,O=DigiCert\, Inc.,C=US +# Expiration Date: 2046-01-14 23:59:59 +# Serial Number: 9E09365ACF7D9C8B93E1C0B042A2EF3 +# SHA256 Fingerprint: 018e13f0772532cf809bd1b17281867283fc48c6e13be9c69812854a490c1b05 + -----BEGIN CERTIFICATE----- -MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBI -MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x -FzAVBgNVBAMTDlNlY3VyZVRydXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIz -MTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAeBgNVBAoTF1NlY3VyZVRydXN0IENv -cnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCCASIwDQYJKoZIhvcN -AQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQXOZEz -Zum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO -0gMdA+9tDWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIao -wW8xQmxSPmjL8xk037uHGFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj -7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b01k/unK8RCSc43Oz969XL0Imnal0ugBS -8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmHursCAwEAAaOBnTCBmjAT -BgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB -/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCeg -JYYjaHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGC -NxUBBAMCAQAwDQYJKoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt3 -6Z3q059c4EVlew3KW+JwULKUBRSuSceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/ -3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHfmbx8IVQr5Fiiu1cprp6poxkm -D5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZnMUFdAvnZyPS -CPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR -3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= +MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQsw +CQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURp +Z2lDZXJ0IFRMUyBFQ0MgUDM4NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2 +MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ +bmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQgUm9vdCBHNTB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1TzvdlHJS +7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp +0zVozptjn4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICIS +B4CIfBFqMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 +BAMDA2gAMGUCMQCJao1H5+z8blUD2WdsJk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQ +LgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIxAJSdYsiJvRmEFOml+wG4 +DXZDjC5Ty3zfDBeWUA== -----END CERTIFICATE----- -subject= /C=US/O=SecureTrust Corporation/CN=Secure Global CA + +# Source URL: https://cacerts.digicert.com/DigiCertTLSRSA4096RootG5.crt +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=DigiCert TLS RSA4096 Root G5,O=DigiCert\, Inc.,C=US +# Issuer: CN=DigiCert TLS RSA4096 Root G5,O=DigiCert\, Inc.,C=US +# Expiration Date: 2046-01-14 23:59:59 +# Serial Number: 8F9B478A8FA7EDA6A333789DE7CCF8A +# SHA256 Fingerprint: 371a00dc0533b3721a7eeb40e8419e70799d2b0a0f2c1d80693165f7cec4ad75 + +-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBN +MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMT +HERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcN +NDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs +IEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2IFJvb3QgRzUwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS87IE+ +ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG0 +2C+JFvuUAT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgp +wgscONyfMXdcvyej/Cestyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZM +pG2T6T867jp8nVid9E6P/DsjyG244gXazOvswzH016cpVIDPRFtMbzCe88zdH5RD +nU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnVDdXifBBiqmvwPXbzP6Po +sMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9qTXeXAaDx +Zre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cd +Lvvyz6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvX +KyY//SovcfXWJL5/MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNe +XoVPzthwiHvOAbWWl9fNff2C+MIkwcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPL +tgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4EFgQUUTMc7TZArxfTJc1paPKv +TiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcN +AQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw +GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7H +PNtQOa27PShNlnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLF +O4uJ+DQtpBflF+aZfTCIITfNMBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQ +REtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/u4cnYiWB39yhL/btp/96j1EuMPik +AdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9GOUrYU9DzLjtxpdRv +/PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh47a+ +p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilw +MUc/dNAUFvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WF +qUITVuwhd4GTWgzqltlJyqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCK +ovfepEWFJqgejF0pW8hL2JpqA15w8oVPbEtoL8pU9ozaMv7Da4M/OMZ+ +-----END CERTIFICATE----- + + +# Source URL: https://secure.globalsign.com/cacert/rootr46.crt +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=GlobalSign Root R46,O=GlobalSign nv-sa,C=BE +# Issuer: CN=GlobalSign Root R46,O=GlobalSign nv-sa,C=BE +# Expiration Date: 2046-03-20 00:00:00 +# Serial Number: 11D2BBB9D723189E405F0A9D2DD0DF2567D1 +# SHA256 Fingerprint: 4fa3126d8d3a11d1c4855a4f807cbad6cf919d3a5a88b03bea2c6372d93c40c9 + +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUA +MEYxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYD +VQQDExNHbG9iYWxTaWduIFJvb3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMy +MDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYt +c2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08EsCVeJ +OaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQG +vGIFAha/r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud +316HCkD7rRlr+/fKYIje2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo +0q3v84RLHIf8E6M6cqJaESvWJ3En7YEtbWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSE +y132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvjK8Cd+RTyG/FWaha/LIWF +zXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD412lPFzYE ++cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCN +I/onccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzs +x2sZy/N78CsHpdlseVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqa +ByFrgY/bxFn63iLABJzjqls2k+g9vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC +4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEMBQADggIBAHx4 +7PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg +JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti +2kM3S+LGteWygxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIk +pnnpHs6i58FZFZ8d4kuaPp92CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRF +FRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZmOUdkLG5NrmJ7v2B0GbhWrJKsFjLt +rWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qqJZ4d16GLuc1CLgSk +ZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwyeqiv5 +u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP +4vkYxboznxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6 +N3ec592kD3ZDZopD8p/7DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3 +vouXsXgxT7PntgMTzlSdriVZzH81Xwj3QEUxeCp6 +-----END CERTIFICATE----- + + +# Source URL: https://secure.globalsign.com/cacert/roote46.crt +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=GlobalSign Root E46,O=GlobalSign nv-sa,C=BE +# Issuer: CN=GlobalSign Root E46,O=GlobalSign nv-sa,C=BE +# Expiration Date: 2046-03-20 00:00:00 +# Serial Number: 11D2BBBA336ED4BCE62468C50D841D98E843 +# SHA256 Fingerprint: cbb9c44d84b8043e1050ea31a69f514955d7bfd2e2c6b49301019ad61d9f5058 + +-----BEGIN CERTIFICATE----- +MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYx +CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQD +ExNHbG9iYWxTaWduIFJvb3QgRTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAw +MDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex +HDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkBjtjq +R+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGdd +yXqBPCCjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ +7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZkvLtoURMMA/cVi4RguYv/Uo7njLwcAjA8 ++RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= +-----END CERTIFICATE----- + + +# Source URL: https://i.pki.goog/r2.crt +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=GTS Root R2,O=Google Trust Services LLC,C=US +# Issuer: CN=GTS Root R2,O=Google Trust Services LLC,C=US +# Expiration Date: 2036-06-22 00:00:00 +# Serial Number: 203E5AEC58D04251AAB1125AA +# SHA256 Fingerprint: 8d25cd97229dbf70356bda4eb3cc734031e24cf00fafcfd32dc76eb5841c7ea8 + +-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw +MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp +Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt +nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY +6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu +MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k +RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg +f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV ++3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo +dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW +Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa +G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq +gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID +AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H +vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8 +0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC +B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u +NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg +yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev +HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6 +xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR +TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg +JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV +7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl +6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL +-----END CERTIFICATE----- + + +# Source URL: https://i.pki.goog/r4.crt +# Certificate #1 Details: +# Original Format: DER +# Subject: CN=GTS Root R4,O=Google Trust Services LLC,C=US +# Issuer: CN=GTS Root R4,O=Google Trust Services LLC,C=US +# Expiration Date: 2036-06-22 00:00:00 +# Serial Number: 203E5C068EF631A9C72905052 +# SHA256 Fingerprint: 349dfa4058c5e263123b398ae795573c4e1313c83fe68f93556cd5e8031b3c7d + +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi +QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR +HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D +9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 +p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD +-----END CERTIFICATE----- + + +# Source URL: https://www.identrust.com/file-download/download/public/5718 +# Certificate #1 Details: +# Original Format: PKCS7-DER +# Subject: CN=IdenTrust Commercial Root CA 1,O=IdenTrust,C=US +# Issuer: CN=IdenTrust Commercial Root CA 1,O=IdenTrust,C=US +# Expiration Date: 2034-01-16 18:12:23 +# Serial Number: A0142800000014523C844B500000002 +# SHA256 Fingerprint: 5d56499be4d2e08bcfcad08a3e38723d50503bde706948e42f55603019e528ae + +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBK +MQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVu +VHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQw +MTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MScw +JQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENBIDEwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ldhNlT +3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU ++ehcCuz/mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gp +S0l4PJNgiCL8mdo2yMKi1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1 +bVoE/c40yiTcdCMbXTMTEl3EASX2MN0CXZ/g1Ue9tOsbobtJSdifWwLziuQkkORi +T0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl3ZBWzvurpWCdxJ35UrCL +vYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzyNeVJSQjK +Vsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZK +dHzVWYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHT +c+XvvqDtMwt0viAgxGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hv +l7yTmvmcEpB4eoCHFddydJxVdHixuuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5N +iGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZIhvcNAQELBQAD +ggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH +6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwt +LRvM7Kqas6pgghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93 +nAbowacYXVKV7cndJZ5t+qntozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3 ++wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmVYjzlVYA211QC//G5Xc7UI2/YRYRK +W2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUXfeu+h1sXIFRRk0pT +AwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/rokTLq +l1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG +4iZZRHUe2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZ +mUlO+KWA2yUPHGNiiskzZ2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A +7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7RcGzM7vRX+Bi6hG6H +-----END CERTIFICATE----- + + +# Source URL: https://www.identrust.com/file-download/download/public/5842 +# Certificate #1 Details: +# Original Format: PKCS7-PEM +# Subject: CN=IdenTrust Commercial Root TLS ECC CA 2,O=IdenTrust,C=US +# Issuer: CN=IdenTrust Commercial Root TLS ECC CA 2,O=IdenTrust,C=US +# Expiration Date: 2039-04-11 21:11:10 +# Serial Number: 40018ECF000DE911D7447B73E4C1F82E +# SHA256 Fingerprint: 983d826ba9c87f653ff9e8384c5413e1d59acf19ddc9c98cecae5fdea2ac229c + +-----BEGIN CERTIFICATE----- +MIICbDCCAc2gAwIBAgIQQAGOzwAN6RHXRHtz5MH4LjAKBggqhkjOPQQDBDBSMQsw +CQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MS8wLQYDVQQDEyZJZGVuVHJ1 +c3QgQ29tbWVyY2lhbCBSb290IFRMUyBFQ0MgQ0EgMjAeFw0yNDA0MTEyMTExMTFa +Fw0zOTA0MTEyMTExMTBaMFIxCzAJBgNVBAYTAlVTMRIwEAYDVQQKEwlJZGVuVHJ1 +c3QxLzAtBgNVBAMTJklkZW5UcnVzdCBDb21tZXJjaWFsIFJvb3QgVExTIEVDQyBD +QSAyMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBwomiZTgLg8KqEImMmnO5rNPb +Oo9sv5w4nJh45CXs9Gcu8YET9ulxsyVBCVSfSYeppdtXFEWYyBi0QRCAlp5YZHQB +H675v5rWVKRXvhzsuUNi9Xw0Zy1bAXaikmsrY/J0L52j2RulW4q4WvE7f23VFwZu +d82J8k0YG+M4MpmdOho1rsKjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgGGMB0GA1UdDgQWBBQhNGgGrnXhVx/FuQqjXpuH+IlbwzAKBggqhkjOPQQD +BAOBjAAwgYgCQgDc9F4WOxAgci2uQWfsX9cjeIvDXaaeVjDz31Ycc+ZdPrK1JKrB +f6CuTwWy8VojtGxdM3PJMkJC4LGPuhcvkHLo4gJCAV5h+PXe4bDJ3QxE8hkGFoUW +Ak6KtMCIpbLyt5pHrROi+YW9MpScoNGJkg96G1ETvJTWz6dv0uQYjKXt3jlOfQ7g +-----END CERTIFICATE----- + + +# Source URL: https://ssl-ccp.secureserver.net/repository/sfroot-g2.crt +# Certificate #1 Details: +# Original Format: PEM +# Subject: CN=Starfield Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US +# Issuer: CN=Starfield Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US +# Expiration Date: 2037-12-31 23:59:59 +# Serial Number: 0 +# SHA256 Fingerprint: 2ce1cb0bf9d2f9e102993fbe215152c3b2dd0cabde1c68e5319b839154dbb7f5 + -----BEGIN CERTIFICATE----- -MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBK -MQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24x -GTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkx -MjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3Qg -Q29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwgQ0EwggEiMA0GCSqG -SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jxYDiJ -iQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa -/FHtaMbQbqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJ -jnIFHovdRIWCQtBJwB1g8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnI -HmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYVHDGA76oYa8J719rO+TMg1fW9ajMtgQT7 -sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi0XPnj3pDAgMBAAGjgZ0w -gZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQF -MAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCsw -KaAnoCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsG -AQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0L -URYD7xh8yOOvaliTFGCRsoTciE6+OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXO -H0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cnCDpOGR86p1hcF895P4vkp9Mm -I50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/53CYNv6ZHdAbY -iNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc -f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW +MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx +EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT +HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs +ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw +MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 +b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj +aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp +Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg +nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1 +HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N +Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN +dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0 +HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G +CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU +sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3 +4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg +8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K +pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 +mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 -----END CERTIFICATE----- diff --git a/duo_api.gemspec b/duo_api.gemspec index 0df060b..71d9365 100644 --- a/duo_api.gemspec +++ b/duo_api.gemspec @@ -1,6 +1,8 @@ +# frozen_string_literal: true + Gem::Specification.new do |s| s.name = 'duo_api' - s.version = '1.4.0' + s.version = '1.5.0' s.summary = 'Duo API Ruby' s.description = 'A Ruby implementation of the Duo API.' s.email = 'support@duo.com' @@ -8,12 +10,20 @@ Gem::Specification.new do |s| s.license = 'BSD-3-Clause' s.authors = ['Duo Security'] s.files = [ + 'ca_certs.pem', 'lib/duo_api.rb', - 'ca_certs.pem' + 'lib/duo_api/api_client.rb', + 'lib/duo_api/api_helpers.rb', + 'lib/duo_api/accounts.rb', + 'lib/duo_api/admin.rb', + 'lib/duo_api/auth.rb', + 'lib/duo_api/device.rb' ] - s.add_development_dependency 'rake', '~> 12.0' - s.add_development_dependency 'rubocop', '~> 0.49.0' - s.add_development_dependency 'test-unit', '~> 3.2' - s.add_development_dependency 'mocha', '~> 1.8.0' - s.add_development_dependency 'ostruct', '~> 0.1.0' + s.required_ruby_version = '>= 2.5' + s.add_dependency 'base64', '~> 0.2.0' + s.add_development_dependency 'mocha', '~> 2.7.1' + s.add_development_dependency 'ostruct', '~> 0.6.1' + s.add_development_dependency 'rake', '~> 13.2.1' + s.add_development_dependency 'rubocop', '~> 1.73.1' + s.add_development_dependency 'test-unit', '~> 3.6.7' end diff --git a/examples.rb b/examples.rb deleted file mode 100644 index aff87e8..0000000 --- a/examples.rb +++ /dev/null @@ -1,39 +0,0 @@ -require_relative 'duo_api' - -IKEY = ARGV[0] -SKEY = ARGV[1] -HOST = ARGV[2] -unless HOST - abort "Usage: #{$0} IKEY SKEY HOST" -end - -# Initialize the api -client = DuoApi.new IKEY, SKEY, HOST - -# EXAMPLE 1: Get the first 100 users -resp = client.request 'GET', '/admin/v1/users', {limit: '100', offset:'0'} - -# print out some info from the response -puts resp.code -puts resp.header.to_hash -puts resp.message -puts resp.http_version -puts resp.body - -# EXAMPLE 2: retreive the user 'john' -resp2 = client.request 'GET', '/admin/v1/users', {username: 'john'} -puts resp2.body - -# EXAMPLE 3: create a new user -resp3 = client.request 'POST', '/admin/v1/users', {username: 'john2'} -puts resp3.body - -# EXAMPLE 4: delete user with user_id: 'DUAE0W526W52YHOBMDO6' -resp4 = client.request 'DELETE', '/admin/v1/users/DUAE0W526W52YHOBMDO6' -puts resp4.body - -# EXAMPLE 5: Authlog V2. Pagination with next_offset. -resp5 = client.request 'GET', '/admin/v2/logs/authentication', {'limit': '1', 'mintime': '1546371049194', 'maxtime': '1548963049000'} -puts resp5.body -resp6 = client.request 'GET', '/admin/v2/logs/authentication', {'limit': '1', 'mintime': '1546371049194', 'maxtime': '1548963049000', 'next_offset': result["response"]['metadata']['next_offset']} -puts resp6.body diff --git a/examples/the_hard_way.md b/examples/the_hard_way.md new file mode 100644 index 0000000..a346d3b --- /dev/null +++ b/examples/the_hard_way.md @@ -0,0 +1,43 @@ +# Doing Things The Hard Way + +### Making requests using `request()` +###### - This method returns a raw `Net::HTTPResponse` object, which gives you more control at the expense of simplicity +``` +require 'duo_api' + +# Initialize the api +client = DuoApi.new(IKEY, SKEY, HOST) + +# EXAMPLE 1: Get the first 100 users +resp = client.request('GET', '/admin/v1/users', { limit: '100', offset: '0' }) +# print out some info from the response +puts resp.code # Response status code +puts resp.to_hash # Response headers hash +puts resp.message # Response message +puts resp.http_version # Response HTTP version +puts resp.body # Response body + +# EXAMPLE 2: retreive the user 'john' +resp2 = client.request('GET', '/admin/v1/users', { username: 'john' }) +puts resp2.body + +# EXAMPLE 3: create a new user +resp3 = client.request('POST', '/admin/v1/users', { username: 'john2' }) +puts resp3.body + +# EXAMPLE 4: delete user with user_id: 'DUAE0W526W52YHOBMDO6' +resp4 = client.request('DELETE', '/admin/v1/users/DUAE0W526W52YHOBMDO6') +puts resp4.body + +# EXAMPLE 5: Authlog V2. Pagination with next_offset. +resp5 = client.request( + 'GET', '/admin/v2/logs/authentication', + { limit: '1', mintime: '1546371049194', maxtime: '1548963049000' }) +puts resp5.body +resp5_body = JSON.parse(resp5.body, symbolize_names: true) +resp6 = client.request( + 'GET', '/admin/v2/logs/authentication', + { limit: '1', mintime: '1546371049194', maxtime: '1548963049000', + next_offset: resp5_body[:response][:metadata][:next_offset].join(',') }) +puts resp6.body +``` \ No newline at end of file diff --git a/examples/the_less_hard_way.md b/examples/the_less_hard_way.md new file mode 100644 index 0000000..d000266 --- /dev/null +++ b/examples/the_less_hard_way.md @@ -0,0 +1,45 @@ +# Doing Things The Less Hard Way + +### Making requests using `get()`, `post()`, `put()`, and `delete()` +###### - These methods return a Hash (with symbol keys) of the parsed JSON response body +``` +require 'duo_api' + +# Initialize the api +client = DuoApi.new(IKEY, SKEY, HOST) + +# EXAMPLE 1: Get single user by username +user = client.get('/admin/v1/users', { username: 'john' })[:response] + +# EXAMPLE 2: Create new user +new_user = client.post('/admin/v1/users', { username: 'john2' })[:response] + +TODO: MORE EXAMPLES HERE +``` + +### Making requests using `get_all()` +###### - This method handles paginated responses automatically and returns a Hash (with symbol keys) of the combined parsed JSON response bodies +``` +require 'duo_api' + +# Initialize the api +client = DuoApi.new(IKEY, SKEY, HOST) + +# EXAMPLE 1: Get all users +users = client.get_all('/admin/v1/users')[:response] + +TODO: MORE EXAMPLES HERE +``` + +### Making requests using `get_image()` +###### - This method expects an image content-type and returns the raw response body +``` +require 'duo_api' + +# Initialize the api +client = DuoApi.new(IKEY, SKEY, HOST) + +# EXAMPLE 1: Download logo from Admin API and write to disk +image_data = client.get_image('/admin/v1/logo') +File.write('logo.png', image_data) +``` \ No newline at end of file diff --git a/examples/the_simple_way.md b/examples/the_simple_way.md new file mode 100644 index 0000000..ff411d3 --- /dev/null +++ b/examples/the_simple_way.md @@ -0,0 +1,103 @@ +# Doing Things The Simple Way + +### Making Admin API requests using `DuoApi::Admin` +###### - These methods return only the Array, Hash, or String response data from the parsed JSON response body (except for get_logo(), which returns raw image data) +``` +require 'duo_api' + +# Initialize the api +admin_api = DuoApi::Admin.new(IKEY, SKEY, HOST) + +# EXAMPLE 1: Get all users +users = admin_api.get_users() + +# EXAMPLE 2: Get user matching specific username +user = admin_api.get_users(username: 'john') + +# EXAMPLE 3: Get users matching specific usernames +users = admin_api.get_users(username_list: ['jane', 'john']) + +# EXAMPLE 4: Create new user +admin_api.create_user(username: 'john2') + +# EXAMPLE 5: Create new user with optional parameters +### 'aliases' can be an Array (['alias1value', 'alias2value']), +### a Hash ({alias1: 'alias1value', alias2: 'alias2value'}), or +### a String ('alias1=alias1value&alias2=alias2value') +admin_api.create_user( + username: 'john3', + realname: 'Johnny Johnson III', + firstname: 'Johnny', + lastname: 'Johnson', + email: 'john3@example.com', + aliases: [ 'johnny3' ] +) + +# EXAMPLE 6: Same thing, but you want to pass a Hash +new_user = { + username: 'john3', + realname: 'Johnny Johnson III', + firstname: 'Johnny', + lastname: 'Johnson', + email: 'john3@example.com', + aliases: { alias1: 'johnny3' } +} +admin_api.create_user(**new_user) + +TODO: MORE EXAMPLES HERE +``` + +### Making Accounts API requests using `DuoApi::Accounts` +###### - These methods return only the Array, Hash, or String response data from the parsed JSON response body +``` +require 'duo_api' + +# Initialize the api +accounts_api = DuoApi::Accounts.new(IKEY, SKEY, HOST) + +# EXAMPLE 1: List child accounts +child_accounts = accounts_api.get_child_accounts() + +# EXAMPLE 2: Use Accounts API to make Admin API calls on child account +account_id = 'DAFAKECHILDACCOUNTID' +child_account_admin_api = accounts_api.admin_api(child_account_id: account_id) +child_account_users = child_account_admin_api.get_users() +child_account_integrations = child_account_admin_api.get_integrations() + +TODO: MORE EXAMPLES HERE +``` + +### Making Auth API requests using `DuoApi::Auth` +###### - These methods return only the Array, Hash, or String response data from the parsed JSON response body (except for logo(), which returns raw image data) +``` +require 'duo_api' + +# Initialize the api +auth_api = DuoApi::Auth.new(IKEY, SKEY, HOST) + +# EXAMPLE 1: Start a synchronous auth, waiting for and returning auth result +auth_result = auth_api.auth(factor: 'auto', username: 'john') + +# EXAMPLE 2: Start an asynchronous auth, then request auth result +auth_txid = auth_api.auth(factor: 'auto', username: 'john', async: '1')[:txid] +auth_result = auth_api.auth_status(txid: auth_txid) + +TODO: MORE EXAMPLES HERE +``` + +### Making Device API requests using `DuoApi::Device` +###### - These methods return only the Array, Hash, or String response data from the parsed JSON response body +``` +require 'duo_api' + +# Initialize the api +device_api = DuoApi::Device.new(IKEY, SKEY, HOST, mkey: MKEY) + +# EXAMPLE 1: Get list of all existing active device caches +caches = device_api.get_device_caches(status: 'active') + +# EXAMPLE 2: Get all devices for a specific device cache +cache_devices = device_api.get_device_cache_devices(cache_key: 'CACHEKEY') + +TODO: MORE EXAMPLES HERE +``` \ No newline at end of file diff --git a/lib/duo_api.rb b/lib/duo_api.rb index 9668d54..8906032 100644 --- a/lib/duo_api.rb +++ b/lib/duo_api.rb @@ -1,165 +1,9 @@ -require 'erb' -require 'json' -require 'openssl' -require 'net/https' -require 'time' -require 'uri' +# frozen_string_literal: true -## -# A Ruby implementation of the Duo API -# -class DuoApi - attr_accessor :ca_file +require_relative 'duo_api/api_client' +require_relative 'duo_api/api_helpers' - if Gem.loaded_specs['duo_api'] - VERSION = Gem.loaded_specs['duo_api'].version - else - VERSION = '0.0.0' - end - - # Constants for handling rate limit backoff - MAX_BACKOFF_WAIT_SECS = 32 - INITIAL_BACKOFF_WAIT_SECS = 1 - BACKOFF_FACTOR = 2 - RATE_LIMITED_RESP_CODE = '429' - - def initialize(ikey, skey, host, proxy = nil, ca_file: nil) - @ikey = ikey - @skey = skey - @host = host - if proxy.nil? - @proxy = [] - else - proxy_uri = URI.parse proxy - @proxy = [ - proxy_uri.host, - proxy_uri.port, - proxy_uri.user, - proxy_uri.password - ] - end - @ca_file = ca_file || - File.join(File.dirname(__FILE__), '..', 'ca_certs.pem') - end - - def request(method, path, params = {}, additional_headers = nil) - params_go_in_body = %w[POST PUT PATCH].include?(method) - if params_go_in_body - body = canon_json(params) - params = {} - else - body = '' - end - - uri = request_uri(path, params) - current_date, signed = sign(method, uri.host, path, params, body, additional_headers) - - request = Net::HTTP.const_get(method.capitalize).new uri.to_s - request.basic_auth(@ikey, signed) - request['Date'] = current_date - request['User-Agent'] = "duo_api_ruby/#{VERSION}" - if params_go_in_body - request['Content-Type'] = 'application/json' - request.body = body - end - - Net::HTTP.start( - uri.host, uri.port, *@proxy, - use_ssl: true, ca_file: @ca_file, - verify_mode: OpenSSL::SSL::VERIFY_PEER - ) do |http| - wait_secs = INITIAL_BACKOFF_WAIT_SECS - while true do - resp = http.request(request) - if resp.code != RATE_LIMITED_RESP_CODE or wait_secs > MAX_BACKOFF_WAIT_SECS - return resp - end - random_offset = rand() - sleep(wait_secs + random_offset) - wait_secs *= BACKOFF_FACTOR - end - end - end - - private - - def encode_key_val(k, v) - # encode the key and the value for a url - key = ERB::Util.url_encode(k.to_s) - value = ERB::Util.url_encode(v.to_s) - key + '=' + value - end - - def canon_params(params_hash = nil) - return '' if params_hash.nil? - params_hash.sort.map do |k, v| - # when it is an array, we want to add that as another param - # eg. next_offset = ['1547486297000', '5bea1c1e-612c-4f1d-b310-75fd31385b15'] - if v.is_a?(Array) - encode_key_val(k, v[0]) + '&' + encode_key_val(k, v[1]) - else - encode_key_val(k, v) - end - end.join('&') - end - - def canon_json(params_hash = nil) - return '' if params_hash.nil? - JSON.generate(Hash[params_hash.sort]) - end - - def canon_x_duo_headers(additional_headers) - additional_headers ||= {} - - if not additional_headers.select{|k,v| k.nil? or v.nil?}.empty? - raise 'Not allowed "nil" as a header name or value' - end - - canon_list = [] - added_headers = [] - additional_headers.keys.sort.each do |header_name| - header_name_lowered = header_name.downcase - header_value = additional_headers[header_name] - validate_additional_header(header_name_lowered, header_value, added_headers) - canon_list.append(header_name_lowered, header_value) - added_headers.append(header_name_lowered) - end - - canon = canon_list.join("\x00") - OpenSSL::Digest::SHA512.hexdigest(canon) - end - - def validate_additional_header(header_name, value, added_headers) - raise 'Not allowed "Null" character in header name' if header_name.include?("\x00") - raise 'Not allowed "Null" character in header value' if value.include?("\x00") - raise 'Additional headers must start with \'X-Duo-\'' unless header_name.downcase.start_with?('x-duo-') - raise "Duplicate header passed, header=#{header_name}" if added_headers.include?(header_name.downcase) - end - - def request_uri(path, params = nil) - u = 'https://' + @host + path - u += '?' + canon_params(params) unless params.nil? - URI.parse(u) - end - - def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {}) - # options[:date] being passed manually is specifically for tests - date = options[:date] || Time.now.rfc2822() - canon = [ - date, - method.upcase, - host.downcase, - path, - canon_params(params), - OpenSSL::Digest::SHA512.hexdigest(body), - canon_x_duo_headers(additional_headers) - ] - [date, canon.join("\n")] - end - - def sign(method, host, path, params, body = '', additional_headers = nil, options: {}) - # options[:date] being passed manually is specifically for tests - date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options) - [date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)] - end -end +require_relative 'duo_api/admin' +require_relative 'duo_api/accounts' +require_relative 'duo_api/auth' +require_relative 'duo_api/device' diff --git a/lib/duo_api/accounts.rb b/lib/duo_api/accounts.rb new file mode 100644 index 0000000..b9bae18 --- /dev/null +++ b/lib/duo_api/accounts.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative 'api_client' +require_relative 'api_helpers' + +require_relative 'admin' + +class DuoApi + ## + # Duo Accounts API (https://duo.com/docs/accountsapi) + # + class Accounts < DuoApi + ## + # Accounts API + # + def get_child_accounts + post('/accounts/v1/account/list')[:response] + end + + def create_child_account(name:) + params = { name: name } + post('/accounts/v1/account/create', params)[:response] + end + + def delete_child_account(account_id:) + params = { account_id: account_id } + post('/accounts/v1/account/delete', params)[:response] + end + + ## + # Child Account Admin API Wrapper + # + def admin_api(child_account_id:) + child_account = get_child_accounts.select{ |a| a[:account_id] == child_account_id }.first + raise(ChildAccountError, "Child account #{child_account_id} not found") unless child_account + + client = DuoApi::Admin.new(@ikey, @skey, child_account[:api_hostname], @proxy_str, + ca_file: @ca_file, default_params: { account_id: child_account_id }) + + # Additional Child Account Admin API Methods + # + # Note: + # - These are enabled by support request only + # - They can only be called by the DuoApi::Admin instance returned by this wrapper method + # - account_id is required for each of these, but it is provided by the client default_params + # + client.instance_eval do + def get_edition + get('/admin/v1/billing/edition')[:response] + end + + def set_edition(edition:) + params = { edition: edition } + post('/admin/v1/billing/edition', params)[:response] + end + + def get_telephony_credits + get('/admin/v1/billing/telephony_credits')[:response] + end + + def set_telephony_credits(credits:) + params = { credits: credits } + post('/admin/v1/billing/telephony_credits', params)[:response] + end + end + + client + end + end +end diff --git a/lib/duo_api/admin.rb b/lib/duo_api/admin.rb new file mode 100644 index 0000000..8a0648d --- /dev/null +++ b/lib/duo_api/admin.rb @@ -0,0 +1,832 @@ +# frozen_string_literal: true + +require_relative 'api_client' +require_relative 'api_helpers' + +class DuoApi + ## + # Duo Admin API (https://duo.com/docs/adminapi) + # + class Admin < DuoApi + ## + # Users + # + def get_users(**optional_params) + # optional_params: username, email, user_id_list, username_list + optional_params.tap do |p| + p[:user_id_list] = json_serialized_array(p[:user_id_list]) if p[:user_id_list] + p[:username_list] = json_serialized_array(p[:username_list]) if p[:username_list] + end + get_all('/admin/v1/users', optional_params)[:response] + end + + def create_user(username:, **optional_params) + # optional_params: alias1, alias2, alias3, alias4, aliases, realname, email, + # enable_auto_prompt, status, notes, firstname, lastname + optional_params.tap do |p| + p[:aliases] = serialized_aliases(p[:aliases]) if p[:aliases] + end + params = optional_params.merge({ username: username }) + post('/admin/v1/users', params)[:response] + end + + def bulk_create_users(users:) + # Each user hash in users array requires :username and supports the following + # optional keys: realname, emaiml, status, notes, firstname, lastname + params = { users: json_serialized_array(users) } + post('/admin/v1/users/bulk_create', params)[:response] + end + + def bulk_restore_users(user_id_list:) + params = { user_id_list: json_serialized_array(user_id_list) } + post('/admin/v1/users/bulk_restore', params)[:response] + end + + def bulk_trash_users(user_id_list:) + params = { user_id_list: json_serialized_array(user_id_list) } + post('/admin/v1/users/bulk_send_to_trash', params)[:response] + end + + def get_user(user_id:) + get("/admin/v1/users/#{user_id}")[:response] + end + + def update_user(user_id:, **optional_params) + # optional_params: alias1, alias2, alias3, alias4, aliases, realname, email, + # enable_auto_prompt, status, notes, firstname, lastname, + # username + optional_params.tap do |p| + p[:aliases] = serialized_aliases(p[:aliases]) if p[:aliases] + end + post("/admin/v1/users/#{user_id}", optional_params)[:response] + end + + def delete_user(user_id:) + delete("/admin/v1/users/#{user_id}")[:response] + end + + def enroll_user(username:, email:, **optional_params) + # optional_params: valid_secs + params = optional_params.merge({ username: username, email: email }) + post('/admin/v1/users/enroll', params)[:response] + end + + def create_user_bypass_codes(user_id:, **optional_params) + # optional_params: count, codes, preserve_existing, reuse_count, valid_secs + optional_params.tap do |p| + p[:codes] = csv_serialized_array(p[:codes]) if p[:codes] + end + post("/admin/v1/users/#{user_id}/bypass_codes", optional_params)[:response] + end + + def get_user_bypass_codes(user_id:) + get_all("/admin/v1/users/#{user_id}/bypass_codes")[:response] + end + + def get_user_groups(user_id:) + get_all("/admin/v1/users/#{user_id}/groups")[:response] + end + + def add_user_group(user_id:, group_id:) + params = { group_id: group_id } + post("/admin/v1/users/#{user_id}/groups", params)[:response] + end + + def remove_user_group(user_id:, group_id:) + delete("/admin/v1/users/#{user_id}/groups/#{group_id}")[:response] + end + + def get_user_phones(user_id:) + get_all("/admin/v1/users/#{user_id}/phones")[:response] + end + + def add_user_phone(user_id:, phone_id:) + params = { phone_id: phone_id } + post("/admin/v1/users/#{user_id}/phones", params)[:response] + end + + def remove_user_phone(user_id:, phone_id:) + delete("/admin/v1/users/#{user_id}/phones/#{phone_id}")[:response] + end + + def get_user_hardware_tokens(user_id:) + get_all("/admin/v1/users/#{user_id}/tokens")[:response] + end + + def add_user_hardware_token(user_id:, token_id:) + params = { token_id: token_id } + post("/admin/v1/users/#{user_id}/tokens", params)[:response] + end + + def remove_user_hardware_token(user_id:, token_id:) + delete("/admin/v1/users/#{user_id}/tokens/#{token_id}")[:response] + end + + def get_user_webauthn_credentials(user_id:) + get_all("/admin/v1/users/#{user_id}/webauthncredentials")[:response] + end + + def get_user_desktop_authenticators(user_id:) + get_all("/admin/v1/users/#{user_id}/desktopauthenticators")[:response] + end + + def sync_user(username:, directory_key:) + params = { username: username } + post("/admin/v1/users/directorysync/#{directory_key}/syncuser", params)[:response] + end + + def send_verification_push(user_id:, phone_id:) + params = { phone_id: phone_id } + post("/admin/v1/users/#{user_id}/send_verification_push", params)[:response] + end + + def get_verification_push_response(user_id:, push_id:) + params = { push_id: push_id } + get("/admin/v1/users/#{user_id}/verification_push_response", params)[:response] + end + + ## + # Bulk Operations + # + def bulk_operations(operations:) + # Each hash in user_operations array requires :method, :path, and :body + # Each :body has the same parameter requirements as the individual operation + # Supported operations: + # Create User: POST /admin/v1/users + # Modify User: POST /admin/v1/users/[user_id] + # Delete User: DELETE /admin/v1/users/[user_id] + # Add User Group: POST /admin/v1/users/[user_id]/groups + # Remove User Group: POST /admin/v1/users/[user_id]/groups/[group_id] + operations.each{ |o| o[:body][:aliases] = serialized_aliases(o[:body][:aliases]) if o[:body][:aliases] } + params = { operations: json_serialized_array(operations) } + post('/admin/v1/bulk', params)[:response] + end + + ## + # Groups + # + def get_groups(**optional_params) + # optional_params: group_id_list + get_all('/admin/v1/groups', optional_params)[:response] + end + + def create_group(name:, **optional_params) + # optional_params: desc, status + params = optional_params.merge({ name: name }) + post('/admin/v1/groups', params)[:response] + end + + def get_group(group_id:) + get("/admin/v2/groups/#{group_id}")[:response] + end + + def get_group_users(group_id:) + get_all("/admin/v2/groups/#{group_id}/users")[:response] + end + + def update_group(group_id:, **optional_params) + # optional_params: desc, status, name + post("/admin/v1/groups/#{group_id}", optional_params)[:response] + end + + def delete_group(group_id:) + delete("/admin/v1/groups/#{group_id}")[:response] + end + + ## + # Phones + # + def get_phones(**optional_params) + # optional_params: number, extension + get_all('/admin/v1/phones', optional_params)[:response] + end + + def create_phone(**optional_params) + # optional_params: number, name, extension, type, platform, predelay, postdelay + post('/admin/v1/phones', optional_params)[:response] + end + + def get_phone(phone_id:) + get("/admin/v1/phones/#{phone_id}")[:response] + end + + def update_phone(phone_id:, **optional_params) + # optional_params: number, name, extension, type, platform, predelay, postdelay + post("/admin/v1/phones/#{phone_id}", optional_params)[:response] + end + + def delete_phone(phone_id:) + delete("/admin/v1/phones/#{phone_id}")[:response] + end + + def create_activation_url(phone_id:, **optional_params) + # optional_params: valid_secs, install + post("/admin/v1/phones/#{phone_id}/activation_url", optional_params)[:response] + end + + def send_sms_activation(phone_id:, **optional_params) + # optional_params: valid_secs, install, installation_msg, activation_msg + post("/admin/v1/phones/#{phone_id}/send_sms_activation", optional_params)[:response] + end + + def send_sms_installation(phone_id:, **optional_params) + # optional_params: installation_msg + post("/admin/v1/phones/#{phone_id}/send_sms_installation", optional_params)[:response] + end + + def send_sms_passcodes(phone_id:) + post("/admin/v1/phones/#{phone_id}/send_sms_passcodes")[:response] + end + + ## + # Tokens + # + def get_tokens(**optional_params) + # optional_params: type, serial + get_all('/admin/v1/tokens', optional_params)[:response] + end + + def create_token(type:, serial:, **optional_params) + # optional_params: secret, counter, private_id, aes_key + params = optional_params.merge({ type: type, serial: serial }) + post('/admin/v1/tokens', params)[:response] + end + + def get_token(token_id:) + get("/admin/v1/tokens/#{token_id}")[:response] + end + + def resync_token(token_id:, code1:, code2:, code3:) + params = { code1: code1, code2: code2, code3: code3 } + post("/admin/v1/tokens/#{token_id}/resync", params)[:response] + end + + def delete_token(token_id:) + delete("/admin/v1/tokens/#{token_id}")[:response] + end + + ## + # WebAuthn Credentials + # + def get_webauthncredentials + get_all('/admin/v1/webauthncredentials')[:response] + end + + def get_webauthncredential(webauthnkey:) + get("/admin/v1/webauthncredentials/#{webauthnkey}")[:response] + end + + def delete_webauthncredential(webauthnkey:) + delete("/admin/v1/webauthncredentials/#{webauthnkey}")[:response] + end + + ## + # Desktop Authenticators + # + def get_desktop_authenticators + get_all('/admin/v1/desktop_authenticators')[:response] + end + + def get_desktop_authenticator(dakey:) + get("/admin/v1/desktop_authenticators/#{dakey}")[:response] + end + + def delete_desktop_authenticator(dakey:) + delete("/admin/v1/desktop_authenticators/#{dakey}")[:response] + end + + def get_shared_desktop_authenticators + get_all('/admin/v1/desktop_authenticators/shared_device_auth')[:response] + end + + def get_shared_desktop_authenticator(shared_device_key:) + get("/admin/v1/desktop_authenticators/shared_device_auth/#{shared_device_key}")[:response] + end + + def create_shared_desktop_authenticator(group_id_list:, trusted_endpoint_integration_id_list:, + **optional_params) + # optional_params: active, name + params = optional_params.merge({ + group_id_list: group_id_list, + trusted_endpoint_integration_id_list: trusted_endpoint_integration_id_list + }) + post('/admin/v1/desktop_authenticators/shared_device_auth', params)[:response] + end + + def update_shared_desktop_authenticator(shared_device_key:, **optional_params) + # optional_params: active, name, group_id_list, trusted_endpoint_integration_id_list + put("/admin/v1/desktop_authenticators/shared_device_auth/#{shared_device_key}", + optional_params)[:response] + end + + def delete_shared_desktop_authenticator(shared_device_key:) + delete("/admin/v1/desktop_authenticators/shared_device_auth/#{shared_device_key}")[:response] + end + + ## + # Bypass Codes + # + def get_bypass_codes + get_all('/admin/v1/bypass_codes')[:response] + end + + def get_bypass_code(bypass_code_id:) + get("/admin/v1/bypass_codes/#{bypass_code_id}")[:response] + end + + def delete_bypass_code(bypass_code_id:) + delete("/admin/v1/bypass_codes/#{bypass_code_id}")[:response] + end + + ## + # Integrations + # + def get_integrations + get_all('/admin/v3/integrations')[:response] + end + + def create_integration(name:, type:, **optional_params) + # optional_params: adminapi_admins, adminapi_admins_read, adminapi_allow_to_set_permissions, + # adminapi_info, adminapi_integrations, adminapi_read_log, + # adminapi_read_resource, adminapi_settings, adminapi_write_resource, + # enroll_policy, greeting, groups_allowed, ip_whitelist, + # ip_whitelist_enroll_policy, networks_for_api_access, notes, + # trusted_device_days, self_service_allowed, sso, username_normalization_policy + # + # sso params: https://duo.com/docs/adminapi#sso-parameters + params = optional_params.merge({ name: name, type: type }) + post('/admin/v3/integrations', params)[:response] + end + + def get_integration(integration_key:) + get("/admin/v3/integrations/#{integration_key}")[:response] + end + + def update_integration(integration_key:, **optional_params) + # optional_params: adminapi_admins, adminapi_admins_read, adminapi_allow_to_set_permissions, + # adminapi_info, adminapi_integrations, adminapi_read_log, + # adminapi_read_resource, adminapi_settings, adminapi_write_resource, + # enroll_policy, greeting, groups_allowed, ip_whitelist, + # ip_whitelist_enroll_policy, networks_for_api_access, notes, + # trusted_device_days, self_service_allowed, sso, username_normalization_policy, + # name, policy_key, prompt_v4_enabled, reset_secret_key + # + # sso params: https://duo.com/docs/adminapi#sso-parameters + post("/admin/v3/integrations/#{integration_key}", optional_params)[:response] + end + + def delete_integration(integration_key:) + delete("/admin/v3/integrations/#{integration_key}")[:response] + end + + def get_integration_secret_key(integration_key:) + get("/admin/v1/integrations/#{integration_key}/skey")[:response] + end + + def get_oauth_integration_client_secret(integration_key:, client_id:) + get("/admin/v2/integrations/oauth_cc/#{integration_key}/client_secret/#{client_id}")[:response] + end + + def reset_oauth_integration_client_secret(integration_key:, client_id:) + post("/admin/v2/integrations/oauth_cc/#{integration_key}/client_secret/#{client_id}")[:response] + end + + def get_oidc_integration_client_secret(integration_key:) + get("/admin/v2/integrations/oidc/#{integration_key}/client_secret")[:response] + end + + def reset_oidc_integration_client_secret(integration_key:) + post("/admin/v2/integrations/oidc/#{integration_key}/client_secret")[:response] + end + + ## + # Policies + # + def get_policies_summary + get('/admin/v2/policies/summary')[:response] + end + + def get_policies + get_all('/admin/v2/policies')[:response] + end + + def get_global_policy + get('/admin/v2/policies/global')[:response] + end + + def get_policy(policy_key:) + get("/admin/v2/policies/#{policy_key}")[:response] + end + + def calculate_policy(user_id:, integration_key:) + params = { user_id: user_id, integration_key: integration_key } + get('/admin/v2/policies/calculate', params)[:response] + end + + def copy_policy(policy_key:, **optional_params) + # optional_params: new_policy_names_list + params = optional_params.merge({ policy_key: policy_key }) + post('/admin/v2/policies/copy', params)[:response] + end + + def create_policy(policy_name:, **optional_params) + # optional_params: apply_to_apps, apply_to_groups_in_apps, sections + params = optional_params.merge({ policy_name: policy_name }) + post('/admin/v2/policies', params)[:response] + end + + def update_policies(policies_to_update:, policy_changes:) + # parameter formatting: https://duo.com/docs/adminapi#update-policies + params = { policies_to_update: policies_to_update, policy_changes: policy_changes } + put('/admin/v2/policies/update', params)[:response] + end + + def update_policy(policy_key:, **optional_params) + # optional_params: apply_to_apps, apply_to_groups_in_apps, sections, + # policy_name, sections_to_delete + params = optional_params.merge({ policy_key: policy_key }) + put("/admin/v2/policies/#{policy_key}", params)[:response] + end + + def delete_policy(policy_key:) + delete("/admin/v2/policies/#{policy_key}")[:response] + end + + ## + # Endpoints + # + def get_endpoints + get_all('/admin/v1/endpoints')[:response] + end + + def get_endpoint(epkey:) + get("/admin/v1/endpoints/#{epkey}")[:response] + end + + ## + # Registered Devices + # + def get_registered_devices + get_all('/admin/v1/registered_devices')[:response] + end + + def get_registered_device(compkey:) + get("/admin/v1/registered_devices/#{compkey}")[:response] + end + + def delete_registered_device(compkey:) + delete("/admin/v1/registered_devices/#{compkey}")[:response] + end + + def get_blocked_registered_devices + get_all('/admin/v1/registered_devices/blocked')[:response] + end + + def get_blocked_registered_device(compkey:) + get("/admin/v1/registered_devices/blocked/#{compkey}")[:response] + end + + def block_registered_devices(registered_device_key_list:) + params = { registered_device_key_list: registered_device_key_list } + post('/admin/v1/registered_devices/blocked', params)[:response] + end + + def block_registered_device(compkey:) + post("/admin/v1/registered_devices/blocked/#{compkey}")[:response] + end + + def unblock_registered_devices(registered_device_key_list:) + params = { registered_device_key_list: registered_device_key_list } + delete('/admin/v1/registered_devices/blocked', params)[:response] + end + + def unblock_registered_device(compkey:) + delete("/admin/v1/registered_devices/blocked/#{compkey}")[:response] + end + + ## + # Passport + # + def get_passport_config + get('/admin/v2/passport/config')[:response] + end + + def update_passport_config(disabled_groups:, enabled_groups:, enabled_status:) + params = { disabled_groups: disabled_groups, enabled_groups: enabled_groups, + enabled_status: enabled_status } + post('/admin/v2/passport/config', params)[:response] + end + + ## + # Administrators + # + def get_admins + get_all('/admin/v1/admins')[:response] + end + + def create_admin(email:, name:, **optional_params) + # optional_params: phone, role, restricted_by_admin_units, send_email, token_id, valid_days + params = optional_params.merge({ email: email, name: name }) + post('/admin/v1/admins', params)[:response] + end + + def get_admin(admin_id:) + get("/admin/v1/admins/#{admin_id}")[:response] + end + + def update_admin(admin_id:, **optional_params) + # optional_params: phone, role, restricted_by_admin_units, token_id, name, status + post("/admin/v1/admins/#{admin_id}", optional_params)[:response] + end + + def delete_admin(admin_id:) + delete("/admin/v1/admins/#{admin_id}")[:response] + end + + def reset_admin_auth_attempts(admin_id:) + post("/admin/v1/admins/#{admin_id}/reset")[:response] + end + + def clear_admin_inactivity(admin_id:) + post("/admin/v1/admins/#{admin_id}/clear_inactivity")[:response] + end + + def create_existing_admin_activation_link(admin_id:) + post("/admin/v1/admins/#{admin_id}/activation_link")[:response] + end + + def delete_existing_admin_activation_link(admin_id:) + delete("/admin/v1/admins/#{admin_id}/activation_link")[:response] + end + + def email_existing_admin_activation_link(admin_id:) + post("/admin/v1/admins/#{admin_id}/activation_link/email")[:response] + end + + def create_new_admin_activation_link(email:, **optional_params) + # optional_params: admin_name, admin_role, send_email, valid_days + params = optional_params.merge({ email: email }) + post('/admin/v1/admins/activations', params)[:response] + end + + def get_new_admin_pending_activations + get_all('/admin/v1/admins/activations')[:response] + end + + def delete_new_admin_pending_activations(admin_activation_id:) + delete("/admin/v1/admins/activations/#{admin_activation_id}")[:response] + end + + def sync_admin(directory_key:, email:) + params = { email: email } + post("/admin/v1/admins/directorysync/#{directory_key}/syncadmin", params)[:response] + end + + def get_admin_password_mgmt_statuses + get_all('/admin/v1/admins/password_mgmt')[:response] + end + + def get_admin_password_mgmt_status(admin_id:) + get("/admin/v1/admins/#{admin_id}/password_mgmt")[:response] + end + + def update_admin_password_mgmt_status(admin_id:, **optional_params) + # optional_params: has_external_password_mgmt, password + post("/admin/v1/admins/#{admin_id}/password_mgmt", optional_params)[:response] + end + + def get_admin_allowed_auth_factors + get('/admin/v1/admins/allowed_auth_methods')[:response] + end + + def update_admin_allowed_auth_factors(**optional_params) + # optional_params: hardware_token_enabled, mobile_otp_enabled, push_enabled, sms_enabled, + # verified_push_enabled, verified_push_length, voice_enabled, webauthn_enabled, + # yubikey_enabled + post('/admin/v1/admins/allowed_auth_methods', optional_params)[:response] + end + + ## + # Administrative Units + # + def get_administrative_units(**optional_params) + # optional_params: admin_id, group_id, integration_key + get_all('/admin/v1/administrative_units', optional_params)[:response] + end + + def get_administrative_unit(admin_unit_id:) + get("/admin/v1/administrative_units/#{admin_unit_id}")[:response] + end + + def create_administrative_unit(name:, description:, restrict_by_groups:, **optional_params) + # optional_params: restrict_by_integrations, admins, groups, integrations + params = optional_params.merge({ name: name, description: description, + restrict_by_groups: restrict_by_groups }) + post('/admin/v1/administrative_units', params)[:response] + end + + def update_administrative_unit(admin_unit_id:, **optional_params) + # optional_params: restrict_by_integrations, admins, groups, integrations, + # name, description, restrict_by_groups + post("/admin/v1/administrative_units/#{admin_unit_id}", optional_params)[:response] + end + + def add_administrative_unit_admin(admin_unit_id:, admin_id:) + post("/admin/v1/administrative_units/#{admin_unit_id}/admin/#{admin_id}")[:response] + end + + def remove_administrative_unit_admin(admin_unit_id:, admin_id:) + delete("/admin/v1/administrative_units/#{admin_unit_id}/admin/#{admin_id}")[:response] + end + + def add_administrative_unit_group(admin_unit_id:, group_id:) + post("/admin/v1/administrative_units/#{admin_unit_id}/group/#{group_id}")[:response] + end + + def remove_administrative_unit_group(admin_unit_id:, group_id:) + delete("/admin/v1/administrative_units/#{admin_unit_id}/group/#{group_id}")[:response] + end + + def add_administrative_unit_integration(admin_unit_id:, integration_key:) + post("/admin/v1/administrative_units/#{admin_unit_id}/integration/#{integration_key}")[:response] + end + + def remove_administrative_unit_integration(admin_unit_id:, integration_key:) + delete("/admin/v1/administrative_units/#{admin_unit_id}/integration/#{integration_key}")[:response] + end + + def delete_administrative_unit(admin_unit_id:) + delete("/admin/v1/administrative_units/#{admin_unit_id}")[:response] + end + + ## + # Logs + # + def get_authentication_logs(mintime:, maxtime:, **optional_params) + # optional_params: applications, users, assessment, detections, event_types, factors, formatter, + # groups, phone_numbers, reasons, results, tokens, sort + # + # more info: https://duo.com/docs/adminapi#authentication-logs + params = optional_params.merge({ mintime: mintime, maxtime: maxtime }) + data_array_path = %i[response authlogs] + metadata_path = %i[response metadata] + get_all('/admin/v2/logs/authentication', params, data_array_path: data_array_path, + metadata_path: metadata_path).dig(*data_array_path) + end + + def get_activity_logs(mintime:, maxtime:, **optional_params) + # optional_params: sort + params = optional_params.merge({ mintime: mintime, maxtime: maxtime }) + data_array_path = %i[response items] + metadata_path = %i[response metadata] + get_all('/admin/v2/logs/activity', params, data_array_path: data_array_path, + metadata_path: metadata_path).dig(*data_array_path) + end + + def get_admin_logs(**optional_params) + # optional_params: mintime + get('/admin/v1/logs/administrator', optional_params)[:response] + end + + def get_telephony_logs(mintime:, maxtime:, **optional_params) + # optional_params: sort + params = optional_params.merge({ mintime: mintime, maxtime: maxtime }) + data_array_path = %i[response items] + metadata_path = %i[response metadata] + get_all('/admin/v2/logs/telephony', params, data_array_path: data_array_path, + metadata_path: metadata_path).dig(*data_array_path) + end + + def get_offline_enrollment_logs(**optional_params) + # optional_params: mintime + get('/admin/v1/logs/offline_enrollment', optional_params)[:response] + end + + ## + # Trust Monitor + # + def get_trust_monitor_events(mintime:, maxtime:, **optional_params) + # optional_params: formatter, type + params = optional_params.merge({ mintime: mintime, maxtime: maxtime }) + params[:limit] = 200 if !params[:limit] || (params[:limit].to_i > 200) + data_array_path = %i[response events] + metadata_path = %i[response metadata] + get_all('/admin/v1/trust_monitor/events', params, data_array_path: data_array_path, + metadata_path: metadata_path).dig(*data_array_path) + end + + ## + # Settings + # + def get_settings + get('/admin/v1/settings')[:response] + end + + def update_settings(**optional_params) + # optional_params: caller_id, duo_mobile_otp_type, email_activity_notification_enabled, + # enrollment_universal_prompt_enabled, fraud_email, fraud_email_enabled, + # global_ssp_policy_enforced, helpdesk_bypass, helpdesk_bypass_expiration, + # helpdesk_can_send_enroll_email, inactive_user_expiration, keypress_confirm, + # keypress_fraud, language, lockout_expire_duration, lockout_threshold, + # log_retention_days, minimum_password_length, name, + # password_requires_lower_alpha, password_requires_numeric, + # password_requires_special, password_requires_upper_alpha, + # push_activity_notification_enabled, sms_batch, sms_expiration, sms_message, + # sms_refresh, telephony_warning_min, timezone, unenrolled_user_lockout_threshold, + # user_managers_can_put_users_in_bypass, user_telephony_cost_max + post('/admin/v1/settings', optional_params)[:response] + end + + def get_logo + get_image('/admin/v1/logo') + end + + def update_logo(logo:) + # logo should be raw png data or base64 strict encoded raw png data + encoded_logo = base64?(logo) ? logo : Base64.strict_encode64(logo) + params = { logo: encoded_logo } + post('/admin/v1/logo', params)[:response] + end + + def delete_logo + delete('/admin/v1/logo')[:response] + end + + ## + # Custom Branding + # + def get_custom_branding + get('/admin/v1/branding')[:response] + end + + def update_custom_branding(**optional_params) + # optional_params: background_img, card_accent_color, logo, page_background_color, + # powered_by_duo, sso_custom_username_label + post('/admin/v1/branding', optional_params)[:response] + end + + def get_custom_branding_draft + get('/admin/v1/branding/draft')[:response] + end + + def update_custom_branding_draft(**optional_params) + # optional_params: background_img, card_accent_color, logo, page_background_color, + # powered_by_duo, sso_custom_username_label, user_ids + post('/admin/v1/branding/draft', optional_params)[:response] + end + + def add_custom_branding_draft_user(user_id:) + post("/admin/v1/branding/draft/users/#{user_id}")[:response] + end + + def remove_custom_branding_draft_user(user_id:) + delete("/admin/v1/branding/draft/users/#{user_id}")[:response] + end + + def publish_custom_branding_draft + post('/admin/v1/branding/draft/publish')[:response] + end + + def get_custom_branding_messaging + get('/admin/v1/branding/custom_messaging')[:response] + end + + def update_custom_branding_messaging(**optional_params) + # optional_params: help_links, help_text, locale + post('/admin/v1/branding/custom_messaging', optional_params)[:response] + end + + ## + # Account Info + # + def get_account_info_summary + get('/admin/v1/info/summary')[:response] + end + + def get_telephony_credits_used_report(**optional_params) + # optional_params: maxtime, mintime + get('/admin/v1/info/telephony_credits_used', optional_params)[:response] + end + + def get_authentication_attempts_report(**optional_params) + get('/admin/v1/info/authentication_attempts', optional_params)[:response] + end + + def get_user_authentication_attempts_report(**optional_params) + get('/admin/v1/info/user_authentication_attempts', optional_params)[:response] + end + + private + + def serialized_aliases(aliases) + case aliases + when Array + aliases.map.with_index{ |a, i| "alias#{i + 1}=#{a}" }.join('&') + when Hash + aliases.map{ |k, v| "#{k}=#{v}" }.join('&') + else + aliases + end + end + end +end diff --git a/lib/duo_api/api_client.rb b/lib/duo_api/api_client.rb new file mode 100644 index 0000000..2e8b4b5 --- /dev/null +++ b/lib/duo_api/api_client.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'base64' +require 'erb' +require 'json' +require 'openssl' +require 'net/https' +require 'time' +require 'uri' + +## +# A Ruby implementation of the Duo API +# +class DuoApi + attr_accessor :ca_file + attr_reader :default_params + + VERSION = Gem.loaded_specs['duo_api'] ? Gem.loaded_specs['duo_api'].version : '0.0.0' + + # Constants for handling rate limit backoff + MAX_BACKOFF_WAIT_SECS = 32 + INITIAL_BACKOFF_WAIT_SECS = 1 + BACKOFF_FACTOR = 2 + + def initialize(ikey, skey, host, proxy = nil, ca_file: nil, default_params: {}) + @ikey = ikey + @skey = skey + @host = host + @proxy_str = proxy + if proxy.nil? + @proxy = [] + else + proxy_uri = URI.parse proxy + @proxy = [ + proxy_uri.host, + proxy_uri.port, + proxy_uri.user, + proxy_uri.password + ] + end + @ca_file = ca_file || + File.join(File.dirname(__FILE__), '..', '..', 'ca_certs.pem') + @default_params = default_params.transform_keys(&:to_sym) + end + + def default_params=(default_params) + @default_params = default_params.transform_keys(&:to_sym) + end + + # Basic authenticated request returning raw Net::HTTPResponse object + def request(method, path, params = {}, additional_headers = nil) + # Merge default params with provided params + params = @default_params.merge(params.transform_keys(&:to_sym)) + + # Determine if params should be in a JSON request body + params_go_in_body = %w[POST PUT PATCH].include?(method) + if params_go_in_body + body = canon_json(params) + params = {} + else + body = '' + end + + # Construct the request URI + uri = request_uri(path, params) + + # Sign the request + current_date, signed = sign(method, uri.host, path, params, body, additional_headers) + + # Create the HTTP request object + request = Net::HTTP.const_get(method.capitalize).new(uri.to_s) + request.basic_auth(@ikey, signed) + request['Date'] = current_date + request['User-Agent'] = "duo_api_ruby/#{VERSION}" + + # Set Content-Type and request body for JSON requests + if params_go_in_body + request['Content-Type'] = 'application/json' + request.body = body + end + + # Start the HTTP session + Net::HTTP.start( + uri.host, uri.port, *@proxy, + use_ssl: true, ca_file: @ca_file, + verify_mode: OpenSSL::SSL::VERIFY_PEER + ) do |http| + wait_secs = INITIAL_BACKOFF_WAIT_SECS + loop do + resp = http.request(request) + + # Check if the response is rate-limited and handle backoff + return resp if !resp.is_a?(Net::HTTPTooManyRequests) || (wait_secs > MAX_BACKOFF_WAIT_SECS) + + random_offset = rand + sleep(wait_secs + random_offset) + wait_secs *= BACKOFF_FACTOR + end + end + end + + private + + # Encode a key-value pair for a URL + def encode_key_val(key, val) + key = ERB::Util.url_encode(key.to_s) + value = ERB::Util.url_encode(val.to_s) + "#{key}=#{value}" + end + + # Build a canonical parameter string + def canon_params(params_hash = nil) + return '' if params_hash.nil? + + params_hash.transform_keys(&:to_s).sort.map do |k, v| + # When value an array, repeat key for each unique value in sorted array + if v.is_a?(Array) + if v.count.positive? + v.sort.uniq.map{ |vn| encode_key_val(k, vn) }.join('&') + else + encode_key_val(k, '') + end + else + encode_key_val(k, v) + end + end.join('&') + end + + # Generate a canonical JSON body + def canon_json(params_hash = nil) + return '' if params_hash.nil? + + JSON.generate(params_hash.sort.to_h) + end + + # Canonicalize additional headers for signing + def canon_x_duo_headers(additional_headers) + additional_headers ||= {} + + unless additional_headers.none?{ |k, v| k.nil? || v.nil? } + raise(HeaderError, 'Not allowed "nil" as a header name or value') + end + + canon_list = [] + added_headers = [] + additional_headers.keys.sort.each do |header_name| + header_name_lowered = header_name.downcase + header_value = additional_headers[header_name] + validate_additional_header(header_name_lowered, header_value, added_headers) + canon_list.append(header_name_lowered, header_value) + added_headers.append(header_name_lowered) + end + + canon = canon_list.join("\x00") + OpenSSL::Digest::SHA512.hexdigest(canon) + end + + # Validate additional headers to ensure they meet requirements + def validate_additional_header(header_name, value, added_headers) + header_name.downcase! + raise(HeaderError, 'Not allowed "Null" character in header name') if header_name.include?("\x00") + raise(HeaderError, 'Not allowed "Null" character in header value') if value.include?("\x00") + raise(HeaderError, 'Additional headers must start with \'X-Duo-\'') unless header_name.start_with?('x-duo-') + raise(HeaderError, "Duplicate header passed, header=#{header_name}") if added_headers.include?(header_name) + end + + # Construct the request URI + def request_uri(path, params = nil) + u = "https://#{@host}#{path}" + u += "?#{canon_params(params)}" unless params.nil? + URI.parse(u) + end + + # Create a canonical string for signing requests + def canonicalize(method, host, path, params, body = '', additional_headers = nil, options: {}) + # options[:date] being passed manually is specifically for tests + date = options[:date] || Time.now.rfc2822 + canon = [ + date, + method.upcase, + host.downcase, + path, + canon_params(params), + OpenSSL::Digest::SHA512.hexdigest(body), + canon_x_duo_headers(additional_headers) + ] + [date, canon.join("\n")] + end + + # Sign the request with HMAC-SHA512 + def sign(method, host, path, params, body = '', additional_headers = nil, options: {}) + # options[:date] being passed manually is specifically for tests + date, canon = canonicalize(method, host, path, params, body, additional_headers, options: options) + [date, OpenSSL::HMAC.hexdigest('sha512', @skey, canon)] + end + + # Custom Error Classes + class HeaderError < StandardError; end + class RateLimitError < StandardError; end + class ResponseCodeError < StandardError; end + class ContentTypeError < StandardError; end + class PaginationError < StandardError; end + class ChildAccountError < StandardError; end +end diff --git a/lib/duo_api/api_helpers.rb b/lib/duo_api/api_helpers.rb new file mode 100644 index 0000000..e585b3d --- /dev/null +++ b/lib/duo_api/api_helpers.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require_relative 'api_client' + +# Extend DuoApi class with some HTTP method helpers +class DuoApi + # Perform a GET request and parse the response as JSON + def get(path, params = {}, additional_headers = nil) + resp = request('GET', path, params, additional_headers) + raise_http_errors(resp) + raise_content_type_errors(resp[:'content-type'], 'application/json') + + parse_json_to_sym_hash(resp.body) + end + + # Perform a GET request and retrieve all paginated JSON data + def get_all(path, params = {}, additional_headers = nil, data_array_path: nil, metadata_path: nil) + # Set default paths for returned data array and metadata if not provided + data_array_path = if data_array_path.is_a?(Array) && (data_array_path.count >= 1) + data_array_path.map(&:to_sym) + else + [:response] + end + metadata_path = if metadata_path.is_a?(Array) && (metadata_path.count >= 1) + metadata_path.map(&:to_sym) + else + [:metadata] + end + + # Ensure params keys are symbols and ignore offset parameters + params.transform_keys!(&:to_sym) + %i[offset next_offset].each do |p| + if params[p] + warn "Ignoring supplied #{p} parameter for get_all method" + params.delete(p) + end + end + # Default :limit to 1000 unless specified to minimize requests + params[:limit] ||= 1000 + + all_data = [] + prev_results_count = 0 + next_offset = 0 + prev_offset = 0 + resp_body_hash = {} + loop do + resp = request('GET', path, params, additional_headers) + raise_http_errors(resp) + raise_content_type_errors(resp[:'content-type'], 'application/json') + + resp_body_hash = parse_json_to_sym_hash(resp.body) + resp_data_array = resp_body_hash.dig(*data_array_path) + unless resp_data_array.is_a?(Array) + raise(PaginationError, + "Object at data_array_path #{JSON.generate(data_array_path)} is not an Array") + end + all_data.concat(resp_data_array) + + resp_metadata = resp_body_hash.dig(*metadata_path) + if resp_metadata.is_a?(Hash) && resp_metadata[:next_offset] + next_offset = resp_metadata[:next_offset] + next_offset = next_offset.to_i if string_int?(next_offset) + + if next_offset.is_a?(Array) || next_offset.is_a?(String) + next_offset = next_offset.join(',') if next_offset.is_a?(Array) + raise(PaginationError, 'Paginated response offset error') if next_offset == prev_offset + + params[:next_offset] = next_offset + else + raise(PaginationError, 'Paginated response offset error') if next_offset <= prev_offset + + params[:offset] = next_offset + end + else + next_offset = nil + params.delete(:offset) + params.delete(:next_offset) + end + + break if !next_offset || + (all_data.count <= prev_results_count) + + prev_results_count = all_data.count + prev_offset = next_offset + end + + # Replace the data array in the last returned resp_body_hash with the all_data array + data_array_parent_hash = if data_array_path.count > 1 + resp_body_hash.dig(*data_array_path[0..-2]) + else + resp_body_hash + end + data_array_key = data_array_path.last + data_array_parent_hash[data_array_key] = all_data + + resp_body_hash + end + + # Perform a GET request to retrieve image data and return raw data + def get_image(path, params = {}, additional_headers = nil) + resp = request('GET', path, params, additional_headers) + raise_http_errors(resp) + raise_content_type_errors(resp[:'content-type'], %r{^image/}) + + resp.body + end + + # Perform a POST request and parse the response as JSON + def post(path, params = {}, additional_headers = nil) + resp = request('POST', path, params, additional_headers) + raise_http_errors(resp) + raise_content_type_errors(resp[:'content-type'], 'application/json') + + parse_json_to_sym_hash(resp.body) + end + + # Perform a PUT request and parse the response as JSON + def put(path, params = {}, additional_headers = nil) + resp = request('PUT', path, params, additional_headers) + raise_http_errors(resp) + raise_content_type_errors(resp[:'content-type'], 'application/json') + + parse_json_to_sym_hash(resp.body) + end + + # Perform a DELETE request and parse the response as JSON + def delete(path, params = {}, additional_headers = nil) + resp = request('DELETE', path, params, additional_headers) + raise_http_errors(resp) + raise_content_type_errors(resp[:'content-type'], 'application/json') + + parse_json_to_sym_hash(resp.body) + end + + private + + # Raise errors for non-successful HTTP responses + def raise_http_errors(resp) + return if resp.is_a?(Net::HTTPSuccess) + raise(RateLimitError, 'Rate limit retry max wait exceeded') if resp.is_a?(Net::HTTPTooManyRequests) + + raise(ResponseCodeError, "HTTP #{resp.code}: #{resp.body}") + end + + # Validate the content type of the response against the expected type + def raise_content_type_errors(received, allowed) + valid = false + if allowed.is_a?(Regexp) + valid = true if received =~ allowed + elsif received == allowed + valid = true + end + raise(ContentTypeError, "Invalid Content-Type #{received}, should match #{allowed.inspect}") unless valid + end + + # Check if a value is a Base64 encoded string + def base64?(value) + value.is_a?(String) and Base64.strict_encode64(Base64.decode64(value)) == value + end + + # Check if a string represents an integer + def string_int?(value) + value.is_a?(String) and value.to_i.to_s == value + end + + # Parse JSON string to Hash with symbol keys + def parse_json_to_sym_hash(json) + JSON.parse(json, symbolize_names: true) + end + + # JSON serialize Array + def json_serialized_array(value) + value.is_a?(Array) ? JSON.generate(value) : value + end + + # CSV serialize Array + def csv_serialized_array(value) + value.is_a?(Array) ? value.join(',') : value + end + + # Format boolean as 'true' or 'false' + def stringified_boolean(value) + %w[true 1].include?(value.to_s.downcase) ? 'true' : 'false' + end + + # Format boolean as '1' or '0' + def stringified_binary_boolean(value) + %w[true 1].include?(value.to_s.downcase) ? '1' : '0' + end + + # Format boolean as 'True' or 'False' + def stringified_python_boolean(value) + %w[true 1].include?(value.to_s.downcase) ? 'True' : 'False' + end +end diff --git a/lib/duo_api/auth.rb b/lib/duo_api/auth.rb new file mode 100644 index 0000000..863e56a --- /dev/null +++ b/lib/duo_api/auth.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative 'api_client' +require_relative 'api_helpers' + +class DuoApi + ## + # Duo Auth API (https://duo.com/docs/authapi) + # + class Auth < DuoApi + def ping + get('/auth/v2/ping')[:response] + end + + def check + get('/auth/v2/check')[:response] + end + + def logo + get_image('/auth/v2/logo') + end + + def enroll(**optional_params) + # optional_params: username, valid_secs + post('/auth/v2/enroll', optional_params)[:response] + end + + def enroll_status(user_id:, activation_code:) + params = { user_id: user_id, activation_code: activation_code } + post('/auth/v2/enroll_status', params)[:response] + end + + def preauth(**optional_params) + # optional_params: user_id, username, client_supports_verified_push, ipaddr, hostname, + # trusted_device_token + # + # Note: user_id or username must be provided + optional_params.tap do |p| + if p[:client_supports_verified_push] + p[:client_supports_verified_push] = + stringified_binary_boolean(p[:client_supports_verified_push]) + end + end + post('/auth/v2/preauth', optional_params)[:response] + end + + def auth(factor:, **optional_params) + # optional_params: user_id, username, ipaddr, hostname, async + # + # Note: user_id or username must be provided + optional_params.tap do |p| + p[:async] = stringified_binary_boolean(p[:async]) if p[:async] + end + params = optional_params.merge({ factor: factor }) + post('/auth/v2/auth', params)[:response] + end + + def auth_status(txid:) + params = { txid: txid } + get('/auth/v2/auth_status', params)[:response] + end + end +end diff --git a/lib/duo_api/device.rb b/lib/duo_api/device.rb new file mode 100644 index 0000000..e35122c --- /dev/null +++ b/lib/duo_api/device.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require_relative 'api_client' +require_relative 'api_helpers' + +class DuoApi + ## + # Duo Device API (https://duo.com/docs/deviceapi) + # + class Device < DuoApi + attr_accessor :mkey + + def initialize(ikey, skey, host, proxy = nil, mkey:, ca_file: nil, default_params: {}) + super(ikey, skey, host, proxy, ca_file: ca_file, default_params: default_params) + + @mkey = mkey + end + + def create_device_cache(**optional_params) + # optional_params: active + optional_params.tap do |p| + p[:active] = stringified_python_boolean(p[:active]) if p[:active] + end + post("/device/v1/management_systems/#{@mkey}/device_cache", optional_params)[:response] + end + + def add_device_cache_devices(cache_key:, devices:) + params = { devices: devices } + post("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", params)[:response] + end + + def get_device_cache_devices(cache_key:, **optional_params) + # optional_params: device_ids + data_array_path = %i[response devices_retrieved] + metadata_path = %i[response] + get_all("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", optional_params, + data_array_path: data_array_path, metadata_path: metadata_path).dig(*data_array_path) + end + + def delete_device_cache_devices(cache_key:, devices:) + params = { devices: devices } + delete("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/devices", params)[:response] + end + + def activate_device_cache(cache_key:) + post("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}/activate")[:response] + end + + def delete_device_cache(cache_key:) + delete("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}")[:response] + end + + def get_device_caches(status:) + params = { status: status } + get("/device/v1/management_systems/#{@mkey}/device_cache", params)[:response] + end + + def get_device_cache(cache_key:) + get("/device/v1/management_systems/#{@mkey}/device_cache/#{cache_key}")[:response] + end + end +end diff --git a/test/common.rb b/test/common.rb new file mode 100644 index 0000000..e529c0e --- /dev/null +++ b/test/common.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'test/unit' +require 'mocha/test_unit' + +require 'duo_api' + +IKEY = 'test_ikey' +SKEY = 'gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT' +HOST = 'foO.BAr52.cOm' + +# Custom MockResponse object to simulate basics of Net::HTTPResponse +module MockResponse + def initialize(code, body = nil, headers = {}) + @code = code.to_s + @body = body.is_a?(Hash) ? JSON.generate(body) : body + @headers = headers.transform_keys{ |k| k.to_s.downcase.to_sym } + end + + def code + @code + end + + def body + @body + end + + def headers + @headers + end + + def [](key) + standardized_key = key.to_s.downcase.to_sym + headers[standardized_key] + end + + def to_hash + headers + end + + def to_s + [ + 'HTTP Status Code:', code, + 'HTTP Headers:', headers, + 'HTTP Body:', body + ].join("\n") + end +end + +# Override classes in Net module so we can mock them +module Net + class HTTPSuccess + include MockResponse + end + + class HTTPTooManyRequests + include MockResponse + end + + class HTTPBadRequest + include MockResponse + end +end + +## +# Custom Test Cases +# +class BaseTestCase < Test::Unit::TestCase + def setup + @client = DuoApi.new(IKEY, SKEY, HOST) + end +end + +class HTTPTestCase < BaseTestCase + setup + def setup_http + @mock_http = mock + Net::HTTP.expects(:start).times(0..2).yields(@mock_http) + end + + setup + def setup_shared_globals + @ok_resp = Net::HTTPSuccess.new('200') + + @ratelimit_resp = Net::HTTPTooManyRequests.new('429') + + @json_ok_str_resp = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: 'RESPONSE STRING' + }, + { 'Content-Type': 'application/json' } + ) + + @json_ok_hsh_resp = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: { KEY: 'VALUE' } + }, + { 'Content-Type': 'application/json' } + ) + + @json_ok_arr_resp = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: %w[RESPONSE1 RESPONSE2] + }, + { 'Content-Type': 'application/json' } + ) + + @json_fail_resp = Net::HTTPBadRequest.new( + '400', + { + stat: 'FAIL', + message: 'ERROR MESSAGE', + message_detail: 'ERROR MESSAGE DETAIL' + }, + { 'Content-Type': 'application/json' } + ) + + @json_invalid_resp = Net::HTTPSuccess.new( + '200', + 'This is not valid JSON.', + { 'Content-Type': 'application/json' } + ) + + @image_ok_resp = Net::HTTPSuccess.new( + '200', + Base64.decode64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjyN7v9x8ABYkCeCbwZG' \ + 'wAAAAASUVORK5CYII='), + { 'Content-Type': 'image/png' } + ) + end +end + +# Override StandardError to get rid of backtraces on errors for tests +class StandardError + def backtrace + [] + end +end + +# Parse JSON string to Hash with symbol keys +def parse_json_to_sym_hash(json) + JSON.parse(json, symbolize_names: true) +end + +# Format ArgumentError messages for missing keywords +def missing_keywords_message(required_keywords) + return if required_keywords.count < 1 + + msg = 'missing keyword' + msg += 's' if required_keywords.count > 1 + msg += ": :#{required_keywords.join(', :')}" + msg +end diff --git a/test/test_accounts.rb b/test/test_accounts.rb new file mode 100644 index 0000000..da38795 --- /dev/null +++ b/test/test_accounts.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative 'common' + +## +# DuoApi::Accounts tests +# +class TestAccounts < HTTPTestCase + setup + def setup_test_globals + @accounts_api = DuoApi::Accounts.new(IKEY, SKEY, HOST) + end + + def test_get_child_accounts + @mock_http.expects(:request).returns(@json_ok_str_resp) + assert_nothing_raised{ @accounts_api.get_child_accounts } + end + + def test_create_child_account + @mock_http.expects(:request).returns(@json_ok_str_resp) + required_args = { name: 'NAME' } + assert_nothing_raised{ @accounts_api.create_child_account(**required_args) } + end + + def test_create_child_account_args_missing + @mock_http.expects(:request).times(0) + required_args = [:name] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ @accounts_api.create_child_account } + end + + def test_delete_child_account + @mock_http.expects(:request).returns(@json_ok_str_resp) + required_args = { account_id: 'ACCOUNTID' } + assert_nothing_raised{ @accounts_api.delete_child_account(**required_args) } + end + + def test_delete_child_account_args_missing + @mock_http.expects(:request).times(0) + required_args = [:account_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ @accounts_api.delete_child_account } + end +end + +## +# DuoApi::Accounts.admin_api tests +# +class TestAccountsAdminApi < HTTPTestCase + setup + def setup_test_globals + @accounts_api = DuoApi::Accounts.new(IKEY, SKEY, HOST) + + @child_account_id_good = 'DAGOODCHILDACCOUNTID' + @child_account_id_bad = 'DABADCHILDACCOUNTID' + @child_account_json_ok = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: [{ + account_id: @child_account_id_good, + api_hostname: HOST, + name: 'Child Account 1' + }] + }, + { 'Content-Type': 'application/json' } + ) + end + + def test_admin_api + @mock_http.expects(:request).returns(@child_account_json_ok) + required_args = { child_account_id: @child_account_id_good } + assert_instance_of(DuoApi::Admin, @accounts_api.admin_api(**required_args)) + end + + def test_admin_api_args_missing + @mock_http.expects(:request).times(0) + required_args = [:child_account_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ @accounts_api.admin_api } + end + + def test_admin_api_bad_child_account + @mock_http.expects(:request).returns(@child_account_json_ok) + assert_raise_with_message( + DuoApi::ChildAccountError, + "Child account #{@child_account_id_bad} not found" + ){ @accounts_api.admin_api(child_account_id: @child_account_id_bad) } + end + + def test_admin_api_get_edition + @mock_http.expects(:request).twice.returns(@child_account_json_ok, @json_ok_str_resp) + accounts_admin_api = @accounts_api.admin_api(child_account_id: @child_account_id_good) + assert_nothing_raised{ accounts_admin_api.get_edition } + end + + def test_admin_api_set_edition + @mock_http.expects(:request).twice.returns(@child_account_json_ok, @json_ok_str_resp) + accounts_admin_api = @accounts_api.admin_api(child_account_id: @child_account_id_good) + required_args = { edition: 'BEYOND' } + assert_nothing_raised{ accounts_admin_api.set_edition(**required_args) } + end + + def test_admin_api_set_edition_args_missing + @mock_http.expects(:request).returns(@child_account_json_ok) + accounts_admin_api = @accounts_api.admin_api(child_account_id: @child_account_id_good) + required_args = [:edition] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ accounts_admin_api.set_edition } + end + + def test_admin_api_get_telephony_credits + @mock_http.expects(:request).twice.returns(@child_account_json_ok, @json_ok_str_resp) + accounts_admin_api = @accounts_api.admin_api(child_account_id: @child_account_id_good) + assert_nothing_raised{ accounts_admin_api.get_telephony_credits } + end + + def test_admin_api_set_telephony_credits + @mock_http.expects(:request).twice.returns(@child_account_json_ok, @json_ok_str_resp) + accounts_admin_api = @accounts_api.admin_api(child_account_id: @child_account_id_good) + required_args = { credits: 100 } + assert_nothing_raised{ accounts_admin_api.set_telephony_credits(**required_args) } + end + + def test_admin_api_set_telephony_credits_args_missing + @mock_http.expects(:request).returns(@child_account_json_ok) + accounts_admin_api = @accounts_api.admin_api(child_account_id: @child_account_id_good) + required_args = [:credits] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ accounts_admin_api.set_telephony_credits } + end +end diff --git a/test/test_admin.rb b/test/test_admin.rb new file mode 100644 index 0000000..174f2b7 --- /dev/null +++ b/test/test_admin.rb @@ -0,0 +1,2234 @@ +# frozen_string_literal: true + +require_relative 'common' + +## +# DuoApi::Admin tests +# +class TestAdmin < HTTPTestCase + setup + def setup_test_globals + @admin_api = DuoApi::Admin.new(IKEY, SKEY, HOST) + + @authlogs_ok_resp = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: { + authlogs: %w[RESPONSE1 RESPONSE2] + } + }, + { 'Content-Type': 'application/json' } + ) + + @items_ok_resp = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: { + items: %w[RESPONSE1 RESPONSE2] + } + }, + { 'Content-Type': 'application/json' } + ) + + @events_ok_resp = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: { + events: %w[RESPONSE1 RESPONSE2] + } + }, + { 'Content-Type': 'application/json' } + ) + end + + ## + # Users + # + def test_get_users + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_users } + end + + def test_get_users_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_users(**optional) } + end + + def test_create_user + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { username: 'USERNAME' } + assert_nothing_raised{ @admin_api.create_user(**required) } + end + + def test_create_user_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { username: 'USERNAME' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_user(**required, **optional) } + end + + def test_create_user_args_missing + @mock_http.expects(:request).times(0) + required = [:username] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_user } + end + + def test_bulk_create_users + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { users: [{ username: 'USERNAME1' }, { username: 'USERNAME2' }] } + assert_nothing_raised{ @admin_api.bulk_create_users(**required) } + end + + def test_bulk_create_users_args_missing + @mock_http.expects(:request).times(0) + required = [:users] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.bulk_create_users } + end + + def test_bulk_restore_users + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id_list: %w[USERID1 USERID2] } + assert_nothing_raised{ @admin_api.bulk_restore_users(**required) } + end + + def test_bulk_restore_users_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id_list] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.bulk_restore_users } + end + + def test_bulk_trash_users + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id_list: %w[USERID1 USERID2] } + assert_nothing_raised{ @admin_api.bulk_trash_users(**required) } + end + + def test_bulk_trash_users_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id_list] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.bulk_trash_users } + end + + def test_get_user + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.get_user(**required) } + end + + def test_get_user_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_user } + end + + def test_update_user + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.update_user(**required) } + end + + def test_update_user_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_user(**required, **optional) } + end + + def test_update_user_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_user } + end + + def test_delete_user + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.delete_user(**required) } + end + + def test_delete_user_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_user } + end + + def test_enroll_user + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { username: 'USERNAME', email: 'EMAIL' } + assert_nothing_raised{ @admin_api.enroll_user(**required) } + end + + def test_enroll_user_optional_args + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { username: 'USERNAME', email: 'EMAIL' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.enroll_user(**required, **optional) } + end + + def test_enroll_user_args_missing + @mock_http.expects(:request).times(0) + required = %i[username email] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.enroll_user } + end + + def test_create_user_bypass_codes + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.create_user_bypass_codes(**required) } + end + + def test_create_user_bypass_codes_optional_args + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_user_bypass_codes(**required, **optional) } + end + + def test_create_user_bypass_codes_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_user_bypass_codes } + end + + def test_get_user_bypass_codes + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.get_user_bypass_codes(**required) } + end + + def test_get_user_bypass_codes_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_user_bypass_codes } + end + + def test_get_user_groups + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.get_user_groups(**required) } + end + + def test_get_user_groups_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_user_groups } + end + + def test_add_user_group + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID', group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.add_user_group(**required) } + end + + def test_add_user_group_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.add_user_group } + end + + def test_remove_user_group + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID', group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.remove_user_group(**required) } + end + + def test_remove_user_group_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.remove_user_group } + end + + def test_get_user_phones + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.get_user_phones(**required) } + end + + def test_get_user_phones_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_user_phones } + end + + def test_add_user_phone + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID', phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.add_user_phone(**required) } + end + + def test_add_user_phone_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.add_user_phone } + end + + def test_remove_user_phone + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID', phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.remove_user_phone(**required) } + end + + def test_remove_user_phone_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.remove_user_phone } + end + + def test_get_user_hardware_tokens + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.get_user_hardware_tokens(**required) } + end + + def test_get_user_hardware_tokens_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_user_hardware_tokens } + end + + def test_add_user_hardware_token + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID', token_id: 'TOKENID' } + assert_nothing_raised{ @admin_api.add_user_hardware_token(**required) } + end + + def test_add_user_hardware_token_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id token_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.add_user_hardware_token } + end + + def test_remove_user_hardware_token + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { user_id: 'USERID', token_id: 'TOKENID' } + assert_nothing_raised{ @admin_api.remove_user_hardware_token(**required) } + end + + def test_remove_user_hardware_token_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id token_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.remove_user_hardware_token } + end + + def test_get_user_webauthn_credentials + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.get_user_webauthn_credentials(**required) } + end + + def test_get_user_webauthn_credentials_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_user_webauthn_credentials } + end + + def test_get_user_desktop_authenticators + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.get_user_desktop_authenticators(**required) } + end + + def test_get_user_desktop_authenticators_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_user_desktop_authenticators } + end + + def test_sync_user + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { username: 'USERNAME', directory_key: 'DIRECTORYKEY' } + assert_nothing_raised{ @admin_api.sync_user(**required) } + end + + def test_sync_user_args_missing + @mock_http.expects(:request).times(0) + required = %i[username directory_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.sync_user } + end + + def test_send_verification_push + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID', phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.send_verification_push(**required) } + end + + def test_send_verification_push_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.send_verification_push } + end + + def test_get_verification_push_response + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID', push_id: 'PUSHID' } + assert_nothing_raised{ @admin_api.get_verification_push_response(**required) } + end + + def test_get_verification_push_response_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id push_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_verification_push_response } + end + + ## + # Bulk Operations + # + def test_bulk_operations + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { operations: [{ method: 'POST', path: '/fake', body: { KEY: 'VALUE' } }] } + assert_nothing_raised{ @admin_api.bulk_operations(**required) } + end + + def test_bulk_operations_args_missing + @mock_http.expects(:request).times(0) + required = [:operations] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.bulk_operations } + end + + ## + # Groups + # + def test_get_groups + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_groups } + end + + def test_get_groups_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_groups(**optional) } + end + + def test_create_group + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'NAME' } + assert_nothing_raised{ @admin_api.create_group(**required) } + end + + def test_create_group_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'NAME' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_group(**required, **optional) } + end + + def test_create_group_args_missing + @mock_http.expects(:request).times(0) + required = [:name] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_group } + end + + def test_get_group + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.get_group(**required) } + end + + def test_get_group_args_missing + @mock_http.expects(:request).times(0) + required = [:group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_group } + end + + def test_get_group_users + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.get_group_users(**required) } + end + + def test_get_group_users_args_missing + @mock_http.expects(:request).times(0) + required = [:group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_group_users } + end + + def test_update_group + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.update_group(**required) } + end + + def test_update_group_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { group_id: 'GROUPID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_group(**required, **optional) } + end + + def test_update_group_args_missing + @mock_http.expects(:request).times(0) + required = [:group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_group } + end + + def test_delete_group + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.delete_group(**required) } + end + + def test_delete_group_args_missing + @mock_http.expects(:request).times(0) + required = [:group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_group } + end + + ## + # Phones + # + def test_get_phones + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_phones } + end + + def test_get_phones_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_phones(**optional) } + end + + def test_create_phone + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'PHONENAME' } + assert_nothing_raised{ @admin_api.create_phone(**required) } + end + + def test_create_phone_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'PHONENAME' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_phone(**required, **optional) } + end + + def test_get_phone + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.get_phone(**required) } + end + + def test_get_phone_args_missing + @mock_http.expects(:request).times(0) + required = [:phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_phone } + end + + def test_update_phone + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.update_phone(**required) } + end + + def test_update_phone_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_phone(**required, **optional) } + end + + def test_update_phone_args_missing + @mock_http.expects(:request).times(0) + required = [:phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_phone } + end + + def test_delete_phone + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.delete_phone(**required) } + end + + def test_delete_phone_args_missing + @mock_http.expects(:request).times(0) + required = [:phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_phone } + end + + def test_create_activation_url + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.create_activation_url(**required) } + end + + def test_create_activation_url_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_activation_url(**required, **optional) } + end + + def test_create_activation_url_args_missing + @mock_http.expects(:request).times(0) + required = [:phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_activation_url } + end + + def test_send_sms_activation + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.send_sms_activation(**required) } + end + + def test_send_sms_activation_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.send_sms_activation(**required, **optional) } + end + + def test_send_sms_activation_args_missing + @mock_http.expects(:request).times(0) + required = [:phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.send_sms_activation } + end + + def test_send_sms_installation + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.send_sms_installation(**required) } + end + + def test_send_sms_installation_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { phone_id: 'PHONEID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.send_sms_installation(**required, **optional) } + end + + def test_send_sms_installation_args_missing + @mock_http.expects(:request).times(0) + required = [:phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.send_sms_installation } + end + + def test_send_sms_passcodes + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { phone_id: 'PHONEID' } + assert_nothing_raised{ @admin_api.send_sms_passcodes(**required) } + end + + def test_send_sms_passcodes_args_missing + @mock_http.expects(:request).times(0) + required = [:phone_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.send_sms_passcodes } + end + + ## + # Tokens + # + def test_get_tokens + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_tokens } + end + + def test_get_tokens_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_tokens(**optional) } + end + + def test_create_token + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { type: 'TOKENTYPE', serial: 'TOKENSERIAL' } + assert_nothing_raised{ @admin_api.create_token(**required) } + end + + def test_create_token_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { type: 'TOKENTYPE', serial: 'TOKENSERIAL' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_token(**required, **optional) } + end + + def test_create_token_args_missing + @mock_http.expects(:request).times(0) + required = %i[type serial] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_token } + end + + def test_get_token + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { token_id: 'TOKENID' } + assert_nothing_raised{ @admin_api.get_token(**required) } + end + + def test_get_token_args_missing + @mock_http.expects(:request).times(0) + required = [:token_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_token } + end + + def test_resync_token + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { token_id: 'TOKENID', code1: 'CODE1', code2: 'CODE2', code3: 'CODE3' } + assert_nothing_raised{ @admin_api.resync_token(**required) } + end + + def test_resync_token_args_missing + @mock_http.expects(:request).times(0) + required = %i[token_id code1 code2 code3] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.resync_token } + end + + def test_delete_token + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { token_id: 'TOKENID' } + assert_nothing_raised{ @admin_api.delete_token(**required) } + end + + def test_delete_token_args_missing + @mock_http.expects(:request).times(0) + required = [:token_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_token } + end + + ## + # WebAuthn Credentials + # + def test_get_webauthncredentials + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_webauthncredentials } + end + + def test_get_webauthncredential + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { webauthnkey: 'WEBAUTHNKEY' } + assert_nothing_raised{ @admin_api.get_webauthncredential(**required) } + end + + def test_get_webauthncredential_args_missing + @mock_http.expects(:request).times(0) + required = [:webauthnkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_webauthncredential } + end + + def test_delete_webauthncredential + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { webauthnkey: 'WEBAUTHNKEY' } + assert_nothing_raised{ @admin_api.delete_webauthncredential(**required) } + end + + def test_delete_webauthncredential_args_missing + @mock_http.expects(:request).times(0) + required = [:webauthnkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_webauthncredential } + end + + ## + # Desktop Authenticators + # + def test_get_desktop_authenticators + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_desktop_authenticators } + end + + def test_get_desktop_authenticator + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { dakey: 'DAKEY' } + assert_nothing_raised{ @admin_api.get_desktop_authenticator(**required) } + end + + def test_get_desktop_authenticator_args_missing + @mock_http.expects(:request).times(0) + required = [:dakey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_desktop_authenticator } + end + + def test_delete_desktop_authenticator + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { dakey: 'DAKEY' } + assert_nothing_raised{ @admin_api.delete_desktop_authenticator(**required) } + end + + def test_delete_desktop_authenticator_args_missing + @mock_http.expects(:request).times(0) + required = [:dakey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_desktop_authenticator } + end + + def test_get_shared_desktop_authenticators + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_shared_desktop_authenticators } + end + + def test_get_shared_desktop_authenticator + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { shared_device_key: 'SHAREDDEVICEKEY' } + assert_nothing_raised{ @admin_api.get_shared_desktop_authenticator(**required) } + end + + def test_get_shared_desktop_authenticator_args_missing + @mock_http.expects(:request).times(0) + required = [:shared_device_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_shared_desktop_authenticator } + end + + def test_create_shared_desktop_authenticator + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { group_id_list: ['GROUPID1'], trusted_endpoint_integration_id_list: ['TEIID1'] } + assert_nothing_raised{ @admin_api.create_shared_desktop_authenticator(**required) } + end + + def test_create_shared_desktop_authenticator_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { group_id_list: ['GROUPID1'], trusted_endpoint_integration_id_list: ['TEIID1'] } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_shared_desktop_authenticator(**required, **optional) } + end + + def test_create_shared_desktop_authenticator_args_missing + @mock_http.expects(:request).times(0) + required = %i[group_id_list trusted_endpoint_integration_id_list] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_shared_desktop_authenticator } + end + + def test_update_shared_desktop_authenticator + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { shared_device_key: 'SHAREDDEVICEKEY' } + assert_nothing_raised{ @admin_api.update_shared_desktop_authenticator(**required) } + end + + def test_update_shared_desktop_authenticator_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { shared_device_key: 'SHAREDDEVICEKEY' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_shared_desktop_authenticator(**required, **optional) } + end + + def test_update_shared_desktop_authenticator_args_missing + @mock_http.expects(:request).times(0) + required = [:shared_device_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_shared_desktop_authenticator } + end + + def test_delete_shared_desktop_authenticator + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { shared_device_key: 'SHAREDDEVICEKEY' } + assert_nothing_raised{ @admin_api.delete_shared_desktop_authenticator(**required) } + end + + def test_delete_shared_desktop_authenticator_args_missing + @mock_http.expects(:request).times(0) + required = [:shared_device_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_shared_desktop_authenticator } + end + + ## + # Bypass Codes + # + def test_get_bypass_codes + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_bypass_codes } + end + + def test_get_bypass_code + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { bypass_code_id: 'BYPASSCODEID' } + assert_nothing_raised{ @admin_api.get_bypass_code(**required) } + end + + def test_get_bypass_code_args_missing + @mock_http.expects(:request).times(0) + required = [:bypass_code_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_bypass_code } + end + + def test_delete_bypass_code + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { bypass_code_id: 'BYPASSCODEID' } + assert_nothing_raised{ @admin_api.delete_bypass_code(**required) } + end + + def test_delete_bypass_code_args_missing + @mock_http.expects(:request).times(0) + required = [:bypass_code_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_bypass_code } + end + + ## + # Integrations + # + def test_get_integrations + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_integrations } + end + + def test_create_integration + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'INTEGRATIONNAME', type: 'INTEGRATIONTYPE' } + assert_nothing_raised{ @admin_api.create_integration(**required) } + end + + def test_create_integration_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'INTEGRATIONNAME', type: 'INTEGRATIONTYPE' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_integration(**required, **optional) } + end + + def test_create_integration_args_missing + @mock_http.expects(:request).times(0) + required = %i[name type] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_integration } + end + + def test_get_integration + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.get_integration(**required) } + end + + def test_get_integration_args_missing + @mock_http.expects(:request).times(0) + required = [:integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_integration } + end + + def test_update_integration + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.update_integration(**required) } + end + + def test_update_integration_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_integration(**required, **optional) } + end + + def test_update_integration_args_missing + @mock_http.expects(:request).times(0) + required = [:integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_integration } + end + + def test_delete_integration + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.delete_integration(**required) } + end + + def test_delete_integration_args_missing + @mock_http.expects(:request).times(0) + required = [:integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_integration } + end + + def test_get_integration_secret_key + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.get_integration_secret_key(**required) } + end + + def test_get_integration_secret_key_args_missing + @mock_http.expects(:request).times(0) + required = [:integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_integration_secret_key } + end + + def test_get_oauth_integration_client_secret + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY', client_id: 'CLIENTID' } + assert_nothing_raised{ @admin_api.get_oauth_integration_client_secret(**required) } + end + + def test_get_oauth_integration_client_secret_args_missing + @mock_http.expects(:request).times(0) + required = %i[integration_key client_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_oauth_integration_client_secret } + end + + def test_reset_oauth_integration_client_secret + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY', client_id: 'CLIENTID' } + assert_nothing_raised{ @admin_api.reset_oauth_integration_client_secret(**required) } + end + + def test_reset_oauth_integration_client_secret_args_missing + @mock_http.expects(:request).times(0) + required = %i[integration_key client_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.reset_oauth_integration_client_secret } + end + + def test_get_oidc_integration_client_secret + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.get_oidc_integration_client_secret(**required) } + end + + def test_get_oidc_integration_client_secret_args_missing + @mock_http.expects(:request).times(0) + required = [:integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_oidc_integration_client_secret } + end + + def test_reset_oidc_integration_client_secret + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.reset_oidc_integration_client_secret(**required) } + end + + def test_reset_oidc_integration_client_secret_args_missing + @mock_http.expects(:request).times(0) + required = [:integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.reset_oidc_integration_client_secret } + end + + ## + # Policies + # + def test_get_policies_summary + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_policies_summary } + end + + def test_get_policies + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_policies } + end + + def test_get_global_policy + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.get_global_policy } + end + + def test_get_policy + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { policy_key: 'POLICYKEY' } + assert_nothing_raised{ @admin_api.get_policy(**required) } + end + + def test_get_policy_args_missing + @mock_http.expects(:request).times(0) + required = [:policy_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_policy } + end + + def test_calculate_policy + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID', integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.calculate_policy(**required) } + end + + def test_calculate_policy_args_missing + @mock_http.expects(:request).times(0) + required = %i[user_id integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.calculate_policy } + end + + def test_copy_policy + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { policy_key: 'POLICYKEY' } + assert_nothing_raised{ @admin_api.copy_policy(**required) } + end + + def test_copy_policy_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { policy_key: 'POLICYKEY' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.copy_policy(**required, **optional) } + end + + def test_copy_policy_args_missing + @mock_http.expects(:request).times(0) + required = [:policy_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.copy_policy } + end + + def test_create_policy + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { policy_name: 'POLICYNAME' } + assert_nothing_raised{ @admin_api.create_policy(**required) } + end + + def test_create_policy_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { policy_name: 'POLICYNAME' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_policy(**required, **optional) } + end + + def test_create_policy_args_missing + @mock_http.expects(:request).times(0) + required = [:policy_name] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_policy } + end + + def test_update_policies + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { policies_to_update: ['POLICYKEY1'], policy_changes: { sections: {} } } + assert_nothing_raised{ @admin_api.update_policies(**required) } + end + + def test_update_policies_args_missing + @mock_http.expects(:request).times(0) + required = %i[policies_to_update policy_changes] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_policies } + end + + def test_update_policy + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { policy_key: 'POLICYKEY' } + assert_nothing_raised{ @admin_api.update_policy(**required) } + end + + def test_update_policy_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { policy_key: 'POLICYKEY' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_policy(**required, **optional) } + end + + def test_update_policy_args_missing + @mock_http.expects(:request).times(0) + required = [:policy_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_policy } + end + + def test_delete_policy + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { policy_key: 'POLICYKEY' } + assert_nothing_raised{ @admin_api.delete_policy(**required) } + end + + def test_delete_policy_args_missing + @mock_http.expects(:request).times(0) + required = [:policy_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_policy } + end + + ## + # Endpoints + # + def test_get_endpoints + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_endpoints } + end + + def test_get_endpoint + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { epkey: 'EPKEY' } + assert_nothing_raised{ @admin_api.get_endpoint(**required) } + end + + def test_get_endpoint_args_missing + @mock_http.expects(:request).times(0) + required = [:epkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_endpoint } + end + + ## + # Registered Devices + # + def test_get_registered_devices + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_registered_devices } + end + + def test_get_registered_device + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { compkey: 'COMPKEY' } + assert_nothing_raised{ @admin_api.get_registered_device(**required) } + end + + def test_get_registered_device_args_missing + @mock_http.expects(:request).times(0) + required = [:compkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_registered_device } + end + + def test_delete_registered_device + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { compkey: 'COMPKEY' } + assert_nothing_raised{ @admin_api.delete_registered_device(**required) } + end + + def test_delete_registered_device_args_missing + @mock_http.expects(:request).times(0) + required = [:compkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_registered_device } + end + + def test_get_blocked_registered_devices + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_blocked_registered_devices } + end + + def test_get_blocked_registered_device + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { compkey: 'COMPKEY' } + assert_nothing_raised{ @admin_api.get_blocked_registered_device(**required) } + end + + def test_get_blocked_registered_device_args_missing + @mock_http.expects(:request).times(0) + required = [:compkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_blocked_registered_device } + end + + def test_block_registered_devices + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { registered_device_key_list: ['REGISTEREDDEVICEKEY1'] } + assert_nothing_raised{ @admin_api.block_registered_devices(**required) } + end + + def test_block_registered_devices_args_missing + @mock_http.expects(:request).times(0) + required = [:registered_device_key_list] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.block_registered_devices } + end + + def test_block_registered_device + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { compkey: 'COMPKEY' } + assert_nothing_raised{ @admin_api.block_registered_device(**required) } + end + + def test_block_registered_device_args_missing + @mock_http.expects(:request).times(0) + required = [:compkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.block_registered_device } + end + + def test_unblock_registered_devices + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { registered_device_key_list: ['REGISTEREDDEVICEKEY1'] } + assert_nothing_raised{ @admin_api.unblock_registered_devices(**required) } + end + + def test_unblock_registered_devices_args_missing + @mock_http.expects(:request).times(0) + required = [:registered_device_key_list] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.unblock_registered_devices } + end + + def test_unblock_registered_device + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { compkey: 'COMPKEY' } + assert_nothing_raised{ @admin_api.unblock_registered_device(**required) } + end + + def test_unblock_registered_device_args_missing + @mock_http.expects(:request).times(0) + required = [:compkey] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.unblock_registered_device } + end + + ## + # Passport + # + def test_get_passport_config + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_passport_config } + end + + def test_update_passport_config + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { disabled_groups: [], enabled_groups: [], enabled_status: 'enabled' } + assert_nothing_raised{ @admin_api.update_passport_config(**required) } + end + + def test_update_passport_config_args_missing + @mock_http.expects(:request).times(0) + required = %i[disabled_groups enabled_groups enabled_status] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_passport_config } + end + + ## + # Administrators + # + def test_get_admins + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_admins } + end + + def test_create_admin + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { email: 'EMAIL', name: 'NAME' } + assert_nothing_raised{ @admin_api.create_admin(**required) } + end + + def test_create_admin_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { email: 'EMAIL', name: 'NAME' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_admin(**required, **optional) } + end + + def test_create_admin_args_missing + @mock_http.expects(:request).times(0) + required = %i[email name] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_admin } + end + + def test_get_admin + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.get_admin(**required) } + end + + def test_get_admin_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_admin } + end + + def test_update_admin + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.update_admin(**required) } + end + + def test_update_admin_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_admin(**required, **optional) } + end + + def test_update_admin_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_admin } + end + + def test_delete_admin + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.delete_admin(**required) } + end + + def test_delete_admin_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_admin } + end + + def test_reset_admin_auth_attempts + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.reset_admin_auth_attempts(**required) } + end + + def test_reset_admin_auth_attempts_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.reset_admin_auth_attempts } + end + + def test_clear_admin_inactivity + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.clear_admin_inactivity(**required) } + end + + def test_clear_admin_inactivity_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.clear_admin_inactivity } + end + + def test_create_existing_admin_activation_link + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.create_existing_admin_activation_link(**required) } + end + + def test_create_existing_admin_activation_link_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_existing_admin_activation_link } + end + + def test_delete_existing_admin_activation_link + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.delete_existing_admin_activation_link(**required) } + end + + def test_delete_existing_admin_activation_link_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_existing_admin_activation_link } + end + + def test_email_existing_admin_activation_link + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.email_existing_admin_activation_link(**required) } + end + + def test_email_existing_admin_activation_link_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.email_existing_admin_activation_link } + end + + def test_create_new_admin_activation_link + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { email: 'EMAIL' } + assert_nothing_raised{ @admin_api.create_new_admin_activation_link(**required) } + end + + def test_create_new_admin_activation_link_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { email: 'EMAIL' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_new_admin_activation_link(**required, **optional) } + end + + def test_create_new_admin_activation_link_args_missing + @mock_http.expects(:request).times(0) + required = [:email] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_new_admin_activation_link } + end + + def test_get_new_admin_pending_activations + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_new_admin_pending_activations } + end + + def test_delete_new_admin_pending_activations + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { admin_activation_id: 'ADMINACTIVATIONID' } + assert_nothing_raised{ @admin_api.delete_new_admin_pending_activations(**required) } + end + + def test_delete_new_admin_pending_activations_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_activation_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_new_admin_pending_activations } + end + + def test_sync_admin + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { directory_key: 'DIRECTORYKEY', email: 'EMAIL' } + assert_nothing_raised{ @admin_api.sync_admin(**required) } + end + + def test_sync_admin_args_missing + @mock_http.expects(:request).times(0) + required = %i[directory_key email] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.sync_admin } + end + + def test_get_admin_password_mgmt_statuses + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_admin_password_mgmt_statuses } + end + + def test_get_admin_password_mgmt_status + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.get_admin_password_mgmt_status(**required) } + end + + def test_get_admin_password_mgmt_status_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_admin_password_mgmt_status } + end + + def test_update_admin_password_mgmt_status + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.update_admin_password_mgmt_status(**required) } + end + + def test_update_admin_password_mgmt_status_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_id: 'ADMINID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_admin_password_mgmt_status(**required, **optional) } + end + + def test_update_admin_password_mgmt_status_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_admin_password_mgmt_status } + end + + def test_get_admin_allowed_auth_factors + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.get_admin_allowed_auth_factors } + end + + def test_update_admin_allowed_auth_factors + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.update_admin_allowed_auth_factors } + end + + def test_update_admin_allowed_auth_factors_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_admin_allowed_auth_factors(**optional) } + end + + ## + # Administrative Units + # + def test_get_administrative_units + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_administrative_units } + end + + def test_get_administrative_units_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_administrative_units(**optional) } + end + + def test_get_administrative_unit + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID' } + assert_nothing_raised{ @admin_api.get_administrative_unit(**required) } + end + + def test_get_administrative_unit_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_unit_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_administrative_unit } + end + + def test_create_administrative_unit + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'NAME', description: 'DESCRIPTION', restrict_by_groups: false } + assert_nothing_raised{ @admin_api.create_administrative_unit(**required) } + end + + def test_create_administrative_unit_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { name: 'NAME', description: 'DESCRIPTION', restrict_by_groups: false } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.create_administrative_unit(**required, **optional) } + end + + def test_create_administrative_unit_args_missing + @mock_http.expects(:request).times(0) + required = %i[name description restrict_by_groups] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.create_administrative_unit } + end + + def test_update_administrative_unit + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID' } + assert_nothing_raised{ @admin_api.update_administrative_unit(**required) } + end + + def test_update_administrative_unit_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_administrative_unit(**required, **optional) } + end + + def test_update_administrative_unit_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_unit_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_administrative_unit } + end + + def test_add_administrative_unit_admin + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID', admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.add_administrative_unit_admin(**required) } + end + + def test_add_administrative_unit_admin_args_missing + @mock_http.expects(:request).times(0) + required = %i[admin_unit_id admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.add_administrative_unit_admin } + end + + def test_remove_administrative_unit_admin + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID', admin_id: 'ADMINID' } + assert_nothing_raised{ @admin_api.remove_administrative_unit_admin(**required) } + end + + def test_remove_administrative_unit_admin_args_missing + @mock_http.expects(:request).times(0) + required = %i[admin_unit_id admin_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.remove_administrative_unit_admin } + end + + def test_add_administrative_unit_group + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID', group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.add_administrative_unit_group(**required) } + end + + def test_add_administrative_unit_group_args_missing + @mock_http.expects(:request).times(0) + required = %i[admin_unit_id group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.add_administrative_unit_group } + end + + def test_remove_administrative_unit_group + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID', group_id: 'GROUPID' } + assert_nothing_raised{ @admin_api.remove_administrative_unit_group(**required) } + end + + def test_remove_administrative_unit_group_args_missing + @mock_http.expects(:request).times(0) + required = %i[admin_unit_id group_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.remove_administrative_unit_group } + end + + def test_add_administrative_unit_integration + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID', integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.add_administrative_unit_integration(**required) } + end + + def test_add_administrative_unit_integration_args_missing + @mock_http.expects(:request).times(0) + required = %i[admin_unit_id integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.add_administrative_unit_integration } + end + + def test_remove_administrative_unit_integration + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { admin_unit_id: 'ADMINUNITID', integration_key: 'INTEGRATIONKEY' } + assert_nothing_raised{ @admin_api.remove_administrative_unit_integration(**required) } + end + + def test_remove_administrative_unit_integration_args_missing + @mock_http.expects(:request).times(0) + required = %i[admin_unit_id integration_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.remove_administrative_unit_integration } + end + + def test_delete_administrative_unit + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { admin_unit_id: 'ADMINUNITID' } + assert_nothing_raised{ @admin_api.delete_administrative_unit(**required) } + end + + def test_delete_administrative_unit_args_missing + @mock_http.expects(:request).times(0) + required = [:admin_unit_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.delete_administrative_unit } + end + + ## + # Logs + # + def test_get_authentication_logs + @mock_http.expects(:request).returns(@authlogs_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + assert_nothing_raised{ @admin_api.get_authentication_logs(**required) } + end + + def test_get_authentication_logs_optional_args + @mock_http.expects(:request).returns(@authlogs_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_authentication_logs(**required, **optional) } + end + + def test_get_authentication_logs_args_missing + @mock_http.expects(:request).times(0) + required = %i[mintime maxtime] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_authentication_logs } + end + + def test_get_activity_logs + @mock_http.expects(:request).returns(@items_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + assert_nothing_raised{ @admin_api.get_activity_logs(**required) } + end + + def test_get_activity_logs_optional_args + @mock_http.expects(:request).returns(@items_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_activity_logs(**required, **optional) } + end + + def test_get_activity_logs_args_missing + @mock_http.expects(:request).times(0) + required = %i[mintime maxtime] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_activity_logs } + end + + def test_get_admin_logs + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_admin_logs } + end + + def test_get_admin_logs_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_admin_logs(**optional) } + end + + def test_get_telephony_logs + @mock_http.expects(:request).returns(@items_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + assert_nothing_raised{ @admin_api.get_telephony_logs(**required) } + end + + def test_get_telephony_logs_optional_args + @mock_http.expects(:request).returns(@items_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_telephony_logs(**required, **optional) } + end + + def test_get_telephony_logs_args_missing + @mock_http.expects(:request).times(0) + required = %i[mintime maxtime] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_telephony_logs } + end + + def test_get_offline_enrollment_logs + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_offline_enrollment_logs } + end + + def test_get_offline_enrollment_logs_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_offline_enrollment_logs(**optional) } + end + + ## + # Trust Monitor + # + + def test_get_trust_monitor_events + @mock_http.expects(:request).returns(@events_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + assert_nothing_raised{ @admin_api.get_trust_monitor_events(**required) } + end + + def test_get_trust_monitor_events_optional_args + @mock_http.expects(:request).returns(@events_ok_resp) + required = { mintime: 123456789000, maxtime: 987654321000 } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_trust_monitor_events(**required, **optional) } + end + + def test_get_trust_monitor_events_args_missing + @mock_http.expects(:request).times(0) + required = %i[mintime maxtime] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.get_trust_monitor_events } + end + + ## + # Settings + # + def test_get_settings + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.get_settings } + end + + def test_update_settings + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.update_settings } + end + + def test_update_settings_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_settings(**optional) } + end + + def test_get_logo + @mock_http.expects(:request).returns(@image_ok_resp) + assert_nothing_raised{ @admin_api.get_logo } + end + + def test_update_logo + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { logo: 'LOGO' } + assert_nothing_raised{ @admin_api.update_logo(**required) } + end + + def test_update_logo_args_missing + @mock_http.expects(:request).times(0) + required = [:logo] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.update_logo } + end + + def test_delete_logo + @mock_http.expects(:request).returns(@json_ok_str_resp) + assert_nothing_raised{ @admin_api.delete_logo } + end + + ## + # Custom Branding + # + def test_get_custom_branding + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.get_custom_branding } + end + + def test_update_custom_branding + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.update_custom_branding } + end + + def test_update_custom_branding_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_custom_branding(**optional) } + end + + def test_get_custom_branding_draft + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.get_custom_branding_draft } + end + + def test_update_custom_branding_draft + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.update_custom_branding_draft } + end + + def test_update_custom_branding_draft_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_custom_branding_draft(**optional) } + end + + def test_add_custom_branding_draft_user + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.add_custom_branding_draft_user(**required) } + end + + def test_add_custom_branding_draft_user_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.add_custom_branding_draft_user } + end + + def test_remove_custom_branding_draft_user + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { user_id: 'USERID' } + assert_nothing_raised{ @admin_api.remove_custom_branding_draft_user(**required) } + end + + def test_remove_custom_branding_draft_user_args_missing + @mock_http.expects(:request).times(0) + required = [:user_id] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @admin_api.remove_custom_branding_draft_user } + end + + def test_publish_custom_branding_draft + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.publish_custom_branding_draft } + end + + def test_get_custom_branding_messaging + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.get_custom_branding_messaging } + end + + def test_update_custom_branding_messaging + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @admin_api.update_custom_branding_messaging } + end + + def test_update_custom_branding_messaging_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.update_custom_branding_messaging(**optional) } + end + + ## + # Account Info + # + def test_get_account_info_summary + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_account_info_summary } + end + + def test_get_telephony_credits_used_report + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_telephony_credits_used_report } + end + + def test_get_telephony_credits_used_report_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_telephony_credits_used_report(**optional) } + end + + def test_get_authentication_attempts_report + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_authentication_attempts_report } + end + + def test_get_authentication_attempts_report_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_authentication_attempts_report(**optional) } + end + + def test_get_user_authentication_attempts_report + @mock_http.expects(:request).returns(@json_ok_arr_resp) + assert_nothing_raised{ @admin_api.get_user_authentication_attempts_report } + end + + def test_get_user_authentication_attempts_report_optional_args + @mock_http.expects(:request).returns(@json_ok_arr_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @admin_api.get_user_authentication_attempts_report(**optional) } + end +end + +class TestAdminPrivateFormatters < BaseTestCase + setup + def setup_test_globals + @admin_api = DuoApi::Admin.new(IKEY, SKEY, HOST) + + @aliases_array = ['ALIAS1', '', nil, 'ALIAS4'] + + @aliases_hash = { alias1: 'ALIAS1', alias2: '', alias3: nil, alias4: 'ALIAS4' } + + @serialized_aliases = 'alias1=ALIAS1&alias2=&alias3=&alias4=ALIAS4' + end + + def test_serialized_aliases_input_array + actual = @admin_api.send(:serialized_aliases, @aliases_array) + assert_equal(actual, @serialized_aliases) + end + + def test_serialized_aliases_input_hash + actual = @admin_api.send(:serialized_aliases, @aliases_hash) + assert_equal(actual, @serialized_aliases) + end + + def test_serialized_aliases_input_string + actual = @admin_api.send(:serialized_aliases, @serialized_aliases) + assert_equal(actual, @serialized_aliases) + end +end diff --git a/test/test_api_client.rb b/test/test_api_client.rb new file mode 100644 index 0000000..f31d069 --- /dev/null +++ b/test/test_api_client.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require_relative 'common' + +class TestCertificateAuthority < BaseTestCase + def test_default_ca_file_exists + assert_equal(true, File.exist?(@client.ca_file)) + end +end + +class TestQueryParameters < BaseTestCase + def assert_canon_params(params, expected) + actual = @client.send(:canon_params, params) + assert_equal(expected, actual) + end + + def test_simple + assert_canon_params( + { realname: 'First Last', username: 'root' }, + 'realname=First%20Last&username=root' + ) + end + + def test_pagination_params + assert_canon_params( + { limit: '100', offset: '0' }, + 'limit=100&offset=0' + ) + end + + def test_zero_params + assert_canon_params( + {}, + '' + ) + end + + def test_one_param + assert_canon_params( + { realname: 'First Last' }, + 'realname=First%20Last' + ) + end + + def test_array_param + assert_canon_params( + { realname: %w[First Last], something: 'test' }, + 'realname=First&realname=Last&something=test' + ) + end + + def test_printable_ascii_characters + assert_canon_params( + { + digits: '0123456789', + letters: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + punctuation: '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~', + whitespace: "\t\n\x0b\x0c\r " + }, + 'digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%' \ + '27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20' + ) + end + + def test_unicode_fuzz_values + assert_canon_params( + { + bar: "\u2815\uaaa3\u37cf\u4bb7\u36e9\ucc05\u668e\u8162\uc2bd\ua1f1", + baz: "\u0df3\u84bd\u5669\u9985\ub8a4\uac3a\u7be7\u6f69\u934a\ub91c", + foo: "\ud4ce\ud6d6\u7938\u50c0\u8a20\u8f15\ufd0b\u8024\u5cb3\uc655", + qux: "\u8b97\uc846-\u828e\u831a\uccca\ua2d4\u8c3e\ub8b2\u99be" + }, + 'bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E' \ + '8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B' \ + '8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%' \ + 'B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE' + ) + end + + def test_unicode_fuzz_keys_and_values + assert_canon_params( + { + "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170": + "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", + "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813": + "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", + "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042": + "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", + "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934": + "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" + }, + '%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%' \ + '90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B' \ + '9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3' \ + '%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%' \ + 'B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%' \ + '85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%' \ + '8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU' + ) + end + + def test_encode_key_val + actual = @client.send(:encode_key_val, 'one', 'two') + assert_equal('one=two', actual) + end +end + +class TestCanonicalize < BaseTestCase + def test_sig_v5_params + params = { + "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170": + "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", + "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813": + "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", + "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042": + "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", + "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934": + "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" + } + body = '' + additional_headers = nil + expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' + expected_canon = expected_date + + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0" \ + '%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%' \ + 'E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%8' \ + '0%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97' \ + '%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%' \ + 'B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%B' \ + 'F%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%' \ + "EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU\ncf83e1357eef" \ + 'b8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd474' \ + "17a81a538327af927da3e\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c" \ + '5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e' + actual_date, actual_canon = @client.send(:canonicalize, 'PoSt', HOST, '/Foo/BaR2/qux', params, body, + additional_headers, options: { date: expected_date }) + assert_equal(expected_canon, actual_canon) + assert_equal(expected_date, actual_date) + end + + def test_sig_v5_json + params_hash = { + "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170": + "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", + "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813": + "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", + "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042": + "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", + "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934": + "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" + } + params = {} + body = JSON.generate(params_hash.sort.to_h) + additional_headers = nil + expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' + expected_canon = expected_date + + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n\n069842dc1b1158ce098fb8cbabf4695fe5b6dbbe0189293c45253b8" \ + "0522d6c56aaed43cfeeb541222d5a34d56f57e2b420b70856d1f09ba346418e7a5bca6397\ncf83e1357eefb8bdf154" \ + '2850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a53' \ + '8327af927da3e' + actual_date, actual_canon = @client.send(:canonicalize, 'PoSt', HOST, '/Foo/BaR2/qux', params, body, + additional_headers, options: { date: expected_date }) + assert_equal(expected_canon, actual_canon) + assert_equal(expected_date, actual_date) + end +end + +class TestSign < BaseTestCase + def test_hmac_sha512 + params = { + "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170": + "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", + "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813": + "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", + "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042": + "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", + "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934": + "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" + } + body = '' + additional_headers = nil + expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' + expected_sig = 'de886475f5ee8cf32872a7c10869e4dce7a0038f8b0da01d903469c6240473dfd1abf98b40b34b9ad7fbc99d5df3f2279' \ + 'e7105fd9101c428b94faaeec5e179cf' + actual_date, actual_sig = @client.send(:sign, 'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, + options: { date: expected_date }) + assert_equal(expected_sig, actual_sig) + assert_equal(expected_date, actual_date) + end +end + +class TestRetryRequests < HTTPTestCase + def test_non_limited_response + @mock_http.expects(:request).returns(@ok_resp) + @client.expects(:sleep).never + actual_resp = @client.request('GET', '/foo/bar') + assert_equal(@ok_resp, actual_resp) + end + + def test_single_limited_response + @mock_http.expects(:request).twice.returns(@ratelimit_resp, @ok_resp) + @client.expects(:rand).returns(0.123) + @client.expects(:sleep).with(1.123) + actual_resp = @client.request('GET', '/foo/bar') + assert_equal(@ok_resp, actual_resp) + end + + def test_all_limited_responses + @mock_http.expects(:request).times(7).returns(@ratelimit_resp) + @client.expects(:rand).times(6).returns(0.123) + @client.expects(:sleep).with(1.123) + @client.expects(:sleep).with(2.123) + @client.expects(:sleep).with(4.123) + @client.expects(:sleep).with(8.123) + @client.expects(:sleep).with(16.123) + @client.expects(:sleep).with(32.123) + actual_resp = @client.request('GET', '/foo/bar') + assert_equal(@ratelimit_resp, actual_resp) + end +end diff --git a/test/test_api_helpers.rb b/test/test_api_helpers.rb new file mode 100644 index 0000000..0fb78c9 --- /dev/null +++ b/test/test_api_helpers.rb @@ -0,0 +1,342 @@ +# frozen_string_literal: true + +require_relative 'common' + +class TestHelpersBasic < HTTPTestCase + def test_get_ok + @mock_http.expects(:request).returns(@json_ok_str_resp) + actual_response = @client.get('/fake') + assert_equal(parse_json_to_sym_hash(@json_ok_str_resp.body), actual_response) + end + + def test_get_fail + @mock_http.expects(:request).returns(@json_fail_resp) + assert_raise_with_message( + DuoApi::ResponseCodeError, + "HTTP #{@json_fail_resp.code}: #{@json_fail_resp.body}" + ){ @client.get('/fake') } + end + + def test_get_invalid_response_format + @mock_http.expects(:request).returns(@json_invalid_resp) + assert_raise(JSON::ParserError){ @client.get('/fake') } + end + + def test_get_invalid_response_content_type + @mock_http.expects(:request).returns(@image_ok_resp) + assert_raise_with_message( + DuoApi::ContentTypeError, + 'Invalid Content-Type image/png, should match "application/json"' + ){ @client.get('/fake') } + end + + def test_get_ratelimit_timeout + @mock_http.expects(:request).times(7).returns(@ratelimit_resp) + @client.expects(:rand).times(6).returns(0.123) + @client.expects(:sleep).with(1.123) + @client.expects(:sleep).with(2.123) + @client.expects(:sleep).with(4.123) + @client.expects(:sleep).with(8.123) + @client.expects(:sleep).with(16.123) + @client.expects(:sleep).with(32.123) + assert_raise_with_message( + DuoApi::RateLimitError, + 'Rate limit retry max wait exceeded' + ){ @client.get('/fake') } + end + + def test_get_all_ok + @mock_http.expects(:request).returns(@json_ok_arr_resp) + actual_response = @client.get_all('/fake') + assert_equal(parse_json_to_sym_hash(@json_ok_arr_resp.body), actual_response) + end + + def test_get_all_fail + @mock_http.expects(:request).returns(@json_fail_resp) + assert_raise_with_message( + DuoApi::ResponseCodeError, + "HTTP #{@json_fail_resp.code}: #{@json_fail_resp.body}" + ){ @client.get_all('/fake') } + end + + def test_get_all_invalid + @mock_http.expects(:request).returns(@json_invalid_resp) + assert_raise(JSON::ParserError){ @client.get_all('/fake') } + end + + def test_get_image_ok + @mock_http.expects(:request).returns(@image_ok_resp) + actual_response = @client.get_image('/fake') + assert_equal(@image_ok_resp.body, actual_response) + end + + def test_get_image_fail + @mock_http.expects(:request).returns(@json_fail_resp) + assert_raise_with_message( + DuoApi::ResponseCodeError, + "HTTP #{@json_fail_resp.code}: #{@json_fail_resp.body}" + ){ @client.get_image('/fake') } + end + + def test_get_image_invalid_response_content_type + @mock_http.expects(:request).returns(@json_ok_str_resp) + assert_raise_with_message( + DuoApi::ContentTypeError, + 'Invalid Content-Type application/json, should match /^image\//' + ){ @client.get_image('/fake') } + end + + def test_post_ok + @mock_http.expects(:request).returns(@json_ok_str_resp) + actual_response = @client.post('/fake') + assert_equal(parse_json_to_sym_hash(@json_ok_str_resp.body), actual_response) + end + + def test_post_fail + @mock_http.expects(:request).returns(@json_fail_resp) + assert_raise_with_message( + DuoApi::ResponseCodeError, + "HTTP #{@json_fail_resp.code}: #{@json_fail_resp.body}" + ){ @client.post('/fake') } + end + + def test_post_invalid_response_format + @mock_http.expects(:request).returns(@json_invalid_resp) + assert_raise(JSON::ParserError){ @client.post('/fake') } + end + + def test_post_invalid_response_content_type + @mock_http.expects(:request).returns(@image_ok_resp) + assert_raise_with_message( + DuoApi::ContentTypeError, + 'Invalid Content-Type image/png, should match "application/json"' + ){ @client.post('/fake') } + end + + def test_put_ok + @mock_http.expects(:request).returns(@json_ok_str_resp) + actual_response = @client.put('/fake') + assert_equal(parse_json_to_sym_hash(@json_ok_str_resp.body), actual_response) + end + + def test_put_fail + @mock_http.expects(:request).returns(@json_fail_resp) + assert_raise_with_message( + DuoApi::ResponseCodeError, + "HTTP #{@json_fail_resp.code}: #{@json_fail_resp.body}" + ){ @client.put('/fake') } + end + + def test_put_invalid_response_format + @mock_http.expects(:request).returns(@json_invalid_resp) + assert_raise(JSON::ParserError){ @client.put('/fake') } + end + + def test_put_invalid_response_content_type + @mock_http.expects(:request).returns(@image_ok_resp) + assert_raise_with_message( + DuoApi::ContentTypeError, + 'Invalid Content-Type image/png, should match "application/json"' + ){ @client.put('/fake') } + end + + def test_delete_ok + @mock_http.expects(:request).returns(@json_ok_str_resp) + actual_response = @client.delete('/fake') + assert_equal(parse_json_to_sym_hash(@json_ok_str_resp.body), actual_response) + end + + def test_delete_fail + @mock_http.expects(:request).returns(@json_fail_resp) + assert_raise_with_message( + DuoApi::ResponseCodeError, + "HTTP #{@json_fail_resp.code}: #{@json_fail_resp.body}" + ){ @client.delete('/fake') } + end + + def test_delete_invalid_response_format + @mock_http.expects(:request).returns(@json_invalid_resp) + assert_raise(JSON::ParserError){ @client.delete('/fake') } + end + + def test_delete_invalid_response_content_type + @mock_http.expects(:request).returns(@image_ok_resp) + assert_raise_with_message( + DuoApi::ContentTypeError, + 'Invalid Content-Type image/png, should match "application/json"' + ){ @client.delete('/fake') } + end +end + +class TestHelpersPaginated < HTTPTestCase + setup + def setup_test_globals + @standard_paged_response1 = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: %w[RESPONSE1 RESPONSE2], + metadata: { + total_objects: 4, + next_offset: 2, + prev_offset: 0 + } + }, + { 'Content-Type': 'application/json' } + ) + + @standard_paged_response2 = Net::HTTPSuccess.new( + '200', + JSON.generate({ + stat: 'OK', + response: %w[RESPONSE3 RESPONSE4], + metadata: { + total_objects: 4, + prev_offset: 2 + } + }), + { 'Content-Type': 'application/json' } + ) + + @standard_paged_combined_results = { + stat: 'OK', + response: %w[RESPONSE1 RESPONSE2 RESPONSE3 RESPONSE4], + metadata: { + total_objects: 4, + prev_offset: 2 + } + } + + @nonstandard_paged_response1 = Net::HTTPSuccess.new( + '200', + JSON.generate({ + stat: 'OK', + response: { + items: %w[RESPONSE1 RESPONSE2], + metadata: { + next_offset: %w[1738997429000 cb306faf-7f36-494d-9a0e-5697d93331f8] + } + } + }), + { 'Content-Type': 'application/json' } + ) + + @nonstandard_paged_response2 = Net::HTTPSuccess.new( + '200', + JSON.generate({ + stat: 'OK', + response: { + items: %w[RESPONSE3 RESPONSE4], + metadata: {} + } + }), + { 'Content-Type': 'application/json' } + ) + + @nonstandard_paged_combined_results = { + stat: 'OK', + response: { + items: %w[RESPONSE1 RESPONSE2 RESPONSE3 RESPONSE4], + metadata: {} + } + } + end + + def test_get_all_standard_paged_ok + @mock_http.expects(:request).twice.returns( + @standard_paged_response1, @standard_paged_response2 + ) + actual_response = @client.get_all('/fake') + assert_equal(@standard_paged_combined_results, actual_response) + end + + def test_get_all_nonstandard_paged_ok + @mock_http.expects(:request).twice.returns( + @nonstandard_paged_response1, @nonstandard_paged_response2 + ) + actual_response = @client.get_all('/fake', + data_array_path: %w[response items], + metadata_path: %w[response metadata]) + assert_equal(@nonstandard_paged_combined_results, actual_response) + end + + def test_get_all_bad_data_array_path + @mock_http.expects(:request).returns(@nonstandard_paged_response1) + assert_raise_with_message( + DuoApi::PaginationError, + 'Object at data_array_path ["response"] is not an Array' + ){ @client.get_all('/fake', metadata_path: %w[response metadata]) } + end +end + +class TestHelpersPrivateArrayFormatters < BaseTestCase + setup + def setup_test_globals + @client = DuoApi.new(IKEY, SKEY, HOST) + + @array = %w[ITEM1 ITEM2 ITEM3] + + @json_serialized_array = '["ITEM1","ITEM2","ITEM3"]' + + @csv_serialized_array = 'ITEM1,ITEM2,ITEM3' + end + + def test_json_serialized_array_input_array + actual = @client.send(:json_serialized_array, @array) + assert_equal(actual, @json_serialized_array) + end + + def test_json_serialized_array_input_string + actual = @client.send(:json_serialized_array, @json_serialized_array) + assert_equal(actual, @json_serialized_array) + end + + def test_csv_serialized_array_input_array + actual = @client.send(:csv_serialized_array, @array) + assert_equal(actual, @csv_serialized_array) + end + + def test_csv_serialized_array_input_string + actual = @client.send(:csv_serialized_array, @csv_serialized_array) + assert_equal(actual, @csv_serialized_array) + end +end + +class TestHelpersPrivateBooleanFormatters < BaseTestCase + setup + def setup_test_globals + @client = DuoApi.new(IKEY, SKEY, HOST) + + @bool_true = true + + @string_true = 'TRUE' + + @string_int_true = '1' + + @int_true = 1 + + @bool_false = false + + @string_false = 'FALSE' + + @string_int_false = '0' + + @int_false = 0 + + @random_object = Object.new + + @stringified_python_boolean_true = 'True' + + @stringified_python_boolean_false = 'False' + end + + def test_stringified_python_boolean_input_bool_true + actual = @client.send(:stringified_python_boolean, @bool_true) + assert_equal(actual, @stringified_python_boolean_true) + end + + def test_stringified_python_boolean_input_string_true + actual = @client.send(:stringified_python_boolean, @string_true) + assert_equal(actual, @stringified_python_boolean_true) + end +end diff --git a/test/test_auth.rb b/test/test_auth.rb new file mode 100644 index 0000000..f93fcf7 --- /dev/null +++ b/test/test_auth.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require_relative 'common' + +## +# DuoApi::Auth tests +# +class TestAuth < HTTPTestCase + setup + def setup_test_globals + @auth_api = DuoApi::Auth.new(IKEY, SKEY, HOST) + end + + def test_ping + @mock_http.expects(:request).returns(@json_ok_str_resp) + assert_nothing_raised{ @auth_api.ping } + end + + def test_check + @mock_http.expects(:request).returns(@json_ok_str_resp) + assert_nothing_raised{ @auth_api.check } + end + + def test_logo + @mock_http.expects(:request).returns(@image_ok_resp) + assert_nothing_raised{ @auth_api.logo } + end + + def test_enroll + @mock_http.expects(:request).returns(@json_ok_str_resp) + assert_nothing_raised{ @auth_api.enroll } + end + + def test_enroll_optional_args + @mock_http.expects(:request).returns(@json_ok_str_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @auth_api.enroll(**optional) } + end + + def test_enroll_status + @mock_http.expects(:request).returns(@json_ok_str_resp) + required_args = { user_id: 'USERID', activation_code: 'CODE' } + assert_nothing_raised{ @auth_api.enroll_status(**required_args) } + end + + def test_enroll_status_args_missing + @mock_http.expects(:request).times(0) + required_args = %i[user_id activation_code] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ @auth_api.enroll_status } + end + + def test_preauth + @mock_http.expects(:request).returns(@json_ok_str_resp) + assert_nothing_raised{ @auth_api.preauth } + end + + def test_preauth_optional_args + @mock_http.expects(:request).returns(@json_ok_str_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @auth_api.preauth(**optional) } + end + + def test_auth + @mock_http.expects(:request).returns(@json_ok_str_resp) + required_args = { factor: 'auto' } + assert_nothing_raised{ @auth_api.auth(**required_args) } + end + + def test_auth_optional_args + @mock_http.expects(:request).returns(@json_ok_str_resp) + required_args = { factor: 'auto' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @auth_api.auth(**required_args, **optional) } + end + + def test_auth_args_missing + @mock_http.expects(:request).times(0) + required_args = [:factor] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ @auth_api.auth } + end + + def test_auth_status + @mock_http.expects(:request).returns(@json_ok_str_resp) + required_args = { txid: 'TXID' } + assert_nothing_raised{ @auth_api.auth_status(**required_args) } + end + + def test_auth_status_args_missing + @mock_http.expects(:request).times(0) + required_args = [:txid] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required_args) + ){ @auth_api.auth_status } + end +end diff --git a/test/test_device.rb b/test/test_device.rb new file mode 100644 index 0000000..acb5f3c --- /dev/null +++ b/test/test_device.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require_relative 'common' + +## +# DuoApi::Device tests +# +class TestDevice < HTTPTestCase + setup + def setup_test_globals + @device_api = DuoApi::Device.new(IKEY, SKEY, HOST, mkey: 'MKEY') + + @devices_retrieved_ok_resp = Net::HTTPSuccess.new( + '200', + { + stat: 'OK', + response: { + cache_key: 'CACHEKEY', + devices_retrieved: [ + { + date_added: 'DATEADDED', + device_id: 'DEVICEID' + } + ], + num_devices_retrieved: 1 + } + }, + { 'Content-Type': 'application/json' } + ) + end + + def test_create_device_cache + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + assert_nothing_raised{ @device_api.create_device_cache } + end + + def test_create_device_cache_optional_args + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @device_api.create_device_cache(**optional) } + end + + def test_add_device_cache_devices + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { cache_key: 'CACHEKEY', devices: [{ device_id: 'DEVICEID1' }] } + assert_nothing_raised{ @device_api.add_device_cache_devices(**required) } + end + + def test_add_device_cache_devices_args_missing + @mock_http.expects(:request).times(0) + required = %i[cache_key devices] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @device_api.add_device_cache_devices } + end + + def test_get_device_cache_devices + @mock_http.expects(:request).returns(@devices_retrieved_ok_resp) + required = { cache_key: 'CACHEKEY' } + assert_nothing_raised{ @device_api.get_device_cache_devices(**required) } + end + + def test_get_device_cache_devices_optional_args + @mock_http.expects(:request).returns(@devices_retrieved_ok_resp) + required = { cache_key: 'CACHEKEY' } + optional = { things: 'AND STUFF' } + assert_nothing_raised{ @device_api.get_device_cache_devices(**required, **optional) } + end + + def test_get_device_cache_devices_args_missing + @mock_http.expects(:request).times(0) + required = %i[cache_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @device_api.get_device_cache_devices } + end + + def test_delete_device_cache_devices + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { cache_key: 'CACHEKEY', devices: ['DEVICEID1'] } + assert_nothing_raised{ @device_api.delete_device_cache_devices(**required) } + end + + def test_delete_device_cache_devices_args_missing + @mock_http.expects(:request).times(0) + required = %i[cache_key devices] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @device_api.delete_device_cache_devices } + end + + def test_activate_device_cache + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { cache_key: 'CACHEKEY' } + assert_nothing_raised{ @device_api.activate_device_cache(**required) } + end + + def test_activate_device_cache_args_missing + @mock_http.expects(:request).times(0) + required = %i[cache_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @device_api.activate_device_cache } + end + + def test_delete_device_cache + @mock_http.expects(:request).returns(@json_ok_str_resp) + required = { cache_key: 'CACHEKEY' } + assert_nothing_raised{ @device_api.delete_device_cache(**required) } + end + + def test_delete_device_cache_args_missing + @mock_http.expects(:request).times(0) + required = %i[cache_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @device_api.delete_device_cache } + end + + def test_get_device_caches + @mock_http.expects(:request).returns(@json_ok_arr_resp) + required = { status: 'active' } + assert_nothing_raised{ @device_api.get_device_caches(**required) } + end + + def test_get_device_caches_args_missing + @mock_http.expects(:request).times(0) + required = %i[status] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @device_api.get_device_caches } + end + + def test_get_device_cache + @mock_http.expects(:request).returns(@json_ok_hsh_resp) + required = { cache_key: 'CACHEKEY' } + assert_nothing_raised{ @device_api.get_device_cache(**required) } + end + + def test_get_device_cache_args_missing + @mock_http.expects(:request).times(0) + required = %i[cache_key] + assert_raise_with_message( + ArgumentError, + missing_keywords_message(required) + ){ @device_api.get_device_cache } + end +end diff --git a/test/test_duo_api.rb b/test/test_duo_api.rb deleted file mode 100644 index 78d72b2..0000000 --- a/test/test_duo_api.rb +++ /dev/null @@ -1,213 +0,0 @@ -require 'test/unit' -require 'mocha/test_unit' -require 'duo_api' - -IKEY = 'test_ikey' -SKEY = 'gtdfxv9YgVBYcF6dl2Eq17KUQJN2PLM2ODVTkvoT' -HOST = 'foO.BAr52.cOm' - - -class TestCase < Test::Unit::TestCase - - def setup - @client = DuoApi.new(IKEY, SKEY, HOST) - end - -end - -class TestCertificateAuthority < TestCase - - def test_default_ca_file_exists - assert_equal(true, File.exist?(@client.ca_file)) - end - -end - -class TestQueryParameters < TestCase - - def assert_canon_params(params, expected) - actual = @client.send(:canon_params, params) - assert_equal(expected, actual) - end - - def test_simple - assert_canon_params( - {'realname' => 'First Last', 'username' => 'root'}, - 'realname=First%20Last&username=root', - ) - end - - def test_pagination_params - assert_canon_params( - {'limit' => '100', 'offset' => '0'}, - 'limit=100&offset=0', - ) - end - - def test_zero_params - assert_canon_params( - {}, - '', - ) - end - - def test_one_param - assert_canon_params( - {'realname' => 'First Last'}, - 'realname=First%20Last', - ) - end - - def test_array_param - assert_canon_params( - {'realname' => ['First', 'Last'], 'something' => 'test'}, - 'realname=First&realname=Last&something=test', - ) - end - - def test_printable_ascii_characters - assert_canon_params( - { - 'digits' => '0123456789', - 'letters' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', - 'punctuation' => '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~', - 'whitespace' => "\t\n\x0b\x0c\r ", - }, - 'digits=0123456789&letters=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ&punctuation=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~&whitespace=%09%0A%0B%0C%0D%20' - ) - end - - def test_unicode_fuzz_values - assert_canon_params( - { - 'bar' => "\u2815\uaaa3\u37cf\u4bb7\u36e9\ucc05\u668e\u8162\uc2bd\ua1f1", - 'baz' => "\u0df3\u84bd\u5669\u9985\ub8a4\uac3a\u7be7\u6f69\u934a\ub91c", - 'foo' => "\ud4ce\ud6d6\u7938\u50c0\u8a20\u8f15\ufd0b\u8024\u5cb3\uc655", - 'qux' => "\u8b97\uc846-\u828e\u831a\uccca\ua2d4\u8c3e\ub8b2\u99be", - }, - 'bar=%E2%A0%95%EA%AA%A3%E3%9F%8F%E4%AE%B7%E3%9B%A9%EC%B0%85%E6%9A%8E%E8%85%A2%EC%8A%BD%EA%87%B1&baz=%E0%B7%B3%E8%92%BD%E5%99%A9%E9%A6%85%EB%A2%A4%EA%B0%BA%E7%AF%A7%E6%BD%A9%E9%8D%8A%EB%A4%9C&foo=%ED%93%8E%ED%9B%96%E7%A4%B8%E5%83%80%E8%A8%A0%E8%BC%95%EF%B4%8B%E8%80%A4%E5%B2%B3%EC%99%95&qux=%E8%AE%97%EC%A1%86-%E8%8A%8E%E8%8C%9A%EC%B3%8A%EA%8B%94%E8%B0%BE%EB%A2%B2%E9%A6%BE', - ) - end - - def test_unicode_fuzz_keys_and_values - assert_canon_params( - { - "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", - "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", - "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", - "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU", - }, - "%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU", - ) - end - - def test_encode_key_val - actual = @client.send(:encode_key_val, 'one', 'two') - assert_equal('one=two', actual) - end -end - -class TestCanonicalize < TestCase - def test_sig_v5_params - params = { - "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", - "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", - "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", - "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" - } - body = '' - additional_headers = nil - expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' - expected_canon = expected_date + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n%E4%9A%9A%E2%A1%BB%E3%97%90%E8%BB%B3%E6%9C%A7%E5%80%AA%E0%A0%90%ED%82%91%C3%88%EC%85%B0=%E0%BD%85%E1%A9%B6%E3%90%9A%E6%95%8C%EC%88%BF%E9%AC%89%EA%AF%A2%E8%8D%83%E1%AC%A7%E6%83%90&%E7%91%89%E7%B9%8B%EC%B3%BB%E5%A7%BF%EF%B9%9F%E8%8E%B7%EA%B7%8C%E9%80%8C%EC%BF%91%E7%A0%93=%E8%B6%B7%E5%80%A2%E9%8B%93%E4%8B%AF%E2%81%BD%E8%9C%B0%EA%B3%BE%E5%98%97%E0%A5%86%E4%B8%B0&%E7%91%B0%E9%8C%94%E9%80%9C%E9%BA%AE%E4%83%98%E4%88%81%E8%8B%98%E8%B1%B0%E1%B4%B1%EA%81%82=%E1%9F%99%E0%AE%A8%E9%8D%98%EA%AB%9F%EA%90%AA%E4%A2%BE%EF%AE%96%E6%BF%A9%EB%9F%BF%E3%8B%B3&%EC%8B%85%E2%B0%9D%E2%98%A0%E3%98%97%E9%9A%B3F%E8%98%85%E2%83%A8%EA%B0%A1%E5%A4%B4=%EF%AE%A9%E4%86%AA%EB%B6%83%E8%90%8B%E2%98%95%E3%B9%AE%E6%94%AD%EA%A2%B5%ED%95%ABU\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" - actual_date, actual_canon = @client.send(:canonicalize, - 'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date}) - assert_equal(expected_canon, actual_canon) - assert_equal(expected_date, actual_date) - end - - def test_sig_v5_json - params_hash = { - "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", - "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", - "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", - "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" - } - params = {} - body = JSON.generate(Hash[params_hash.sort]) - additional_headers = nil - expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' - expected_canon = expected_date + "\nPOST\nfoo.bar52.com\n/Foo/BaR2/qux\n\n069842dc1b1158ce098fb8cbabf4695fe5b6dbbe0189293c45253b80522d6c56aaed43cfeeb541222d5a34d56f57e2b420b70856d1f09ba346418e7a5bca6397\ncf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" - actual_date, actual_canon = @client.send(:canonicalize, - 'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date}) - assert_equal(expected_canon, actual_canon) - assert_equal(expected_date, actual_date) - end -end - -class TestSign < TestCase - def test_hmac_sha512 - params = { - "\u469a\u287b\u35d0\u8ef3\u6727\u502a\u0810\ud091\u00c8\uc170" => "\u0f45\u1a76\u341a\u654c\uc23f\u9b09\uabe2\u8343\u1b27\u60d0", - "\u7449\u7e4b\uccfb\u59ff\ufe5f\u83b7\uadcc\u900c\ucfd1\u7813" => "\u8db7\u5022\u92d3\u42ef\u207d\u8730\uacfe\u5617\u0946\u4e30", - "\u7470\u9314\u901c\u9eae\u40d8\u4201\u82d8\u8c70\u1d31\ua042" => "\u17d9\u0ba8\u9358\uaadf\ua42a\u48be\ufb96\u6fe9\ub7ff\u32f3", - "\uc2c5\u2c1d\u2620\u3617\u96b3F\u8605\u20e8\uac21\u5934" => "\ufba9\u41aa\ubd83\u840b\u2615\u3e6e\u652d\ua8b5\ud56bU" - } - body = '' - additional_headers = nil - expected_date = 'Fri, 07 Dec 2012 17:18:00 -0000' - expected_sig = 'de886475f5ee8cf32872a7c10869e4dce7a0038f8b0da01d903469c6240473dfd1abf98b40b34b9ad7fbc99d5df3f2279e7105fd9101c428b94faaeec5e179cf' - actual_date, actual_sig = @client.send(:sign, - 'PoSt', HOST, '/Foo/BaR2/qux', params, body, additional_headers, options: {:date => expected_date}) - assert_equal(expected_sig, actual_sig) - assert_equal(expected_date, actual_date) - end - -end - -class MockResponse < Object - attr_reader :code - - def initialize(code) - @code = code - end -end - -class TestRetryRequests < TestCase - def setup - super - @mock_http = mock() - Net::HTTP.expects(:start).yields(@mock_http) - - @limited_response = MockResponse.new('429') - @ok_response = MockResponse.new('200') - end - - def test_non_limited_response - @mock_http.expects(:request).returns(@ok_response) - @client.expects(:sleep).never - actual_response = @client.request('GET', '/foo/bar') - assert_equal(@ok_response, actual_response) - end - - def test_single_limited_response - @mock_http.expects(:request).twice.returns(@limited_response, @ok_response) - @client.expects(:rand).returns(0.123) - @client.expects(:sleep).with(1.123) - actual_response = @client.request('GET', '/foo/bar') - assert_equal(@ok_response, actual_response) - end - - def test_all_limited_responses - @mock_http.expects(:request).times(7).returns(@limited_response) - @client.expects(:rand).times(6).returns(0.123) - @client.expects(:sleep).with(1.123) - @client.expects(:sleep).with(2.123) - @client.expects(:sleep).with(4.123) - @client.expects(:sleep).with(8.123) - @client.expects(:sleep).with(16.123) - @client.expects(:sleep).with(32.123) - actual_response = @client.request('GET', '/foo/bar') - assert_equal(@limited_response, actual_response) - end - -end