From a0178b79f100a110072dceaa57f71033d7327855 Mon Sep 17 00:00:00 2001
From: Jordan Borean <jborean93@gmail.com>
Date: Wed, 7 Feb 2018 20:58:47 +1100
Subject: [PATCH] win_uri: fixes (#35487)

* win_uri: moved away from Invoke-WebRequest and fixed multiple issues

* fixes from review
---
 .../Ansible.ModuleUtils.Legacy.psm1           |   2 +
 lib/ansible/modules/windows/win_uri.ps1       | 237 ++++++++++----
 lib/ansible/modules/windows/win_uri.py        |  70 ++--
 .../targets/uri/files/testserver.ps1          |  36 ---
 test/integration/targets/win_uri/aliases      |   1 +
 .../targets/win_uri/defaults/main.yml         |   3 +
 .../targets/win_uri/tasks/main.yml            |  14 +
 .../targets/win_uri/tasks/test.yml            | 298 ++++++++++++++++++
 test/sanity/pslint/ignore.txt                 |   1 -
 9 files changed, 528 insertions(+), 134 deletions(-)
 delete mode 100644 test/integration/targets/uri/files/testserver.ps1
 create mode 100644 test/integration/targets/win_uri/aliases
 create mode 100644 test/integration/targets/win_uri/defaults/main.yml
 create mode 100644 test/integration/targets/win_uri/tasks/main.yml
 create mode 100644 test/integration/targets/win_uri/tasks/test.yml

diff --git a/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.Legacy.psm1 b/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.Legacy.psm1
index a5e7a7c70b3..9dad05728ed 100644
--- a/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.Legacy.psm1
+++ b/lib/ansible/module_utils/powershell/Ansible.ModuleUtils.Legacy.psm1
@@ -214,6 +214,8 @@ Function Get-AnsibleParam($obj, $name, $default = $null, $resultobj = @{}, $fail
             } elseif ($value -is [string]) {
                 # Convert string type to real Powershell array
                 $value = $value.Split(",").Trim()
+            } elseif ($value -is [int]) {
+                $value = @($value)
             } else {
                 Fail-Json -obj $resultobj -message "Get-AnsibleParam: Parameter '$name' is not a YAML list."
             }
diff --git a/lib/ansible/modules/windows/win_uri.ps1 b/lib/ansible/modules/windows/win_uri.ps1
index 5e7ec6b3b6b..6ace1c3cd70 100644
--- a/lib/ansible/modules/windows/win_uri.ps1
+++ b/lib/ansible/modules/windows/win_uri.ps1
@@ -5,141 +5,252 @@
 # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
 
 #Requires -Module Ansible.ModuleUtils.Legacy
+#Requires -Module Ansible.ModuleUtils.CamelConversion
+#Requires -Module Ansible.ModuleUtils.FileUtil
 
 $ErrorActionPreference = "Stop"
 
-$safe_methods = @("GET", "HEAD")
-$content_keys = @("Content", "Images", "InputFields", "Links", "RawContent")
-
-Function ConvertTo-SnakeCase($input_string) {
-    $snake_case = $input_string -csplit "(?<!^)(?=[A-Z])" -join "_"
-    return $snake_case.ToLower()
-}
-
 $params = Parse-Args -arguments $args -supports_check_mode $true
 $check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false
 
 $url = Get-AnsibleParam -obj $params -name "url" -type "str" -failifempty $true
-$method = Get-AnsibleParam -obj $params "method" -type "str" -default "GET" -validateset "CONNECT","DELETE","GET","HEAD","OPTIONS","PATCH","POST","PUT","REFRESH","TRACE"
+$method = Get-AnsibleParam -obj $params "method" -type "str" -default "GET" -validateset "CONNECT","DELETE","GET","HEAD","MERGE","OPTIONS","PATCH","POST","PUT","REFRESH","TRACE"
 $content_type = Get-AnsibleParam -obj $params -name "content_type" -type "str"
-$headers = Get-AnsibleParam -obj $params -name "headers" -type="dict"
-$body = Get-AnsibleParam -obj $params -name "body" -type "dict"
+$headers = Get-AnsibleParam -obj $params -name "headers"
+$body = Get-AnsibleParam -obj $params -name "body"
 $dest = Get-AnsibleParam -obj $params -name "dest" -type "path"
 
 $user = Get-AnsibleParam -obj $params -name "user" -type "str"
 $password = Get-AnsibleParam -obj $params -name "password" -type "str"
+$force_basic_auth = Get-AnsibleParam -obj $params -name "force_basic_auth" -type "bool" -default $false
 
 $creates = Get-AnsibleParam -obj $params -name "creates" -type "path"
 $removes = Get-AnsibleParam -obj $params -name "removes" -type "path"
 
 $follow_redirects = Get-AnsibleParam -obj $params -name "follow_redirects" -type "str" -default "safe" -validateset "all","none","safe"
-$maximum_redirection = Get-AnsibleParam -obj $params -name "maximum_redirection" -type "int" -default 5
+$maximum_redirection = Get-AnsibleParam -obj $params -name "maximum_redirection" -type "int" -default 50
 $return_content = Get-AnsibleParam -obj $params -name "return_content" -type "bool" -default $false
 $status_code = Get-AnsibleParam -obj $params -name "status_code" -type "list" -default @(200)
 $timeout = Get-AnsibleParam -obj $params -name "timeout" -type "int" -default 30
-$use_basic_parsing = Get-AnsibleParam -obj $params -name "use_basic_parsing" -type "bool" -default $true
+$use_basic_parsing = Get-AnsibleParam -obj $params -name "use_basic_parsing" -type "bool"
 $validate_certs = Get-AnsibleParam -obj $params -name "validate_certs" -type "bool" -default $true
 $client_cert = Get-AnsibleParam -obj $params -name "client_cert" -type "path"
+$client_cert_password = Get-AnsibleParam -obj $params -name "client_cert_password" -type "str"
 
-if ($creates -and (Test-Path -Path $creates)) {
+if ($creates -and (Test-AnsiblePath -Path $creates)) {
     $result.skipped = $true
     Exit-Json -obj $result -message "The 'creates' file or directory ($creates) already exists."
 }
 
-if ($removes -and -not (Test-Path -Path $removes)) {
+if ($removes -and -not (Test-AnsiblePath -Path $removes)) {
     $result.skipped = $true
     Exit-Json -obj $result -message "The 'removes' file or directory ($removes) does not exist."
 }
 
 $result = @{
     changed = $false
-    content_type = $content_type
-    method = $method
     url = $url
-    use_basic_parsing = $use_basic_parsing
 }
 
+if ($use_basic_parsing) {
+    Add-DeprecationWarning -obj $result -message "Since Ansible 2.5, use_basic_parsing does not change any behaviour, this option will be removed" -version 2.7
+}
+
+$client = [System.Net.WebRequest]::Create($url)
+$client.Method = $method
+$client.Timeout = $timeout * 1000
+
 # Disable redirection if requested
 switch($follow_redirects) {
     "none" {
-        $maximum_redirection = 0
+        $client.AllowAutoRedirect = $false
     }
     "safe" {
-        if ($safe_methods -notcontains $method) {
-            $maximum_redirection = 0
+        if (@("GET", "HEAD") -notcontains $method) {
+            $client.AllowAutoRedirect = $false
+        } else {
+            $client.AllowAutoRedirect = $true
         }
     }
+    default {
+        $client.AllowAutoRedirect = $true
+    }
 }
-
-$webrequest_opts = @{
-    ContentType = $content_type
-    ErrorAction = "SilentlyContinue"
-    MaximumRedirection = $maximum_redirection
-    Method = $method
-    TimeoutSec = $timeout
-    Uri = $url
-    UseBasicParsing = $use_basic_parsing
+if ($maximum_redirection -eq 0) {
+    # 0 is not a valid option, need to disable redirection through AllowAutoRedirect
+    $client.AllowAutoRedirect = $false
+} else {
+    $client.MaximumAutomaticRedirections = $maximum_redirection
 }
 
 if (-not $validate_certs) {
-    $PSDefaultParameterValues.Add("Invoke-WebRequest:SkipCertificateCheck", $true)
+    [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
+}
+
+# Enable TLS1.1/TLS1.2 if they're available but disabled (eg. .NET 4.5)
+$security_protcols = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::SystemDefault
+if ([Net.SecurityProtocolType].GetMember("Tls11").Count -gt 0) {
+    $security_protcols = $security_protcols -bor [Net.SecurityProtocolType]::Tls11
+}
+if ([Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) {
+    $security_protcols = $security_protcols -bor [Net.SecurityProtocolType]::Tls12
+}
+[Net.ServicePointManager]::SecurityProtocol = $security_protcols
+
+
+if ($null -ne $content_type) {
+    $client.ContentType = $content_type
 }
 
 if ($headers) {
-    $req_headers = @{}
-    ForEach ($header in $headers.psobject.properties) {
-        $req_headers.Add($header.Name, $header.Value)
+    $req_headers = New-Object -TypeName System.Net.WebHeaderCollection
+    foreach ($header in $headers.GetEnumerator()) {
+        # some headers need to be set on the property itself
+        switch ($header.Name) {
+            Accept { $client.Accept = $header.Value }
+            Connection { $client.Connection = $header.Value }
+            Content-Length { $client.ContentLength = $header.Value }
+            Content-Type { $client.ContentType = $header.Value }
+            Expect { $client.Expect = $header.Value }
+            Date { $client.Date = $header.Value }
+            Host { $client.Host = $header.Value }
+            If-Modified-Since { $client.IfModifiedSince = $header.Value }
+            Range { $client.AddRange($header.Value) }
+            Referer { $client.Referer = $header.Value }
+            Transfer-Encoding {
+                $client.SendChunked = $true
+                $client.TransferEncoding = $header.Value
+            }
+            User-Agent { $client.UserAgent = $header.Value }
+            default { $req_headers.Add($header.Name, $header.Value) }
+        }
     }
-    $webrequest_opts.Headers = $req_headers
+    $client.Headers = $req_headers
 }
 
 if ($client_cert) {
-    Try {
-        $webrequest_opts.Certificate = Get-PfxCertificate -FilePath $client_cert
-    } Catch {
-        Fail-Json -obj $result -message "Failed to read client certificate '$client_cert'"
+    if (-not (Test-AnsiblePath -Path $client_cert)) {
+        Fail-Json -obj $result -message "Client certificate '$client_cert' does not exist"
+    }
+    try {
+        $certs = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2Collection -ArgumentList $client_cert, $client_cert_password
+        $client.ClientCertificates = $certs
+    } catch [System.Security.Cryptography.CryptographicException] {
+        Fail-Json -obj $result -message "Failed to read client certificate '$client_cert': $($_.Exception.Message)"
+    } catch {
+        Fail-Json -obj $result -message "Unhandled exception when reading client certificate at '$client_cert': $($_.Exception.Message)"
     }
 }
 
-if ($body) {
-    $webrequest_opts.Body = $body
-    $result.body = $body
-}
-
-if ($dest -and -not $check_mode) {
-    $webrequest_opts.OutFile = $dest
-    $webrequest_opts.PassThru = $true
-    $result.dest = $dest
-}
-
 if ($user -and $password) {
-    $webrequest_opts.Credential = New-Object System.Management.Automation.PSCredential($user, $($password | ConvertTo-SecureString -AsPlainText -Force))
+    if ($force_basic_auth) {
+        $basic_value = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($user):$($password)"))
+        $client.Headers.Add("Authorization", "Basic $basic_value")
+    } else {
+        $sec_password = ConvertTo-SecureString -String $password -AsPlainText -Force
+        $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $user, $sec_password
+        $client.Credentials = $credential
+    }
 } elseif ($user -or $password) {
     Add-Warning -obj $result -message "Both 'user' and 'password' parameters are required together, skipping authentication"
 }
 
-try {
-    $response = Invoke-WebRequest @webrequest_opts
-} catch {
-    Fail-Json $result $_.Exception.Message
+if ($null -ne $body) {
+    if ($body -is [Hashtable]) {
+        $body_string = ConvertTo-Json -InputObject $body -Compress
+    } elseif ($body -isnot [String]) {
+        $body_string = $body.ToString()
+    } else {
+        $body_string = $body
+    }
+    $buffer = [System.Text.Encoding]::UTF8.GetBytes($body_string)
+
+    $req_st = $client.GetRequestStream()
+    try {
+        $req_st.Write($buffer, 0, $buffer.Length)
+    } finally {
+        $req_st.Flush()
+        $req_st.Close()
+    }
 }
 
-# TODO: When writing to a file, this is not idempotent !
-# FIXME: Assume a change when we are writing to a file
-if ($dest) {
-    $result.changed = $true
+try {
+    $response = $client.GetResponse()
+} catch [System.Net.ProtocolViolationException] {
+    Fail-Json -obj $result -message "ProtocolViolationException when sending web request: $($_.Exception.Message)"
+} catch [System.Net.WebException] {
+    Fail-Json -obj $result -message "WebException occurred when sending web request: $($_.Exception.Message)"
+} catch {
+    Fail-Json -obj $result -message "Unhandled exception occured when sending web request. Exception: $($_.Exception.Message)"
 }
 
 ForEach ($prop in $response.psobject.properties) {
-    if ($content_keys -contains $prop.Name -and -not $return_content) {
-        continue
-    }
-    $result_key = ConvertTo-SnakeCase $prop.Name
+    $result_key = Convert-StringToSnakeCase -string $prop.Name
     $result.$result_key = $prop.Value
 }
 
+# manually get the headers as not all of them are in the response properties
+foreach ($header_key in $response.Headers.GetEnumerator()) {
+    $header_value = $response.Headers[$header_key]
+    $header_key = $header_key.Replace("-", "") # replace - with _ for snake conversion
+    $header_key = Convert-StringToSnakeCase -string $header_key
+    $result.$header_key = $header_value
+}
+
 if ($status_code -notcontains $response.StatusCode) {
     Fail-Json -obj $result -message "Status code of request '$($response.StatusCode)' is not in list of valid status codes $status_code."
 }
 
+# we only care about the return body if we need to return the content or create a file
+if ($return_content -or $dest) {
+    $resp_st = $response.GetResponseStream()
+
+    # copy to a MemoryStream so we can read it multiple times
+    $memory_st = New-Object -TypeName System.IO.MemoryStream
+    try {
+        $resp_st.CopyTo($memory_st)
+        $resp_st.Close()
+
+        if ($return_content) {
+            $memory_st.Seek(0, [System.IO.SeekOrigin]::Begin)
+            $content_bytes = $memory_st.ToArray()
+            $result.content = [System.Text.Encoding]::UTF8.GetString($content_bytes)
+            if ($result.ContainsKey("content_type") -and $result.content_type -in @("application/json", "application/javascript")) {
+                $result.json = ConvertFrom-Json -InputObject $result.content
+            }
+        }
+
+        if ($dest) {
+            $memory_st.Seek(0, [System.IO.SeekOrigin]::Begin)
+            $changed = $true
+
+            if (Test-AnsiblePath -Path $dest) {
+                $actual_checksum = Get-FileChecksum -path $dest -algorithm "sha1"
+
+                $sp = New-Object -TypeName System.Security.Cryptography.SHA1CryptoServiceProvider
+                $content_checksum = [System.BitConverter]::ToString($sp.ComputeHash($memory_st)).Replace("-", "").ToLower()
+    
+                if ($actual_checksum -eq $content_checksum) {
+                    $changed = $false
+                }
+            }
+
+            $result.changed = $changed
+            if ($changed -and (-not $check_mode)) {
+                $memory_st.Seek(0, [System.IO.SeekOrigin]::Begin)
+                $file_stream = [System.IO.File]::Create($dest)
+                try {
+                    $memory_st.CopyTo($file_stream)
+                } finally {
+                    $file_stream.Flush()
+                    $file_stream.Close()
+                }
+            }
+        }
+    } finally {
+        $memory_st.Close()
+    }
+}
+
 Exit-Json -obj $result
+
diff --git a/lib/ansible/modules/windows/win_uri.py b/lib/ansible/modules/windows/win_uri.py
index 9d5dc0ab29c..c30c8967bb1 100644
--- a/lib/ansible/modules/windows/win_uri.py
+++ b/lib/ansible/modules/windows/win_uri.py
@@ -22,12 +22,11 @@ options:
   url:
     description:
     - Supports FTP, HTTP or HTTPS URLs in the form of (ftp|http|https)://host.domain:port/path.
-    - Also supports file:/// URLs through Invoke-WebRequest.
     required: yes
   method:
     description:
     - The HTTP Method of the request or response.
-    choices: [ CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, REFRESH, TRACE ]
+    choices: [ CONNECT, DELETE, GET, HEAD, MERGE, OPTIONS, PATCH, POST, PUT, REFRESH, TRACE ]
     default: GET
   content_type:
     description:
@@ -43,16 +42,30 @@ options:
     description:
     - Password to use for authentication.
     version_added: '2.4'
+  force_basic_auth:
+    description:
+    - By default the authentication information is only sent when a webservice
+      responds to an initial request with a 401 status. Since some basic auth
+      services do not properly send a 401, logins will fail.
+    - This option forces the sending of the Basic authentication header upon
+      the initial request.
+    type: bool
+    default: 'no'
+    version_added: '2.5'
   dest:
     description:
     - Output the response body to a file.
     version_added: '2.3'
   headers:
     description:
-    - 'Key Value pairs for headers. Example "Host: www.somesite.com"'
+    - Extra headers to set on the request, see the examples for more details on
+      how to set this.
   use_basic_parsing:
     description:
-    - This module relies upon 'Invoke-WebRequest', which by default uses the Internet Explorer Engine to parse a webpage.
+    - As of Ansible 2.5, this option is no longer valid and cannot be changed from C(yes), this option will be removed
+      in Ansible 2.7.
+    - Before Ansible 2.5, this module relies upon 'Invoke-WebRequest', which by default uses the Internet Explorer Engine
+      to parse a webpage.
     - There's an edge-case where if a user hasn't run IE before, this will fail.
     - The only advantage to using the Internet Explorer praser is that you can traverse the DOM in a powershell script.
     - That isn't useful for Ansible, so by default we toggle 'UseBasicParsing'. However, you can toggle that off here.
@@ -120,8 +133,17 @@ options:
     version_added: '2.4'
   client_cert:
     description:
-    - Specifies the client certificate(.pfx)  that is used for a secure web request.
+    - Specifies the client certificate (.pfx) that is used for a secure web request.
+    - The WinRM connection must be authenticated with C(CredSSP) if the
+      certificate file is not password protected.
+    - Other authentication types can set I(client_cert_password) when the cert
+      is password protected.
     version_added: '2.4'
+  client_cert_password:
+    description:
+    - The password for the client certificate (.pfx) file that is used for a
+      secure web request.
+    version_added: '2.5'
 notes:
 - For non-Windows targets, use the M(uri) module instead.
 author:
@@ -161,26 +183,6 @@ url:
   returned: always
   type: string
   sample: https://www.ansible.com
-method:
-  description: The HTTP method used.
-  returned: always
-  type: string
-  sample: GET
-content_type:
-  description: The "content-type" header used.
-  returned: always
-  type: string
-  sample: application/json
-use_basic_parsing:
-  description: The state of the "use_basic_parsing" flag.
-  returned: always
-  type: bool
-  sample: True
-body:
-  description: The content of the body used
-  returned: when body is specified
-  type: string
-  sample: '{"id":1}'
 status_code:
   description: The HTTP Status Code of the response.
   returned: success
@@ -191,19 +193,19 @@ status_description:
   returned: success
   type: string
   sample: OK
-raw_content:
+content:
   description: The raw content of the HTTP response.
-  returned: success
+  returned: success and return_content is True
   type: string
-  sample: 'HTTP/1.1 200 OK\nX-XSS-Protection: 1; mode=block\nAlternate-Protocol: 443:quic,p=1\nAlt-Svc: quic="www.google.com:443";'
-headers:
-  description: The Headers of the response.
-  returned: success
-  type: dict
-  sample: {"Content-Type": "application/json"}
-raw_content_length:
+  sample: '{"foo": "bar"}'
+content_length:
   description: The byte size of the response.
   returned: success
   type: int
   sample: 54447
+json:
+  description: The json structure returned under content as a dictionary
+  returned: success and Content-Type is "application/json" or "application/javascript" and return_content is True
+  type: dict
+  sample: {"this-is-dependent": "on the actual return content"}
 '''
diff --git a/test/integration/targets/uri/files/testserver.ps1 b/test/integration/targets/uri/files/testserver.ps1
deleted file mode 100644
index 6df4bd05f9e..00000000000
--- a/test/integration/targets/uri/files/testserver.ps1
+++ /dev/null
@@ -1,36 +0,0 @@
-param (
-    [int]$port = 8000
-)
-
-$listener = New-Object Net.HttpListener
-$listener.Prefixes.Add("http://+:$port/")
-$listener.Start()
-
-try {
-    while ($listener.IsListening) {
-        # process received request
-        $context = $listener.GetContext()
-        $Request = $context.Request
-        $Response = $context.Response
-        #$Response.Headers.Add("Content-Type","text/plain")
-
-        $received = '{0} {1}' -f $Request.httpmethod, $Request.url.localpath
-
-        # is there HTML content for this URL?
-        $html = $htmlcontents[$received]
-        if ($html -eq $null) {
-            $Response.statuscode = 404
-            $html = 'Oops, the page is not available!'
-        }
-
-        # return the HTML to the caller
-        $buffer = [Text.Encoding]::UTF8.GetBytes($html)
-        $Response.ContentLength64 = $buffer.length
-        $Response.OutputStream.Write($buffer, 0, $buffer.length)
-
-        $Response.Close()
-    }
-} finally {
-    $listener.Stop()
-    $listener.Close()
-}
diff --git a/test/integration/targets/win_uri/aliases b/test/integration/targets/win_uri/aliases
new file mode 100644
index 00000000000..2854047d09b
--- /dev/null
+++ b/test/integration/targets/win_uri/aliases
@@ -0,0 +1 @@
+windows/ci/group4
diff --git a/test/integration/targets/win_uri/defaults/main.yml b/test/integration/targets/win_uri/defaults/main.yml
new file mode 100644
index 00000000000..1a02bcaafe7
--- /dev/null
+++ b/test/integration/targets/win_uri/defaults/main.yml
@@ -0,0 +1,3 @@
+---
+test_uri_path: C:\ansible\win_uri
+httpbin_host: httpbin.org
diff --git a/test/integration/targets/win_uri/tasks/main.yml b/test/integration/targets/win_uri/tasks/main.yml
new file mode 100644
index 00000000000..7105f5aa477
--- /dev/null
+++ b/test/integration/targets/win_uri/tasks/main.yml
@@ -0,0 +1,14 @@
+---
+- name: create test directory
+  win_file:
+    path: '{{test_uri_path}}'
+    state: directory
+
+- block:
+  - include_tasks: test.yml
+
+  always:
+  - name: cleanup test directory
+    win_file:
+      path: '{{test_uri_path}}'
+      state: absent
diff --git a/test/integration/targets/win_uri/tasks/test.yml b/test/integration/targets/win_uri/tasks/test.yml
new file mode 100644
index 00000000000..551dfeeb4bb
--- /dev/null
+++ b/test/integration/targets/win_uri/tasks/test.yml
@@ -0,0 +1,298 @@
+---
+# get with mismatch https
+# get with mismatch https and ignore validation
+
+- name: get request without return_content
+  win_uri:
+    url: http://{{httpbin_host}}/get
+    return_content: no
+  register: get_request_without_content
+
+- name: assert get request without return_content
+  assert:
+    that:
+    - not get_request_without_content.changed
+    - get_request_without_content.content is not defined
+    - get_request_without_content.json is not defined
+    - get_request_without_content.status_code == 200
+
+- name: get request with xml content
+  win_uri:
+    url: http://{{httpbin_host}}/xml
+    return_content: yes
+  register: get_request_with_xml_content
+
+- name: assert get request with xml content
+  assert:
+    that:
+    - not get_request_with_xml_content.changed
+    - get_request_with_xml_content.content is defined
+    - get_request_with_xml_content.json is not defined
+    - get_request_with_xml_content.status_code == 200
+
+- name: get request with binary content
+  win_uri:
+    url: http://{{httpbin_host}}/image/png
+    return_content: yes
+  register: get_request_with_binary_content
+
+- name: assert get request with binary content
+  assert:
+    that:
+    - not get_request_with_binary_content.changed
+    - get_request_with_binary_content.content is defined
+    - get_request_with_binary_content.json is not defined
+    - get_request_with_xml_content.status_code == 200
+
+- name: get request with return_content and dest (check mode)
+  win_uri:
+    url: http://{{httpbin_host}}/get
+    return_content: yes
+    dest: '{{test_uri_path}}\get.json'
+  register: get_request_with_dest_check
+  check_mode: yes
+
+- name: get stat of downloaded file (check mode)
+  win_stat:
+    path: '{{test_uri_path}}\get.json'
+  register: get_request_with_dest_actual_check
+
+- name: assert get request with return_content and dest (check mode)
+  assert:
+    that:
+    - get_request_with_dest_check.changed
+    - get_request_with_dest_check.content is defined
+    - get_request_with_dest_check.json is defined
+    - get_request_with_dest_actual_check.stat.exists == False
+
+- name: get request with return_content and dest
+  win_uri:
+    url: http://{{httpbin_host}}/get
+    return_content: yes
+    dest: '{{test_uri_path}}\get.json'
+  register: get_request_with_dest
+
+- name: get stat of downloaded file
+  win_stat:
+    path: '{{test_uri_path}}\get.json'
+    checksum_algorithm: sha1
+    get_checksum: yes
+  register: get_request_with_dest_actual
+
+- name: assert get request with return_content and dest
+  assert:
+    that:
+    - get_request_with_dest.changed
+    - get_request_with_dest.content is defined
+    - get_request_with_dest.json is defined
+    - get_request_with_dest_actual.stat.exists == True
+    - get_request_with_dest_actual.stat.checksum == get_request_with_dest.content|hash('sha1')
+
+- name: get request with return_content and dest (idempotent)
+  win_uri:
+    url: http://{{httpbin_host}}/get
+    return_content: yes
+    dest: '{{test_uri_path}}\get.json'
+  register: get_request_with_dest_again
+
+- name: assert get request with return_content and dest (idempotent)
+  assert:
+    that:
+    - not get_request_with_dest_again.changed
+
+- name: post request with return_content, dest and different content
+  win_uri:
+    url: http://{{httpbin_host}}/post
+    method: POST
+    content_type: application/json
+    body: '{"foo": "bar"}'
+    return_content: yes
+    dest: '{{test_uri_path}}\get.json'
+  register: post_request_with_different_content
+
+- name: get stat of downloaded file
+  win_stat:
+    path: '{{test_uri_path}}\get.json'
+    checksum_algorithm: sha1
+    get_checksum: yes
+  register: post_request_with_different_content_actual
+
+- name: assert post request with return_content, dest and different content
+  assert:
+    that:
+    - post_request_with_different_content.changed
+    - post_request_with_different_content_actual.stat.exists == True
+    - post_request_with_different_content_actual.stat.checksum == post_request_with_different_content.content|hash('sha1')
+
+- name: test redirect without follow_redirects
+  win_uri:
+    url: http://{{httpbin_host}}/redirect/2
+    follow_redirects: none
+    status_code: 302
+  register: redirect_without_follow
+
+- name: assert redirect without follow_redirects
+  assert:
+    that:
+    - not redirect_without_follow.changed
+    - redirect_without_follow.location|default("") == '/relative-redirect/1'
+    - redirect_without_follow.status_code == 302
+
+- name: test redirect with follow_redirects
+  win_uri:
+    url: http://{{httpbin_host}}/redirect/2
+    follow_redirects: all
+  register: redirect_with_follow
+
+- name: assert redirect with follow_redirects
+  assert:
+    that:
+    - not redirect_with_follow.changed
+    - redirect_with_follow.location is not defined
+    - redirect_with_follow.status_code == 200
+    - redirect_with_follow.response_uri == 'http://{{httpbin_host}}/get'
+
+- name: get request with redirect of TLS
+  win_uri:
+    url: https://{{httpbin_host}}/redirect/2
+    follow_redirects: all
+  register: redirect_with_follow_tls
+
+- name: assert redirect with redirect of TLS
+  assert:
+    that:
+    - not redirect_with_follow_tls.changed
+    - redirect_with_follow_tls.location is not defined
+    - redirect_with_follow_tls.status_code == 200
+    - redirect_with_follow_tls.response_uri == 'https://{{httpbin_host}}/get'
+
+- name: test basic auth
+  win_uri:
+    url: http://{{httpbin_host}}/basic-auth/user/passwd
+    user: user
+    password: passwd
+  register: basic_auth
+
+- name: assert test basic auth
+  assert:
+    that:
+    - not basic_auth.changed
+    - basic_auth.status_code == 200
+
+- name: test basic auth with force auth
+  win_uri:
+    url: http://{{httpbin_host}}/hidden-basic-auth/user/passwd
+    user: user
+    password: passwd
+    force_basic_auth: yes
+  register: basic_auth_forced
+
+- name: assert test basic auth with forced auth
+  assert:
+    that:
+    - not basic_auth_forced.changed
+    - basic_auth_forced.status_code == 200
+
+- name: test PUT
+  win_uri:
+    url: http://{{httpbin_host}}/put
+    method: PUT
+    body: foo=bar
+    return_content: yes
+  register: put_request
+
+- name: assert test PUT
+  assert:
+    that:
+    - not put_request.changed
+    - put_request.status_code == 200
+    - put_request.json.data == 'foo=bar'
+
+- name: test OPTIONS
+  win_uri:
+    url: http://{{httpbin_host}}/
+    method: OPTIONS
+  register: option_request
+
+- name: assert test OPTIONS
+  assert:
+    that:
+    - not option_request.changed
+    - option_request.status_code == 200
+    - 'option_request.allow.split(", ")|sort == ["GET", "HEAD", "OPTIONS"]'
+
+# SNI Tests
+
+- name: validate status_codes are correct
+  win_uri:
+    url: http://{{httpbin_host}}/status/202
+    status_code:
+    - 202
+    method: POST
+    body: foo
+  register: status_code_check
+
+- name: assert validate status_codes are correct
+  assert:
+    that:
+    - not status_code_check.changed
+    - status_code_check.status_code == 202
+
+- name: send JSON body with dict type
+  win_uri:
+    url: http://{{httpbin_host}}/post
+    method: POST
+    body:
+      foo: bar
+      list:
+      - 1
+      - 2
+      dict:
+        foo: bar
+    headers:
+      'Content-Type': 'text/json'
+    return_content: yes
+  register: json_as_dict
+
+- name: set fact of expected json dict
+  set_fact:
+    json_as_dict_value:
+      foo: bar
+      list:
+      - 1
+      - 2
+      dict:
+        foo: bar
+
+- name: assert send JSON body with dict type
+  assert:
+    that:
+    - not json_as_dict.changed
+    - json_as_dict.json.json == json_as_dict_value
+    - json_as_dict.status_code == 200
+
+- name: get request with custom headers
+  win_uri:
+    url: http://{{httpbin_host}}/get
+    headers:
+      Test-Header: hello
+      Another-Header: world
+    return_content: yes
+  register: get_custom_header
+
+- name: assert request with custom headers
+  assert:
+    that:
+    - not get_custom_header.changed
+    - get_custom_header.status_code == 200
+    - get_custom_header.json.headers['Test-Header'] == 'hello'
+    - get_custom_header.json.headers['Another-Header'] == 'world'
+
+# client cert auth tests
+
+- name: get request with timeout
+  win_uri:
+    url: http://{{httpbin_host}}/delay/10
+    timeout: 5
+  register: get_with_timeout_fail
+  failed_when: '"The operation has timed out" not in get_with_timeout_fail.msg'
diff --git a/test/sanity/pslint/ignore.txt b/test/sanity/pslint/ignore.txt
index ef66e4d6977..7f54725681b 100644
--- a/test/sanity/pslint/ignore.txt
+++ b/test/sanity/pslint/ignore.txt
@@ -160,7 +160,6 @@ lib/ansible/modules/windows/win_wakeonlan.ps1 PSAvoidUsingCmdletAliases
 lib/ansible/modules/windows/win_webpicmd.ps1 PSAvoidUsingInvokeExpression
 lib/ansible/modules/windows/win_webpicmd.ps1 PSPossibleIncorrectComparisonWithNull
 lib/ansible/modules/windows/win_webpicmd.ps1 PSUseOutputTypeCorrectly
-test/integration/targets/uri/files/testserver.ps1 PSPossibleIncorrectComparisonWithNull
 test/integration/targets/win_audit_rule/library/test_get_audit_rule.ps1 PSAvoidUsingCmdletAliases
 test/integration/targets/win_dsc/library/test_win_dsc_iis_info.ps1 PSPossibleIncorrectComparisonWithNull
 test/integration/targets/win_dsc/templates/ANSIBLE_xTestResource.psm1 PSAvoidDefaultValueForMandatoryParameter