diff --git a/.spelling b/.spelling index d23ed693e1..0b7489bf02 100644 --- a/.spelling +++ b/.spelling @@ -67,6 +67,7 @@ macOS Microsoft.PowerShell.Archive MS-PSRP myget +Multipart New-PSSessionOption New-PSTransportOption NuGet diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs index 750afa12c8..a730b464ee 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/CoreCLR/WebRequestPSCmdlet.CoreClr.cs @@ -389,6 +389,11 @@ namespace Microsoft.PowerShell.Commands byte[] bytes = content as byte[]; SetRequestContent(request, bytes); } + else if (content is MultipartFormDataContent multipartFormDataContent) + { + WebSession.ContentHeaders.Clear(); + SetRequestContent(request, multipartFormDataContent); + } else { SetRequestContent(request, @@ -787,6 +792,32 @@ namespace Microsoft.PowerShell.Commands return streamContent.Headers.ContentLength.Value; } + /// + /// Sets the ContentLength property of the request and writes the specified content to the request's RequestStream. + /// + /// The WebRequest who's content is to be set + /// A MultipartFormDataContent object containing multipart/form-data content. + /// The number of bytes written to the requests RequestStream (and the new value of the request's ContentLength property + /// + /// Because this function sets the request's ContentLength property and writes content data into the requests's stream, + /// it should be called one time maximum on a given request. + /// + internal long SetRequestContent(HttpRequestMessage request, MultipartFormDataContent multipartContent) + { + if (request == null) + { + throw new ArgumentNullException("request"); + } + if (multipartContent == null) + { + throw new ArgumentNullException("multipartContent"); + } + + request.Content = multipartContent; + + return multipartContent.Headers.ContentLength.Value; + } + internal long SetRequestContent(HttpRequestMessage request, IDictionary content) { if (request == null) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index 9ba437edb9..efc0007a0b 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -359,6 +359,39 @@ function ExecuteRestMethod return $result } +function GetMultipartBody +{ + param + ( + [Switch]$String, + [Switch]$File + ) + $multipartContent = [System.Net.Http.MultipartFormDataContent]::new() + if ($String.IsPresent) + { + $stringHeader = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new("form-data") + $stringHeader.Name = "TestString" + $StringContent = [System.Net.Http.StringContent]::new("TestValue") + $StringContent.Headers.ContentDisposition = $stringHeader + $multipartContent.Add($stringContent) + } + if ($File.IsPresent) + { + $multipartFile = Join-Path $TestDrive 'multipart.txt' + "TestContent" | Set-Content $multipartFile + $FileStream = [System.IO.FileStream]::new($multipartFile, [System.IO.FileMode]::Open) + $fileHeader = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new("form-data") + $fileHeader.Name = "TestFile" + $fileHeader.FileName = 'multipart.txt' + $fileContent = [System.Net.Http.StreamContent]::new($FileStream) + $fileContent.Headers.ContentDisposition = $fileHeader + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("text/plain") + $multipartContent.Add($fileContent) + } + # unary comma required to prevent $multipartContent from being unwrapped/enumerated + return ,$multipartContent +} + <# Defines the list of redirect codes to test as well as the expected Method when the redirection is handled. @@ -1200,6 +1233,41 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { } } + Context "Multipart/form-data Tests" { + It "Verifies Invoke-WebRequest Supports Multipart String Values" { + $body = GetMultipartBody -String + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Body $body -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestString[0] | Should Be 'TestValue' + } + It "Verifies Invoke-WebRequest Supports Multipart File Values" { + $body = GetMultipartBody -File + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Body $body -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Files[0].FileName | Should Be 'multipart.txt' + $result.Files[0].ContentType | Should Be 'text/plain' + $result.Files[0].Content | Should Match 'TestContent' + } + It "Verifies Invoke-WebRequest Supports Mixed Multipart String and File Values" { + $body = GetMultipartBody -String -File + $uri = Get-WebListenerUrl -Test 'Multipart' + $response = Invoke-WebRequest -Uri $uri -Body $body -Method 'POST' + $result = $response.Content | ConvertFrom-Json + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestString[0] | Should Be 'TestValue' + $result.Files[0].FileName | Should Be 'multipart.txt' + $result.Files[0].ContentType | Should Be 'text/plain' + $result.Files[0].Content | Should Match 'TestContent' + } + } + BeforeEach { if ($env:http_proxy) { $savedHttpProxy = $env:http_proxy @@ -1737,6 +1805,38 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { } } + Context "Multipart/form-data Tests" { + It "Verifies Invoke-RestMethod Supports Multipart String Values" { + $body = GetMultipartBody -String + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Body $body -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestString[0] | Should Be 'TestValue' + } + It "Verifies Invoke-RestMethod Supports Multipart File Values" { + $body = GetMultipartBody -File + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Body $body -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Files[0].FileName | Should Be 'multipart.txt' + $result.Files[0].ContentType | Should Be 'text/plain' + $result.Files[0].Content | Should Match 'TestContent' + } + It "Verifies Invoke-RestMethod Supports Mixed Multipart String and File Values" { + $body = GetMultipartBody -String -File + $uri = Get-WebListenerUrl -Test 'Multipart' + $result = Invoke-RestMethod -Uri $uri -Body $body -Method 'POST' + + $result.Headers.'Content-Type' | Should Match 'multipart/form-data' + $result.Items.TestString[0] | Should Be 'TestValue' + $result.Files[0].FileName | Should Be 'multipart.txt' + $result.Files[0].ContentType | Should Be 'text/plain' + $result.Files[0].Content | Should Match 'TestContent' + } + } + #region charset encoding tests Context "Invoke-RestMethod Encoding tests with BasicHtmlWebResponseObject response" { diff --git a/test/tools/Modules/WebListener/WebListener.psm1 b/test/tools/Modules/WebListener/WebListener.psm1 index 09f8b6836a..9e84396235 100644 --- a/test/tools/Modules/WebListener/WebListener.psm1 +++ b/test/tools/Modules/WebListener/WebListener.psm1 @@ -116,6 +116,7 @@ function Get-WebListenerUrl { 'Cert', 'Get', 'Home', + 'Multipart', '/' )] [String]$Test diff --git a/test/tools/WebListener/Controllers/MultipartController.cs b/test/tools/WebListener/Controllers/MultipartController.cs new file mode 100644 index 0000000000..c56c6e68c9 --- /dev/null +++ b/test/tools/WebListener/Controllers/MultipartController.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using mvc.Models; + + +namespace mvc.Controllers +{ + public class MultipartController : Controller + { + private IHostingEnvironment _environment; + + public MultipartController(IHostingEnvironment environment) + { + _environment = environment; + } + public ActionResult Index() + { + return View(); + } + + [HttpPost] + public JsonResult Index(IFormCollection collection) + { + if (!Request.HasFormContentType) + { + Response.StatusCode = 415; + Hashtable error = new Hashtable {{"error","Unsupported media type"}}; + return Json(error); + } + + List fileList = new List(); + foreach (var file in collection.Files) + { + string result = string.Empty; + if (file.Length > 0) + { + using (var reader = new StreamReader(file.OpenReadStream())) + { + result = reader.ReadToEnd(); + } + } + Hashtable fileHash = new Hashtable + { + {"ContentDisposition" , file.ContentDisposition}, + {"ContentType" , file.ContentType}, + {"FileName" , file.FileName}, + {"Length" , file.Length}, + {"Name" , file.Name}, + {"Content" , result}, + {"Headers" , file.Headers} + }; + fileList.Add(fileHash); + } + Hashtable itemsHash = new Hashtable(); + foreach (var key in collection.Keys) + { + itemsHash.Add(key,collection[key]); + } + MediaTypeHeaderValue mediaContentType = MediaTypeHeaderValue.Parse(Request.ContentType); + Hashtable headers = new Hashtable(); + foreach (var key in Request.Headers.Keys) + { + headers.Add(key, String.Join(Constants.HeaderSeparator, Request.Headers[key])); + } + Hashtable output = new Hashtable + { + {"Files" , fileList}, + {"Items" , itemsHash}, + {"Boundary", HeaderUtilities.RemoveQuotes(mediaContentType.Boundary).Value}, + {"Headers" , headers} + }; + return Json(output); + } + + public IActionResult Error() + { + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + } + } +} diff --git a/test/tools/WebListener/README.md b/test/tools/WebListener/README.md index 9db5f8dc07..edf9bc1872 100644 --- a/test/tools/WebListener/README.md +++ b/test/tools/WebListener/README.md @@ -82,3 +82,47 @@ Invoke-WebRequest -Uri 'http://localhost:8083/Get/' -Body @{TestField = 'TestVal "origin": "127.0.0.1" } ``` + +## /Multipart/ + +### GET +Provides an HTML form for `multipart/form-data` submission. + +### POST +Accepts a `multipart/form-data` submission and returns a JSON object containing information about the submission including the items and files submitted. + +```json +{ + "Files": [ + { + "ContentDisposition": "form-data; name=fileData; filename=test.txt", + "Headers": { + "Content-Disposition": [ + "form-data; name=fileData; filename=test.txt" + ], + "Content-Type": [ + "text/plain" + ] + }, + "FileName": "test.txt", + "Length": 15, + "ContentType": "text/plain", + "Name": "fileData", + "Content": "Test Contents\r\n" + } + ], + "Items": { + "stringData": [ + "TestValue" + ] + }, + "Boundary": "83027bde-fd9b-4ea0-b1ca-a1f661d01ada", + "Headers": { + "Content-Type": "multipart/form-data; boundary=\"83027bde-fd9b-4ea0-b1ca-a1f661d01ada\"", + "Connection": "Keep-Alive", + "Content-Length": "336", + "Host": "localhost:8083", + "User-Agent": "Mozilla/5.0 (Windows NT; Microsoft Windows 10.0.15063 ; en-US) WindowsPowerShell/6.0.0" + } +} +``` diff --git a/test/tools/WebListener/Views/Home/Index.cshtml b/test/tools/WebListener/Views/Home/Index.cshtml index 667c251276..3a13c61471 100644 --- a/test/tools/WebListener/Views/Home/Index.cshtml +++ b/test/tools/WebListener/Views/Home/Index.cshtml @@ -3,4 +3,5 @@
  • / - This page
  • /Cert/ - Client Certificate Details
  • /Get/ - Emulates functionality of https://httpbin.org/get by returning GET headers, Arguments, and Request URL
  • +
  • /Multipart/ - Multipart/form-data submission testing
  • diff --git a/test/tools/WebListener/Views/Multipart/Index.cshtml b/test/tools/WebListener/Views/Multipart/Index.cshtml new file mode 100644 index 0000000000..01c6f69016 --- /dev/null +++ b/test/tools/WebListener/Views/Multipart/Index.cshtml @@ -0,0 +1,13 @@ +
    +
    + + +
    +
    + + +
    +
    + +
    +