diff --git a/.azure-pipelines/generate-auth-module-template.yml b/.azure-pipelines/generate-auth-module-template.yml index e59b889e3ad..51c36bb6355 100644 --- a/.azure-pipelines/generate-auth-module-template.yml +++ b/.azure-pipelines/generate-auth-module-template.yml @@ -41,7 +41,7 @@ jobs: pwsh: true script: | Write-Host $(BUILDNUMBER) - pwsh $(System.DefaultWorkingDirectory)/tools/GenerateAuthenticationModule.ps1 -ArtifactsLocation $(Build.ArtifactStagingDirectory) -Build -ModulePreviewNumber $(BUILDNUMBER) + pwsh $(System.DefaultWorkingDirectory)/tools/GenerateAuthenticationModule.ps1 -ArtifactsLocation $(Build.ArtifactStagingDirectory) -Build -ModulePreviewNumber $(BUILDNUMBER) -Test - task: DotNetCoreCLI@2 displayName: 'Run: Enabled Tests' @@ -190,6 +190,16 @@ jobs: packagesToPush: '$(Build.ArtifactStagingDirectory)\$(AUTH_MODULE_NAME)\Microsoft.Graph.$(AUTH_MODULE_NAME)*.nupkg' publishVstsFeed: '0985d294-5762-4bc2-a565-161ef349ca3e/edc337b9-e5ea-49dd-a2cb-e8d66668ca57' allowPackageConflicts: true + + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/*-TestResults.xml' + + - task: PublishCodeCoverageResults@1 + inputs: + #codeCoverageTool: 'JaCoCo' # Options: cobertura, jaCoCo + summaryFileLocation: "**/coverage.xml" - task: PublishBuildArtifacts@1 displayName: Publish Artifact Microsoft.Graph.Authentication.nupkg' diff --git a/.azure-pipelines/integrated-pipeline.yml b/.azure-pipelines/integrated-pipeline.yml index 60e5e82eb2f..2aa5247707b 100644 --- a/.azure-pipelines/integrated-pipeline.yml +++ b/.azure-pipelines/integrated-pipeline.yml @@ -47,6 +47,31 @@ stages: Write-Host $(BUILDNUMBER) printenv +- stage: GetSecrets + displayName: 'Get Secrets From Azure KeyVault' + jobs: + - job: GetSecrets + steps: + - task: AzureKeyVault@1 + inputs: + azureSubscription: 'Microsoft Graph Build Agents' + KeyVaultName: 'msgraph-build-vault' + SecretsFilter: '*' + RunAsPreJob: true + + - task: PowerShell@2 + displayName: 'Install Test Certificate' + inputs: + targetType: 'inline' + script: | + $kvSecretBytes = [System.Convert]::FromBase64String('$(MsGraphPSSDKCertificate)') + $certCollection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection + $certCollection.Import($kvSecretBytes,$null,[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable) + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store("My", "CurrentUser") + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + $store.AddRange($certCollection) + $store.Close() + - stage: SecurityPreChecks displayName: 'Security Pre Checks' jobs: diff --git a/.gitignore b/.gitignore index 22b230cd474..352ec93beaf 100644 --- a/.gitignore +++ b/.gitignore @@ -353,4 +353,7 @@ MigrationBackup/ .ionide/ # Visual Studio Code -.vscode/ \ No newline at end of file +.vscode/ + +#Custom Environment Files +localenv.json \ No newline at end of file diff --git a/Nuget.config b/Nuget.config index 7dcddecd56f..56446b4eef8 100644 --- a/Nuget.config +++ b/Nuget.config @@ -2,6 +2,7 @@ + diff --git a/src/Authentication/Authentication.Test/Helpers/StringUtilTests.cs b/src/Authentication/Authentication.Test/Helpers/StringUtilTests.cs index d9a2fbb6587..b1a0f6acff9 100644 --- a/src/Authentication/Authentication.Test/Helpers/StringUtilTests.cs +++ b/src/Authentication/Authentication.Test/Helpers/StringUtilTests.cs @@ -1,37 +1,55 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Text; +namespace Microsoft.Graph.Authentication.Test.Helpers +{ -using Microsoft.Graph.PowerShell.Authentication.Helpers; + using System.Collections; + using System.Collections.Generic; + using System.Management.Automation; -using Xunit; + using Microsoft.Graph.PowerShell.Authentication.Helpers; + + using Xunit; -namespace Microsoft.Graph.Authentication.Test.Helpers -{ public class StringUtilTests { private const string TestJsonArray = "{\"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#users\"," + "\"value\": [{\"id\": \"6e7b768e-07e2-4810-8459-485f84f8f204\"},{\"id\": \"87d349ed-44d7-43e1-9a83-5f2406dee5bd\"}]}"; private const string TestJsonObject = "{\"@odata.context\": \"https://graph.microsoft.com/v1.0/$metadata#users/$entity\",\"businessPhones\": [\"+1 412 555 0109\"],\"displayName\": \"Megan Bowen\",\"givenName\": \"Megan\",\"jobTitle\": \"Auditor\",\"mail\": \"MeganB@M365x214355.onmicrosoft.com\", \"mobilePhone\": null, \"officeLocation\": \"12/1110\",\"preferredLanguage\": \"en-US\",\"surname\": \"Bowen\",\"userPrincipalName\": \"MeganB@M365x214355.onmicrosoft.com\",\"id\": \"48d31887-5fad-4d73-a9f5-3c356e68a038\"}"; + public static IEnumerable TestJsonData => + new List + { + new object[] { TestJsonArray}, + new object[] { TestJsonObject} + }; [Theory] [MemberData(nameof(TestJsonData))] public void ShouldReturnCaseInsensitiveDictionaries(string jsonString) { - Exception ex = null; - var converted = jsonString.TryConvertToJson(out var jsonObj, ref ex); + var hashTable = jsonString.ConvertFromJson(true, null, out _); - Assert.True(converted); - Assert.IsType(jsonObj); - var jsonHashTable = (Hashtable)jsonObj; + Assert.NotNull(hashTable); + var jsonHashTable = (Hashtable)hashTable; Assert.Equal(jsonHashTable["Value"], jsonHashTable["value"]); } - public static IEnumerable TestJsonData => - new List - { - new object[] { TestJsonArray}, - new object[] { TestJsonObject} - }; + + [Theory] + [MemberData(nameof(TestJsonData))] + public void ShouldReturnPsObject(string jsonString) + { + var psObject = jsonString.ConvertFromJson(false, null, out _); + + Assert.NotNull(psObject); + Assert.IsType(psObject); + } + + [Theory] + [MemberData(nameof(TestJsonData))] + public void ShouldReturnHashTable(string jsonString) + { + var hashTable = jsonString.ConvertFromJson(true, null, out _); + + Assert.NotNull(hashTable); + Assert.IsType(hashTable); + } } } diff --git a/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj b/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj index 584dadcf016..05c368f3897 100644 --- a/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj +++ b/src/Authentication/Authentication.Test/Microsoft.Graph.Authentication.Test.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs b/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs index f13fb8edd4d..8ef56c42cbf 100644 --- a/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs +++ b/src/Authentication/Authentication/Cmdlets/InvokeMgGraphRequest.cs @@ -209,6 +209,15 @@ public InvokeMgGraphRequest() HelpMessage = "Custom User Specified User Agent")] public string UserAgent { get; set; } + /// + /// OutputType to return to the caller, Defaults to HashTable + /// + [Parameter(Mandatory = false, + Position = 19, + ParameterSetName = Constants.UserParameterSet, + HelpMessage = "Output Type to return to the caller")] + public OutputType OutputType { get; set; } = OutputType.HashTable; + /// /// Wait for .NET debugger to attach /// @@ -246,7 +255,7 @@ private static async Task GenerateHttpErrorRecordAsync( Resources.ResponseStatusCodeFailure.FormatCurrentCulture(currentResponse.StatusCode, currentResponse.ReasonPhrase); var httpException = new HttpResponseException(errorMessage, currentResponse); - var errorRecord = new ErrorRecord(httpException, Errors.InvokeGraphHttpResponseException, + var errorRecord = new ErrorRecord(httpException, ErrorConstants.Codes.InvokeGraphHttpResponseException, ErrorCategory.InvalidOperation, httpRequestMessage); var detailMsg = await httpResponseMessageFormatter.ReadAsStringAsync(); if (!string.IsNullOrEmpty(detailMsg)) @@ -416,60 +425,67 @@ private Uri PrepareUri(HttpClient httpClient, Uri uri) return uri; } + private void ThrowIfError(ErrorRecord error) + { + if (error != null) + { + ThrowTerminatingError(error); + } + } /// /// Process Http Response /// /// - internal void ProcessResponse(HttpResponseMessage response) + internal async Task ProcessResponseAsync(HttpResponseMessage response) { if (response == null) throw new ArgumentNullException(nameof(response)); - - var baseResponseStream = response.GetResponseStream(); - if (ShouldWriteToPipeline) { - using (var responseStream = new BufferingStreamReader(baseResponseStream)) + var returnType = response.CheckReturnType(); + if (returnType == RestReturnType.Json) { - // determine the response type - var returnType = response.CheckReturnType(); - // Try to get the response encoding from the ContentType header. - Encoding encoding = null; - var charSet = response.Content.Headers.ContentType?.CharSet; - if (!string.IsNullOrEmpty(charSet)) - { - charSet.TryGetEncoding(out encoding); - } - - if (string.IsNullOrEmpty(charSet) && returnType == RestReturnType.Json) - { - encoding = Encoding.UTF8; - } - - Exception ex = null; - - var str = responseStream.DecodeStream(ref encoding); - - string encodingVerboseName; - try - { - encodingVerboseName = string.IsNullOrEmpty(encoding.HeaderName) - ? encoding.EncodingName - : encoding.HeaderName; - } - catch (NotSupportedException) + var responseString = await response.Content.ReadAsStringAsync(); + ErrorRecord error; + switch (OutputType) { - encodingVerboseName = encoding.EncodingName; + case OutputType.HashTable: + var hashTable = responseString.ConvertFromJson(true, null, out error); + ThrowIfError(error); + WriteObject(hashTable); + break; + case OutputType.PSObject: + var psObject = responseString.ConvertFromJson(false, null, out error); + ThrowIfError(error); + WriteObject(psObject, true); + break; + case OutputType.HttpResponseMessage: + WriteObject(response); + break; + case OutputType.Json: + WriteObject(responseString); + break; + default: + throw new ArgumentOutOfRangeException(); } - - // NOTE: Tests use this verbose output to verify the encoding. - WriteVerbose(Resources.ContentEncodingVerboseMessage.FormatCurrentCulture(encodingVerboseName)); - WriteObject(str.TryConvertToJson(out var obj, ref ex) ? obj : str); + } + else if (returnType == RestReturnType.Image) + { + var errorRecord = + GetValidationError(Resources.NonJsonResponseWithoutOutputFilePath, + ErrorConstants.Codes.InvokeGraphContentTypeException, returnType); + ThrowIfError(errorRecord); + } + else if (returnType == RestReturnType.OctetStream) + { + var errorRecord = + GetValidationError(Resources.NonJsonResponseWithoutInfer, + ErrorConstants.Codes.InvokeGraphContentTypeException, returnType, response.Content.Headers.ContentDisposition); + ThrowIfError(errorRecord); } } - if (ShouldSaveToOutFile) { - baseResponseStream.SaveStreamToFile(QualifiedOutFile, this, _cancellationTokenSource.Token); + response.GetResponseStream().SaveStreamToFile(QualifiedOutFile, this, _cancellationTokenSource.Token); } if (InferOutputFileName.IsPresent) @@ -478,16 +494,28 @@ internal void ProcessResponse(HttpResponseMessage response) { if (!string.IsNullOrWhiteSpace(response.Content.Headers.ContentDisposition.FileName)) { - var fileName = response.Content.Headers.ContentDisposition.FileNameStar; - var fullFileName = QualifyFilePath(fileName); - WriteVerbose( - Resources.InferredFileNameVerboseMessage.FormatCurrentCulture(fileName, fullFileName)); - baseResponseStream.SaveStreamToFile(fullFileName, this, _cancellationTokenSource.Token); + var fileName = response.Content.Headers.ContentDisposition.FileNameStar ?? response.Content.Headers.ContentDisposition.FileName; + if (!string.IsNullOrWhiteSpace(fileName)) + { + var sanitizedFileName = SanitizeFileName(fileName); + var fullFileName = QualifyFilePath(sanitizedFileName); + WriteVerbose( + Resources.InferredFileNameVerboseMessage.FormatCurrentCulture(fileName, fullFileName)); + response.GetResponseStream().SaveStreamToFile(fullFileName, this, _cancellationTokenSource.Token); + } + else + { + var errorRecord = GetValidationError(Resources.InferredFileNameIncorrect, + ErrorConstants.Codes.InvokeGraphRequestCouldNotInferFileName, fileName); + WriteError(errorRecord); + } } } else { - WriteVerbose(Resources.InferredFileNameErrorMessage); + var errorRecord = GetValidationError(Resources.InferredFileNameErrorMessage, + ErrorConstants.Codes.InvokeGraphRequestCouldNotInferFileName); + WriteError(errorRecord); } } @@ -504,6 +532,17 @@ internal void ProcessResponse(HttpResponseMessage response) } } + /// + /// When Inferring file names from Content disposition, ensure that + /// only valid path characters are in the file name + /// + /// + /// + private static string SanitizeFileName(string fileName) + { + var illegalCharacters = Path.GetInvalidFileNameChars().Concat(Path.GetInvalidPathChars()).ToArray(); + return string.Concat(fileName.Split(illegalCharacters)); + } /// /// Gets a Custom AuthProvider or configured default provided depending on Auth Scheme specified. @@ -629,7 +668,7 @@ private long SetRequestContent(HttpRequestMessage request, string content) if (!SkipHeaderValidation) { var outerEx = new ValidationMetadataException(Resources.ContentTypeExceptionErrorMessage, ex); - var er = new ErrorRecord(outerEx, Errors.InvokeGraphContentTypeException, + var er = new ErrorRecord(outerEx, ErrorConstants.Codes.InvokeGraphContentTypeException, ErrorCategory.InvalidArgument, ContentType); ThrowTerminatingError(er); } @@ -639,7 +678,7 @@ private long SetRequestContent(HttpRequestMessage request, string content) if (!SkipHeaderValidation) { var outerEx = new ValidationMetadataException(Resources.ContentTypeExceptionErrorMessage, ex); - var er = new ErrorRecord(outerEx, Errors.InvokeGraphContentTypeException, + var er = new ErrorRecord(outerEx, ErrorConstants.Codes.InvokeGraphContentTypeException, ErrorCategory.InvalidArgument, ContentType); ThrowTerminatingError(er); } @@ -748,7 +787,7 @@ private void FillRequestStream(HttpRequestMessage request) { var outerEx = new ValidationMetadataException(Resources.ContentTypeExceptionErrorMessage, ex); - var er = new ErrorRecord(outerEx, Errors.InvokeGraphContentTypeException, + var er = new ErrorRecord(outerEx, ErrorConstants.Codes.InvokeGraphContentTypeException, ErrorCategory.InvalidArgument, ContentType); ThrowTerminatingError(er); } @@ -864,14 +903,13 @@ internal virtual void PrepareSession() /// /// Validate the Request Uri must have the same Host as GraphHttpClient BaseAddress. /// - /// - private void ValidateRequestUri(HttpClient httpClient) + private void ValidateRequestUri() { if (Uri == null) { var error = GetValidationError( Resources.InvokeGraphRequestMissingUriErrorMessage, - Errors.InvokeGraphRequestInvalidHost, + ErrorConstants.Codes.InvokeGraphRequestInvalidHost, nameof(Uri)); ThrowTerminatingError(error); } @@ -880,7 +918,7 @@ private void ValidateRequestUri(HttpClient httpClient) { var error = GetValidationError( Resources.InvokeGraphRequestInvalidUriErrorMessage, - Errors.InvokeGraphRequestInvalidHost, + ErrorConstants.Codes.InvokeGraphRequestInvalidHost, nameof(Uri)); ThrowTerminatingError(error); } @@ -895,7 +933,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.GraphRequestSessionConflict, - Errors.InvokeGraphRequestSessionConflictException); + ErrorConstants.Codes.InvokeGraphRequestSessionConflictException); ThrowTerminatingError(error); } @@ -905,7 +943,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.BodyMissingWhenMethodIsSpecified, - Errors.InvokeGraphRequestBodyMissingWhenMethodIsSpecified, + ErrorConstants.Codes.InvokeGraphRequestBodyMissingWhenMethodIsSpecified, nameof(Body), Method); ThrowTerminatingError(error); } @@ -914,7 +952,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.PassThruWithOutputFilePathMissing, - Errors.InvokeGraphRequestOutFileMissingException, + ErrorConstants.Codes.InvokeGraphRequestOutFileMissingException, nameof(PassThru), nameof(OutputFilePath)); ThrowTerminatingError(error); } @@ -923,7 +961,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.AuthenticationTokenConflict, - Errors.InvokeGraphRequestAuthenticationTokenConflictException, + ErrorConstants.Codes.InvokeGraphRequestAuthenticationTokenConflictException, Authentication, nameof(Token)); ThrowTerminatingError(error); } @@ -932,7 +970,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.NotConnectedToGraphException, - Errors.InvokeGraphRequestAuthenticationTokenConflictException, + ErrorConstants.Codes.InvokeGraphRequestAuthenticationTokenConflictException, Authentication, nameof(Token)); ThrowTerminatingError(error); } @@ -942,7 +980,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.AuthenticationCredentialNotSupplied, - Errors.InvokeGraphRequestAuthenticationTokenConflictException, + ErrorConstants.Codes.InvokeGraphRequestAuthenticationTokenConflictException, Authentication, nameof(Token)); ThrowTerminatingError(error); } @@ -952,7 +990,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.BodyConflict, - Errors.InvokeGraphRequestBodyConflictException, + ErrorConstants.Codes.InvokeGraphRequestBodyConflictException, nameof(Body), nameof(InputFilePath)); ThrowTerminatingError(error); } @@ -961,7 +999,7 @@ private void ValidateParameters() { var error = GetValidationError( Resources.InferFileNameOutFilePathConflict, - Errors.InvokeGraphRequestBodyConflictException, + ErrorConstants.Codes.InvokeGraphRequestBodyConflictException, nameof(InferOutputFileName), nameof(OutputFilePath)); ThrowTerminatingError(error); } @@ -979,7 +1017,7 @@ private void ValidateParameters() { errorRecord = GetValidationError( Resources.NotFileSystemPath, - Errors.InvokeGraphRequestFileNotFilesystemPathException, + ErrorConstants.Codes.InvokeGraphRequestFileNotFilesystemPathException, InputFilePath); } else @@ -988,13 +1026,13 @@ private void ValidateParameters() { errorRecord = GetValidationError( Resources.MultiplePathsResolved, - Errors.InvokeGraphRequestInputFileMultiplePathsResolvedException, InputFilePath); + ErrorConstants.Codes.InvokeGraphRequestInputFileMultiplePathsResolvedException, InputFilePath); } else if (providerPaths.Count == 0) { errorRecord = GetValidationError( Resources.NoPathResolved, - Errors.InvokeGraphRequestInputFileNoPathResolvedException, InputFilePath); + ErrorConstants.Codes.InvokeGraphRequestInputFileNoPathResolvedException, InputFilePath); } else { @@ -1002,7 +1040,7 @@ private void ValidateParameters() { errorRecord = GetValidationError( Resources.DirectoryPathSpecified, - Errors.InvokeGraphRequestInputFileNotFilePathException, InputFilePath); + ErrorConstants.Codes.InvokeGraphRequestInputFileNotFilePathException, InputFilePath); } _originalFilePath = InputFilePath; @@ -1097,41 +1135,39 @@ private async Task ProcessRecordAsync() PrepareSession(); using (var client = GetHttpClient()) { - ValidateRequestUri(client); + ValidateRequestUri(); using (var httpRequestMessage = GetRequest(client, Uri)) { - using (var httpRequestMessageFormatter = new HttpMessageFormatter(httpRequestMessage)) + var httpRequestMessageFormatter = new HttpMessageFormatter(httpRequestMessage); + + FillRequestStream(httpRequestMessage); + try { - FillRequestStream(httpRequestMessage); - try + await ReportRequestStatusAsync(httpRequestMessageFormatter); + var httpResponseMessage = await GetResponseAsync(client, httpRequestMessage); + var httpResponseMessageFormatter = new HttpMessageFormatter(httpResponseMessage); + await ReportResponseStatusASync(httpResponseMessageFormatter); + var isSuccess = httpResponseMessage.IsSuccessStatusCode; + if (ShouldCheckHttpStatus && !isSuccess) { - await ReportRequestStatusAsync(httpRequestMessageFormatter); - var httpResponseMessage = await GetResponseAsync(client, httpRequestMessage); - using (var httpResponseMessageFormatter = new HttpMessageFormatter(httpResponseMessage)) - { - await ReportResponseStatusASync(httpResponseMessageFormatter); - var isSuccess = httpResponseMessage.IsSuccessStatusCode; - if (ShouldCheckHttpStatus && !isSuccess) - { - var httpErrorRecord = await GenerateHttpErrorRecordAsync(httpResponseMessageFormatter, httpRequestMessage); - ThrowTerminatingError(httpErrorRecord); - } - - ProcessResponse(httpResponseMessage); - } + var httpErrorRecord = await GenerateHttpErrorRecordAsync(httpResponseMessageFormatter, httpRequestMessage); + ThrowTerminatingError(httpErrorRecord); } - catch (HttpRequestException ex) + + await ProcessResponseAsync(httpResponseMessage); + + } + catch (HttpRequestException ex) + { + var er = new ErrorRecord(ex, ErrorConstants.Codes.InvokeGraphHttpResponseException, + ErrorCategory.InvalidOperation, + httpRequestMessage); + if (ex.InnerException != null) { - var er = new ErrorRecord(ex, Errors.InvokeGraphHttpResponseException, - ErrorCategory.InvalidOperation, - httpRequestMessage); - if (ex.InnerException != null) - { - er.ErrorDetails = new ErrorDetails(ex.InnerException.Message); - } - - ThrowTerminatingError(er); + er.ErrorDetails = new ErrorDetails(ex.InnerException.Message); } + + ThrowTerminatingError(er); } } } diff --git a/src/Authentication/Authentication/ErrorConstants.cs b/src/Authentication/Authentication/ErrorConstants.cs index e0212b8aa2b..783bc3e8cbf 100644 --- a/src/Authentication/Authentication/ErrorConstants.cs +++ b/src/Authentication/Authentication/ErrorConstants.cs @@ -15,6 +15,24 @@ internal static class Codes internal const string SessionLockWriteDisposed = "sessionLockWriteDisposed"; internal const string SessionLockWriteRecursion = "sessionLockWriteRecursion"; internal const string InvalidJWT = "invalidJWT"; + internal const string InvokeGraphHttpResponseException = nameof(InvokeGraphHttpResponseException); + internal const string InvokeGraphContentTypeException = nameof(InvokeGraphContentTypeException); + internal const string InvokeGraphRequestInvalidHost = nameof(InvokeGraphRequestInvalidHost); + internal const string InvokeGraphRequestSessionConflictException = nameof(InvokeGraphRequestSessionConflictException); + internal const string InvokeGraphRequestBodyMissingWhenMethodIsSpecified = nameof(InvokeGraphRequestBodyMissingWhenMethodIsSpecified); + internal const string InvokeGraphRequestOutFileMissingException = nameof(InvokeGraphRequestOutFileMissingException); + internal const string InvokeGraphRequestAuthenticationTokenConflictException = nameof(InvokeGraphRequestAuthenticationTokenConflictException); + internal const string InvokeGraphRequestAuthenticationCredentialNotSuppliedException = nameof(InvokeGraphRequestAuthenticationCredentialNotSuppliedException); + internal const string InvokeGraphRequestBodyConflictException = nameof(InvokeGraphRequestBodyConflictException); + internal const string InvokeGraphRequestFileNotFilesystemPathException = nameof(InvokeGraphRequestFileNotFilesystemPathException); + internal const string InvokeGraphRequestInputFileMultiplePathsResolvedException = nameof(InvokeGraphRequestInputFileMultiplePathsResolvedException); + internal const string InvokeGraphRequestInputFileNoPathResolvedException = nameof(InvokeGraphRequestInputFileNoPathResolvedException); + internal const string InvokeGraphRequestInputFileNotFilePathException = nameof(InvokeGraphRequestInputFileNotFilePathException); + internal const string InvokeGraphRequestMissingAuthenticationContext = nameof(InvokeGraphRequestMissingAuthenticationContext); + internal const string InvokeGraphRequestEmptyKeyInJsonString = nameof(InvokeGraphRequestEmptyKeyInJsonString); + internal const string InvokeGraphRequestDuplicateKeysInJsonString = nameof(InvokeGraphRequestDuplicateKeysInJsonString); + internal const string InvokeGraphRequestKeysWithDifferentCasingInJsonString = nameof(InvokeGraphRequestKeysWithDifferentCasingInJsonString); + public const string InvokeGraphRequestCouldNotInferFileName = nameof(InvokeGraphRequestKeysWithDifferentCasingInJsonString); } internal static class Message diff --git a/src/Authentication/Authentication/Helpers/ContentHelper.cs b/src/Authentication/Authentication/Helpers/ContentHelper.cs index a35885bed8e..f69a6836541 100644 --- a/src/Authentication/Authentication/Helpers/ContentHelper.cs +++ b/src/Authentication/Authentication/Helpers/ContentHelper.cs @@ -4,9 +4,7 @@ using System; using System.Globalization; -using System.Management.Automation; using System.Net.Http; -using System.Net.Http.Headers; using System.Text; using Microsoft.Graph.PowerShell.Authentication.Models; @@ -32,13 +30,65 @@ internal static RestReturnType CheckReturnType(this HttpResponseMessage response var contentType = response.GetContentType(); if (string.IsNullOrEmpty(contentType)) rt = RestReturnType.Detect; - else if (ContentHelper.IsJson(contentType)) + else if (IsJson(contentType)) rt = RestReturnType.Json; - else if (ContentHelper.IsXml(contentType)) + else if (IsXml(contentType)) rt = RestReturnType.Xml; - + else if (IsImage(contentType)) + rt = RestReturnType.Image; + else if (IsOctetStream(contentType)) + rt = RestReturnType.OctetStream; return rt; } + + private static bool IsImage(string contentType) + { + contentType = GetContentTypeSignature(contentType); + return CheckIsImage(contentType); + } + /// + /// Check that the current content-type is a valid image + /// + /// + /// + private static bool CheckIsImage(string contentType) + { + var isImage = false; + if (string.IsNullOrEmpty(contentType)) + return false; + switch (contentType.ToLower(CultureInfo.InvariantCulture)) + { + case "image/apng": + case "image/avif": + case "image/gif": + case "image/jpeg": + case "image/png": + isImage = true; + break; + } + return isImage; + } + + private static bool IsOctetStream(string contentType) + { + contentType = GetContentTypeSignature(contentType); + return CheckIsOctetStream(contentType); + } + + private static bool CheckIsOctetStream(string contentType) + { + var isOctetStream = false; + if (string.IsNullOrEmpty(contentType)) + return false; + switch (contentType.ToLower(CultureInfo.InvariantCulture)) + { + case "application/octet-stream": + isOctetStream = true; + break; + } + return isOctetStream; + } + // used to split contentType arguments private static readonly char[] ContentTypeParamSeparator = { ';' }; diff --git a/src/Authentication/Authentication/Helpers/Errors.cs b/src/Authentication/Authentication/Helpers/Errors.cs deleted file mode 100644 index 1014656293a..00000000000 --- a/src/Authentication/Authentication/Helpers/Errors.cs +++ /dev/null @@ -1,24 +0,0 @@ -// ------------------------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. -// ------------------------------------------------------------------------------ - -namespace Microsoft.Graph.PowerShell.Authentication.Helpers -{ - public static class Errors - { - public const string InvokeGraphHttpResponseException = nameof(InvokeGraphHttpResponseException); - public const string InvokeGraphContentTypeException = nameof(InvokeGraphContentTypeException); - public const string InvokeGraphRequestInvalidHost = nameof(InvokeGraphRequestInvalidHost); - public const string InvokeGraphRequestSessionConflictException = nameof(InvokeGraphRequestSessionConflictException); - public const string InvokeGraphRequestBodyMissingWhenMethodIsSpecified = nameof(InvokeGraphRequestBodyMissingWhenMethodIsSpecified); - public const string InvokeGraphRequestOutFileMissingException = nameof(InvokeGraphRequestOutFileMissingException); - public const string InvokeGraphRequestAuthenticationTokenConflictException = nameof(InvokeGraphRequestAuthenticationTokenConflictException); - public const string InvokeGraphRequestAuthenticationCredentialNotSuppliedException = nameof(InvokeGraphRequestAuthenticationCredentialNotSuppliedException); - public const string InvokeGraphRequestBodyConflictException = nameof(InvokeGraphRequestBodyConflictException); - public const string InvokeGraphRequestFileNotFilesystemPathException = nameof(InvokeGraphRequestFileNotFilesystemPathException); - public const string InvokeGraphRequestInputFileMultiplePathsResolvedException = nameof(InvokeGraphRequestInputFileMultiplePathsResolvedException); - public const string InvokeGraphRequestInputFileNoPathResolvedException = nameof(InvokeGraphRequestInputFileNoPathResolvedException); - public const string InvokeGraphRequestInputFileNotFilePathException = nameof(InvokeGraphRequestInputFileNotFilePathException); - public const string InvokeGraphRequestMissingAuthenticationContext = nameof(InvokeGraphRequestMissingAuthenticationContext); - } -} \ No newline at end of file diff --git a/src/Authentication/Authentication/Helpers/HttpMessageFormatter.cs b/src/Authentication/Authentication/Helpers/HttpMessageFormatter.cs index 07d926c5a5a..dad4ba322f8 100644 --- a/src/Authentication/Authentication/Helpers/HttpMessageFormatter.cs +++ b/src/Authentication/Authentication/Helpers/HttpMessageFormatter.cs @@ -182,10 +182,7 @@ protected override async Task SerializeToStreamAsync(Stream stream, TransportCon { var readStream = await _streamTask.Value; ValidateStreamForReading(readStream); - if (!_contentConsumed) - { - await Content.CopyToAsync(stream); - } + await Content.CopyToAsync(stream); } } diff --git a/src/Authentication/Authentication/Helpers/StringUtil.cs b/src/Authentication/Authentication/Helpers/StringUtil.cs index d409a97f1c5..f0567e96ba5 100644 --- a/src/Authentication/Authentication/Helpers/StringUtil.cs +++ b/src/Authentication/Authentication/Helpers/StringUtil.cs @@ -6,10 +6,14 @@ using System.Collections; using System.Collections.Generic; using System.Globalization; +using System.Linq; +using System.Management.Automation; using System.Net; using System.Text; using System.Text.RegularExpressions; + using Microsoft.Graph.PowerShell.Authentication.Properties; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -65,53 +69,31 @@ internal static string FormatDictionary(this IDictionary content) } /// - /// Convert json string to object. + /// Convert a JSON string back to an object of type or + /// depending on parameter . /// - /// - /// - /// - /// - /// - internal static bool TryConvertToJson(this string jsonString, out object obj, ref Exception exRef) - { - var converted = false; - try - { - obj = jsonString.ConvertFromJson(exception: out exRef); - if (obj == null) - { - JToken.Parse(jsonString); - } - else - { - converted = true; - } - } - catch (JsonException ex) - { - var msg = Resources.JsonSerializationFailed.FormatCurrentCulture(ex.Message); - exRef = new ArgumentException(msg, ex); - obj = default; - } - catch (Exception jsonParseException) - { - exRef = jsonParseException; - obj = default; - } - - return converted; - } - - internal static object ConvertFromJson(this string jsonString, out Exception exception, int maxDepth = 1024) + /// The JSON text to convert. + /// True if the result should be returned as a + /// instead of a . + /// The max depth allowed when deserializing the json jsonString. Set to null for no maximum. + /// An error record if the conversion failed. + /// A or a + /// if the parameter is true. + internal static object ConvertFromJson(this string jsonString, bool returnHashtable, int? maxDepth, out ErrorRecord error) { if (jsonString == null) { throw new ArgumentNullException(nameof(jsonString)); } - exception = null; + + error = null; try { - // If input starts with '[' (ignoring white spaces). + // JsonConvert.DeserializeObject does not throw an exception when an invalid Json array is passed. + // This issue is being tracked by https://github.com/JamesNK/Newtonsoft.Json/issues/1930. + // To work around this, we need to identify when jsonString is a Json array, and then try to parse it via JArray.Parse(). + + // If jsonString starts with '[' (ignoring white spaces). if (Regex.Match(jsonString, @"^\s*\[").Success) { // JArray.Parse() will throw a JsonException if the array is invalid. @@ -122,9 +104,12 @@ internal static object ConvertFromJson(this string jsonString, out Exception exc // Please note that if the Json array is valid, we don't do anything, // we just continue the deserialization. } - var obj = JsonConvert.DeserializeObject(jsonString, + + var obj = JsonConvert.DeserializeObject( + jsonString, new JsonSerializerSettings { + // This TypeNameHandling setting is required to be secure. TypeNameHandling = TypeNameHandling.None, MetadataPropertyHandling = MetadataPropertyHandling.Ignore, MaxDepth = maxDepth @@ -134,23 +119,177 @@ internal static object ConvertFromJson(this string jsonString, out Exception exc { case JObject dictionary: // JObject is a IDictionary - return PopulateHashTableFromJDictionary(dictionary, out exception); + /* Note: Do not use Ternary operator as HashTable is implicitly convertible to PsObject, thus the ternary operation below, always returns a PSObject. + * return returnHashtable ? PopulateHashTableFromJDictionary(dictionary, out error) : PopulateFromJDictionary(dictionary, new DuplicateMemberHashSet(), out error); + * https://github.com/PowerShell/PowerShell/blob/73f852da4252eabe4097ab48a7b67c5d147a01f3/src/System.Management.Automation/engine/MshObject.cs#L965 + */ + if (returnHashtable) + return PopulateHashTableFromJDictionary(dictionary, out error); + else + return PopulateFromJDictionary(dictionary, new DuplicateMemberHashSet(), out error); case JArray list: - return PopulateHashTableFromJArray(list, out exception); + if (returnHashtable) + return PopulateHashTableFromJArray(list, out error); + else + return PopulateFromJArray(list, out error); default: return obj; } } - catch (JsonException ex) + catch (JsonException je) + { + var msg = string.Format(CultureInfo.CurrentCulture, Resources.JsonDeserializationFailed, je.Message); + + // the same as JavaScriptSerializer does + throw new ArgumentException(msg, je); + } + } + + private class DuplicateMemberHashSet : HashSet + { + public DuplicateMemberHashSet() + : base(StringComparer.OrdinalIgnoreCase) + { + } + + public bool TryGetValue(string entryKey, out string entry) { - var msg = Resources.JsonSerializationFailed.FormatCurrentCulture(ex.Message); - throw new ArgumentException(msg, ex); + entry = this.FirstOrDefault(x => x == entryKey); + return entry != null; } } - private static ICollection PopulateHashTableFromJArray(JArray list, out Exception exception) + private static PSObject PopulateFromJDictionary(JObject entries, DuplicateMemberHashSet memberHashTracker, out ErrorRecord error) + { + error = null; + var result = new PSObject(); + foreach (var entry in entries) + { + if (string.IsNullOrEmpty(entry.Key)) + { + var errorMsg = string.Format(CultureInfo.CurrentCulture, Resources.EmptyKeyInJsonString); + error = new ErrorRecord( + new InvalidOperationException(errorMsg), + ErrorConstants.Codes.InvokeGraphRequestEmptyKeyInJsonString, + ErrorCategory.InvalidOperation, + null); + return null; + } + + // Case sensitive duplicates should normally not occur since JsonConvert.DeserializeObject + // does not throw when encountering duplicates and just uses the last entry. + if (memberHashTracker.TryGetValue(entry.Key, out var maybePropertyName) + && string.Equals(entry.Key, maybePropertyName, StringComparison.Ordinal)) + { + var errorMsg = string.Format(CultureInfo.CurrentCulture, Resources.DuplicateKeysInJsonString, entry.Key); + error = new ErrorRecord( + new InvalidOperationException(errorMsg), + ErrorConstants.Codes.InvokeGraphRequestDuplicateKeysInJsonString, + ErrorCategory.InvalidOperation, + null); + return null; + } + + // Compare case insensitive to tell the user to use the -OutputType HashTable option instead. + // This is because PSObject cannot have keys with different casing. + if (memberHashTracker.TryGetValue(entry.Key, out var propertyName)) + { + var errorMsg = string.Format(CultureInfo.CurrentCulture, Resources.KeysWithDifferentCasingInJsonString, propertyName, entry.Key); + error = new ErrorRecord( + new InvalidOperationException(errorMsg), + ErrorConstants.Codes.InvokeGraphRequestKeysWithDifferentCasingInJsonString, + ErrorCategory.InvalidOperation, + null); + return null; + } + + // Array + switch (entry.Value) + { + case JArray list: + { + var listResult = PopulateFromJArray(list, out error); + if (error != null) + { + return null; + } + + result.Properties.Add(new PSNoteProperty(entry.Key, listResult)); + break; + } + case JObject dic: + { + // Dictionary + var dicResult = PopulateFromJDictionary(dic, new DuplicateMemberHashSet(), out error); + if (error != null) + { + return null; + } + + result.Properties.Add(new PSNoteProperty(entry.Key, dicResult)); + break; + } + case JValue value: + { + result.Properties.Add(new PSNoteProperty(entry.Key, value.Value)); + break; + } + } + + memberHashTracker.Add(entry.Key); + } + + return result; + } + + private static ICollection PopulateFromJArray(JArray list, out ErrorRecord error) + { + error = null; + var result = new object[list.Count]; + + for (var index = 0; index < list.Count; index++) + { + var element = list[index]; + switch (element) + { + case JArray subList: + { + // Array + var listResult = PopulateFromJArray(subList, out error); + if (error != null) + { + return null; + } + + result[index] = listResult; + break; + } + case JObject dic: + { + // Dictionary + var dicResult = PopulateFromJDictionary(dic, new DuplicateMemberHashSet(), out error); + if (error != null) + { + return null; + } + + result[index] = dicResult; + break; + } + case JValue value: + { + result[index] = value.Value; + break; + } + } + } + + return result; + } + + private static ICollection PopulateHashTableFromJArray(JArray list, out ErrorRecord error) { - exception = null; + error = null; var result = new object[list.Count]; for (var index = 0; index < list.Count; index++) @@ -162,8 +301,8 @@ private static ICollection PopulateHashTableFromJArray(JArray list, out case JArray array: { // Array - var listResult = PopulateHashTableFromJArray(array, out exception); - if (exception != null) + var listResult = PopulateHashTableFromJArray(array, out error); + if (error != null) { return null; } @@ -174,8 +313,8 @@ private static ICollection PopulateHashTableFromJArray(JArray list, out case JObject dic: { // Dictionary - var dicResult = PopulateHashTableFromJDictionary(dic, out exception); - if (exception != null) + var dicResult = PopulateHashTableFromJDictionary(dic, out error); + if (error != null) { return null; } @@ -194,18 +333,22 @@ private static ICollection PopulateHashTableFromJArray(JArray list, out return result; } - private static Hashtable PopulateHashTableFromJDictionary(JObject entries, out Exception exception) + private static Hashtable PopulateHashTableFromJDictionary(JObject entries, out ErrorRecord error) { - exception = null; - Hashtable result = new Hashtable(entries.Count, StringComparer.OrdinalIgnoreCase); + error = null; + var result = new Hashtable(entries.Count, StringComparer.OrdinalIgnoreCase); foreach (var entry in entries) { // Case sensitive duplicates should normally not occur since JsonConvert.DeserializeObject // does not throw when encountering duplicates and just uses the last entry. if (result.ContainsKey(entry.Key)) { - string errorMsg = Resources.DuplicateKeysInJsonString.FormatCurrentCulture(entry.Key); - exception = new InvalidOperationException(errorMsg); + var errorMsg = string.Format(CultureInfo.CurrentCulture, Resources.DuplicateKeysInJsonString, entry.Key); + error = new ErrorRecord( + new InvalidOperationException(errorMsg), + ErrorConstants.Codes.InvokeGraphRequestDuplicateKeysInJsonString, + ErrorCategory.InvalidOperation, + null); return null; } @@ -214,8 +357,8 @@ private static Hashtable PopulateHashTableFromJDictionary(JObject entries, out E case JArray list: { // Array - var listResult = PopulateHashTableFromJArray(list, out exception); - if (exception != null) + var listResult = PopulateHashTableFromJArray(list, out error); + if (error != null) { return null; } @@ -226,8 +369,8 @@ private static Hashtable PopulateHashTableFromJDictionary(JObject entries, out E case JObject dic: { // Dictionary - var dicResult = PopulateHashTableFromJDictionary(dic, out exception); - if (exception != null) + var dicResult = PopulateHashTableFromJDictionary(dic, out error); + if (error != null) { return null; } diff --git a/src/Authentication/Authentication/Helpers/WebResponseHelper.cs b/src/Authentication/Authentication/Helpers/WebResponseHelper.cs index 35fa71ca59b..25d04d2ebca 100644 --- a/src/Authentication/Authentication/Helpers/WebResponseHelper.cs +++ b/src/Authentication/Authentication/Helpers/WebResponseHelper.cs @@ -4,9 +4,7 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Net.Http; -using Microsoft.Graph.PowerShell.Authentication.Cmdlets; namespace Microsoft.Graph.PowerShell.Authentication.Helpers { diff --git a/src/Authentication/Authentication/Microsoft.Graph.Authentication.csproj b/src/Authentication/Authentication/Microsoft.Graph.Authentication.csproj index 76627c8d751..06915d200fc 100644 --- a/src/Authentication/Authentication/Microsoft.Graph.Authentication.csproj +++ b/src/Authentication/Authentication/Microsoft.Graph.Authentication.csproj @@ -1,6 +1,6 @@ - 0.5.0 + 1.2.0 7.1 netstandard2.0 Library @@ -21,9 +21,9 @@ - - - + + + diff --git a/src/Authentication/Authentication/Microsoft.Graph.Authentication.nuspec b/src/Authentication/Authentication/Microsoft.Graph.Authentication.nuspec index 79463ca7bd5..b1247787e68 100644 --- a/src/Authentication/Authentication/Microsoft.Graph.Authentication.nuspec +++ b/src/Authentication/Authentication/Microsoft.Graph.Authentication.nuspec @@ -1,14 +1,14 @@ - 0.5.2 + 1.2.0 Microsoft.Graph.Authentication Microsoft Graph PowerShell authentication module Microsoft Microsoft true https://aka.ms/devservicesagreement - https://raw.githubusercontent.com/microsoftgraph/g-raph/master/g-raph.png + https://raw.githubusercontent.com/microsoftgraph/msgraph-sdk-powershell/master/documentation/images/graph_color256.png https://github.com/microsoftgraph/msgraph-sdk-powershell © Microsoft Corporation. All rights reserved. Microsoft Office365 Graph PowerShell GraphServiceClient Outlook OneDrive AzureAD GraphAPI Productivity SharePoint Intune SDK diff --git a/src/Authentication/Authentication/Microsoft.Graph.Authentication.psd1 b/src/Authentication/Authentication/Microsoft.Graph.Authentication.psd1 index 0727bb50275..58b92e0a4f0 100644 --- a/src/Authentication/Authentication/Microsoft.Graph.Authentication.psd1 +++ b/src/Authentication/Authentication/Microsoft.Graph.Authentication.psd1 @@ -3,7 +3,7 @@ # # Generated by: Microsoft # -# Generated on: 14-Sep-20 +# Generated on: 23-Jan-21 # @{ @@ -72,15 +72,16 @@ FormatsToProcess = './Microsoft.Graph.Authentication.format.ps1xml' FunctionsToExport = @() # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = 'Connect-MgGraph', 'Disconnect-MgGraph', 'Get-MgContext', 'Get-MgProfile', - 'Select-MgProfile', 'Invoke-MgGraphRequest', 'Add-MgEnvironment', 'Get-MgEnvironment', - 'Remove-MgEnvironment', 'Set-MgEnvironment' +CmdletsToExport = 'Connect-MgGraph', 'Disconnect-MgGraph', 'Get-MgContext', + 'Get-MgProfile', 'Select-MgProfile', 'Invoke-MgGraphRequest', + 'Add-MgEnvironment', 'Get-MgEnvironment', 'Remove-MgEnvironment', + 'Set-MgEnvironment' # Variables to export from this module # VariablesToExport = @() # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. -AliasesToExport = @('Connect-Graph', 'Disconnect-Graph', 'Invoke-GraphRequest') +AliasesToExport = 'Connect-Graph', 'Disconnect-Graph', 'Invoke-GraphRequest' # DSC resources to export from this module # DscResourcesToExport = @() @@ -106,7 +107,7 @@ PrivateData = @{ ProjectUri = 'https://github.com/microsoftgraph/msgraph-sdk-powershell' # A URL to an icon representing this module. - IconUri = 'https://raw.githubusercontent.com/microsoftgraph/msgraph-sdk-powershell/po/release1.1.0/documentation/images/graph_color256.png' + IconUri = 'https://raw.githubusercontent.com/microsoftgraph/msgraph-sdk-powershell/master/documentation/images/graph_color256.png' # ReleaseNotes of this module ReleaseNotes = 'See https://aka.ms/GraphPowerShell-Release.' diff --git a/src/Authentication/Authentication/Models/OutputType.cs b/src/Authentication/Authentication/Models/OutputType.cs new file mode 100644 index 00000000000..6a291ea04db --- /dev/null +++ b/src/Authentication/Authentication/Models/OutputType.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +namespace Microsoft.Graph.PowerShell.Authentication.Models +{ + /// + /// Output data format to return to the caller + /// + public enum OutputType + { + /// + /// Default OutputType, Key Value + /// + HashTable, + /// + /// Deserialized from returned Json to a PSObject. + /// + PSObject, + /// + /// Full Http Response + /// + HttpResponseMessage, + /// + /// Raw Json String. + /// + Json + } +} \ No newline at end of file diff --git a/src/Authentication/Authentication/Models/RestReturnType.cs b/src/Authentication/Authentication/Models/RestReturnType.cs index db4a7fb8b28..e55693eef10 100644 --- a/src/Authentication/Authentication/Models/RestReturnType.cs +++ b/src/Authentication/Authentication/Models/RestReturnType.cs @@ -16,6 +16,14 @@ public enum RestReturnType /// /// Xml return type. /// - Xml = 2 + Xml = 2, + /// + /// application/octet-stream return type + /// + OctetStream = 3, + /// + /// image/* (image/jpeg, image/png) return type + /// + Image = 4 } } \ No newline at end of file diff --git a/src/Authentication/Authentication/Properties/Resources.Designer.cs b/src/Authentication/Authentication/Properties/Resources.Designer.cs index fa048c26a6c..6d7e3e333ae 100644 --- a/src/Authentication/Authentication/Properties/Resources.Designer.cs +++ b/src/Authentication/Authentication/Properties/Resources.Designer.cs @@ -141,6 +141,15 @@ internal static string DuplicateKeysInJsonString { } } + /// + /// Looks up a localized string similar to The provided JSON includes a property whose name is an empty string, this is only supported using the -OutputType HashTable switch.. + /// + internal static string EmptyKeyInJsonString { + get { + return ResourceManager.GetString("EmptyKeyInJsonString", resourceCulture); + } + } + /// /// Looks up a localized string similar to The cmdlet cannot run because the following conflicting parameters are specified: GraphRequestSession and SessionVariable. Specify either GraphRequestSession or SessionVariable, then retry.. /// @@ -169,7 +178,7 @@ internal static string InferFileNameOutFilePathConflict { } /// - /// Looks up a localized string similar to Could not Infer File Name. + /// Looks up a localized string similar to Could not Infer File Name. Content Disposition Header is Not Present. /// internal static string InferredFileNameErrorMessage { get { @@ -177,6 +186,15 @@ internal static string InferredFileNameErrorMessage { } } + /// + /// Looks up a localized string similar to Inferred File Name {0} is incorrect and cannot be saved. Please specify -OutputFilePath Explicitly. + /// + internal static string InferredFileNameIncorrect { + get { + return ResourceManager.GetString("InferredFileNameIncorrect", resourceCulture); + } + } + /// /// Looks up a localized string similar to Inferred File Name {0} Saving to {1}. /// @@ -231,6 +249,15 @@ internal static string InvokeGraphResponseVerboseMessage { } } + /// + /// Looks up a localized string similar to Conversion from JSON failed with error: {0}. + /// + internal static string JsonDeserializationFailed { + get { + return ResourceManager.GetString("JsonDeserializationFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Conversion from JSON failed with error: {0}. /// @@ -241,7 +268,16 @@ internal static string JsonSerializationFailed { } /// - /// Looks up a localized string similar to The cmdlet cannot run because {0} is missing and 'Authentication' is set to 'Default'. Please call 'Connect-Graph' then try again.. + /// Looks up a localized string similar to Cannot convert the JSON string because it contains keys with different casing. Please use the -OutputType HashTable switch instead. The key that was attempted to be added to the existing key '{0}' was '{1}'.. + /// + internal static string KeysWithDifferentCasingInJsonString { + get { + return ResourceManager.GetString("KeysWithDifferentCasingInJsonString", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The cmdlet cannot run because {0} is missing and 'Authentication' is set to 'Default'. Please call 'Connect-MgGraph' then try again.. /// internal static string MissingAuthenticationContext { get { @@ -258,6 +294,24 @@ internal static string MultiplePathsResolved { } } + /// + /// Looks up a localized string similar to Request returned Non-Json response of {0} with Content-Disposition {1}, Please specify '-OutputFilePath' or '-InferOutputFileName' . + /// + internal static string NonJsonResponseWithoutInfer { + get { + return ResourceManager.GetString("NonJsonResponseWithoutInfer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Request returned Non-Json response of {0}, Please specify '-OutputFilePath'. + /// + internal static string NonJsonResponseWithoutOutputFilePath { + get { + return ResourceManager.GetString("NonJsonResponseWithoutOutputFilePath", resourceCulture); + } + } + /// /// Looks up a localized string similar to Path '{0}' cannot be resolved to a file.. /// @@ -268,7 +322,7 @@ internal static string NoPathResolved { } /// - /// Looks up a localized string similar to The cmdlet cannot run because Authentication is set to {0} and Connect-Graph was not called. Invoke 'Connect-Graph' or specify Authentication to be 'UserProvidedToken' and Provide a Token then retry. + /// Looks up a localized string similar to The cmdlet cannot run because Authentication is set to {0} and Connect-MgGraph was not called. Invoke 'Connect-MgGraph' or specify Authentication to be 'UserProvidedToken' and Provide a Token then retry. /// internal static string NotConnectedToGraphException { get { diff --git a/src/Authentication/Authentication/Properties/Resources.resx b/src/Authentication/Authentication/Properties/Resources.resx index d18dd53db99..4e151fe31e8 100644 --- a/src/Authentication/Authentication/Properties/Resources.resx +++ b/src/Authentication/Authentication/Properties/Resources.resx @@ -144,9 +144,6 @@ The cmdlet cannot run because the following conflicting parameters are specified: GraphRequestSession and SessionVariable. Specify either GraphRequestSession or SessionVariable, then retry. - - Could not Infer File Name - Inferred File Name {0} Saving to {1} @@ -197,12 +194,33 @@ The cmdlet cannot run because the following conflicting parameters are specified: {0} and {1}. Specify either {0} or {1} then retry. - The cmdlet cannot run because Authentication is set to {0} and Connect-Graph was not called. Invoke 'Connect-Graph' or specify Authentication to be 'UserProvidedToken' and Provide a Token then retry + The cmdlet cannot run because Authentication is set to {0} and Connect-MgGraph was not called. Invoke 'Connect-MgGraph' or specify Authentication to be 'UserProvidedToken' and Provide a Token then retry + + + The cmdlet cannot run because {0} is missing and 'Authentication' is set to 'Default'. Please call 'Connect-MgGraph' then try again. + + + Conversion from JSON failed with error: {0} + + + The provided JSON includes a property whose name is an empty string, this is only supported using the -OutputType HashTable switch. Cannot convert the JSON string because a dictionary that was converted from the string contains the duplicated key '{0}'. - - The cmdlet cannot run because {0} is missing and 'Authentication' is set to 'Default'. Please call 'Connect-Graph' then try again. + + Cannot convert the JSON string because it contains keys with different casing. Please use the -OutputType HashTable switch instead. The key that was attempted to be added to the existing key '{0}' was '{1}'. + + + Could not Infer File Name. Content Disposition Header is Not Present + + + Inferred File Name {0} is incorrect and cannot be saved. Please specify -OutputFilePath Explicitly + + + Request returned Non-Json response of {0} with Content-Disposition {1}, Please specify '-OutputFilePath' or '-InferOutputFileName' + + + Request returned Non-Json response of {0}, Please specify '-OutputFilePath' \ No newline at end of file diff --git a/src/Authentication/Authentication/test/Authentication.Tests.ps1 b/src/Authentication/Authentication/test/Authentication.Tests.ps1 new file mode 100644 index 00000000000..0d643681ea9 --- /dev/null +++ b/src/Authentication/Authentication/test/Authentication.Tests.ps1 @@ -0,0 +1,97 @@ +BeforeAll { + $loadEnvPath = Join-Path $PSScriptRoot 'loadEnv.ps1' + if (-Not (Test-Path -Path $loadEnvPath)) { + $loadEnvPath = Join-Path $PSScriptRoot '..\loadEnv.ps1' + } + . ($loadEnvPath) + $ModuleName = "Microsoft.Graph.Authentication" + $ModulePath = Join-Path $PSScriptRoot "..\$ModuleName.psd1" + Import-Module $ModulePath -Force +} +Describe 'Invoke-MgGraphRequest Collection Results' { + BeforeAll { + Connect-MgGraph + } + It 'ShouldReturnPsObject' { + Invoke-MgGraphRequest -OutputType PSObject -Uri "https://graph.microsoft.com/v1.0/users" | Should -BeOfType [System.Management.Automation.PSObject] + } + + It 'ShouldReturnHashTable' { + $hashTable = Invoke-MgGraphRequest -OutputType Hashtable -Uri "https://graph.microsoft.com/v1.0/users" | Should -BeOfType [System.Collections.Hashtable] + } + + It 'ShouldReturnJsonString' { + $jsonString = Invoke-MgGraphRequest -OutputType Json -Uri "https://graph.microsoft.com/v1.0/users" | Should -BeOfType [System.String] + } + + It 'ShouldReturnHttpResponseMessage' { + $httpResponseMessage = Invoke-MgGraphRequest -OutputType HttpResponseMessage -Uri "https://graph.microsoft.com/v1.0/users" | Should -BeOfType [System.Net.Http.HttpResponseMessage] + } + + It 'ShouldReturnPsObjectBeta' { + $psObject = Invoke-MgGraphRequest -OutputType PSObject -Uri "https://graph.microsoft.com/beta/users" | Should -BeOfType [System.Management.Automation.PSObject] + } + + It 'ShouldReturnHashTableBeta' { + $hashTable = Invoke-MgGraphRequest -OutputType Hashtable -Uri "https://graph.microsoft.com/beta/users" | Should -BeOfType [System.Collections.Hashtable] + } + + It 'ShouldReturnJsonStringBeta' { + $jsonString = Invoke-MgGraphRequest -OutputType Json -Uri "https://graph.microsoft.com/beta/users" | Should -BeOfType [System.String] + } + + It 'ShouldReturnHttpResponseMessageBeta' { + $httpResponseMessage = Invoke-MgGraphRequest -OutputType HttpResponseMessage -Uri "https://graph.microsoft.com/beta/users" | Should -BeOfType [System.Net.Http.HttpResponseMessage] + } +} +Describe 'Invoke-MgGraphRequest Single Entity' { + BeforeAll { + Connect-MgGraph + } + It 'ShouldReturnPsObject' { + $psObject = Invoke-MgGraphRequest -OutputType PSObject -Uri "https://graph.microsoft.com/v1.0/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.Management.Automation.PSObject] + } + + It 'ShouldReturnHashTable' { + Invoke-MgGraphRequest -OutputType Hashtable -Uri "https://graph.microsoft.com/v1.0/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.Collections.Hashtable] + } + + It 'ShouldReturnJsonString' { + Invoke-MgGraphRequest -OutputType Json -Uri "https://graph.microsoft.com/v1.0/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.String] + } + + It 'ShouldReturnHttpResponseMessage' { + Invoke-MgGraphRequest -OutputType HttpResponseMessage -Uri "https://graph.microsoft.com/v1.0/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.Net.Http.HttpResponseMessage] + } + + It 'ShouldReturnPsObjectBeta' { + Invoke-MgGraphRequest -OutputType PSObject -Uri "https://graph.microsoft.com/beta/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.Management.Automation.PSObject] + } + + It 'ShouldReturnHashTableBeta' { + Invoke-MgGraphRequest -OutputType Hashtable -Uri "https://graph.microsoft.com/beta/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.Collections.Hashtable] + } + + It 'ShouldReturnJsonStringBeta' { + Invoke-MgGraphRequest -OutputType Json -Uri "https://graph.microsoft.com/beta/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.String] + } + + It 'ShouldReturnHttpResponseMessageBeta' { + Invoke-MgGraphRequest -OutputType HttpResponseMessage -Uri "https://graph.microsoft.com/beta/users/${env:DEFAULTUSERID}" | Should -BeOfType [System.Net.Http.HttpResponseMessage] + } +} + +Describe 'Invoke-MgGraphRequest Non-Json Responses'{ + BeforeAll { + Connect-MgGraph + } + It 'Should Throw when -OutputFilePath is not Specified and Request Returns a Non-Json Response' { + { Invoke-MgGraphRequest -OutputType PSObject -Uri "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D7')" } | Should -Throw + } + It 'Should Not Throw when -OutputFilePath is Specified' { + { Invoke-MgGraphRequest -OutputType PSObject -Uri "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D7')" -OutputFilePath ./data.csv } | Should -Not -Throw + } + It 'Should Not Throw when -InferOutputFilePath is Specified' { + { Invoke-MgGraphRequest -OutputType PSObject -Uri "https://graph.microsoft.com/v1.0/reports/getTeamsUserActivityUserDetail(period='D7')" -InferOutputFileName } | Should -Not -Throw + } +} \ No newline at end of file diff --git a/src/Authentication/Authentication/test/Microsoft.Graph.Authentication-TestResults.xml b/src/Authentication/Authentication/test/Microsoft.Graph.Authentication-TestResults.xml new file mode 100644 index 00000000000..c68c3bb8bd0 --- /dev/null +++ b/src/Authentication/Authentication/test/Microsoft.Graph.Authentication-TestResults.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Authentication/Authentication/test/loadEnv.ps1 b/src/Authentication/Authentication/test/loadEnv.ps1 new file mode 100644 index 00000000000..dfad40588a2 --- /dev/null +++ b/src/Authentication/Authentication/test/loadEnv.ps1 @@ -0,0 +1,30 @@ +# ---------------------------------------------------------------------------------- +# +# Copyright Microsoft Corporation +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------------- + +$envFile = 'localEnv.json' +if (Test-Path -Path (Join-Path $PSScriptRoot $envFile)) { + $envFilePath = Join-Path $PSScriptRoot $envFile +} else { + $envFilePath = Join-Path $PSScriptRoot '..\$envFile' +} +$env = @{} +if (Test-Path -Path $envFilePath) { + $env = Get-Content (Join-Path $PSScriptRoot $envFile) | ConvertFrom-Json + $env:DEFAULTUSERID = $env.DefaultUserIdentifier +} else { + $env.TenantIdentifier = ${env:TENANTIDENTIFIER} + $env.ClientIdentifier = ${env:CLIENTIDENTIFIER} + $env.CertificateThumbprint = ${env:CERTIFICATETHUMBPRINT} +} +$PSDefaultParameterValues=@{"Connect-MgGraph:TenantId"=$env.TenantIdentifier; "Connect-MgGraph:ClientId"=$env.ClientIdentifier; "Connect-MgGraph:CertificateThumbprint"=$env.CertificateThumbprint} \ No newline at end of file diff --git a/src/Authentication/Authentication/test/readme.md b/src/Authentication/Authentication/test/readme.md new file mode 100644 index 00000000000..7c752b4c8c4 --- /dev/null +++ b/src/Authentication/Authentication/test/readme.md @@ -0,0 +1,17 @@ +# Test +This directory contains the [Pester](https://www.powershellgallery.com/packages/Pester) tests to run for the module. We use Pester as it is the unofficial standard for PowerShell unit testing. Test stubs for custom cmdlets (created in `..\custom`) will be generated into this folder when `build-module.ps1` is ran. These test stubs will fail automatically, to indicate that tests should be written for custom cmdlets. + +## Info +- Modifiable: yes +- Generated: partial +- Committed: yes +- Packaged: no + +## Details +We allow three testing modes: *live*, *record*, and *playback*. These can be selected using the `-Live`, `-Record`, and `-Playback` switches respectively on the `test-module.ps1` script. This script will run through any `.Tests.ps1` scripts in the `test` folder. If you choose the *record* mode, it will create a `.Recording.json` file of the REST calls between the client and server. Then, when you choose *playback* mode, it will use the `.Recording.json` file to mock the communication between server and client. The *live* mode runs the same as the *record* mode; however, it doesn't create the `.Recording.json` file. + +## Purpose +Custom cmdlets generally encompass additional functionality not described in the REST specification, or combines functionality generated from the REST spec. To validate this functionality continues to operate as intended, creating tests that can be ran and re-ran against custom cmdlets is part of the framework. + +## Usage +To execute tests, run the `test-module.ps1`. To write tests, [this example](https://github.com/pester/Pester/blob/8b9cf4248315e44f1ac6673be149f7e0d7f10466/Examples/Planets/Get-Planet.Tests.ps1#L1) from the Pester repository is very useful for getting started. \ No newline at end of file diff --git a/tools/GenerateAuthenticationModule.ps1 b/tools/GenerateAuthenticationModule.ps1 index 863094938de..abd5de076b8 100644 --- a/tools/GenerateAuthenticationModule.ps1 +++ b/tools/GenerateAuthenticationModule.ps1 @@ -9,6 +9,7 @@ Param( [switch] $Publish, [switch] $EnableSigning, [switch] $BuildWhenEqual, + [switch] $Test, [int] $ModulePreviewNumber = -1 ) enum VersionState { @@ -31,6 +32,7 @@ $PackModulePS1 = Join-Path $PSScriptRoot ".\PackModule.ps1" -Resolve $PublishModulePS1 = Join-Path $PSScriptRoot ".\PublishModule.ps1" -Resolve $ValidateUpdatedModuleVersionPS1 = Join-Path $PSScriptRoot ".\ValidateUpdatedModuleVersion.ps1" -Resolve $AuthModulePath = Join-Path $PSScriptRoot "..\src\Authentication\Authentication\" -Resolve +$TestModulePS1 = Join-Path $PSScriptRoot ".\TestModule.ps1" -Resolve # Read ModuleVersion set on local auth module. $ManifestContent = Import-LocalizedData -BaseDirectory $AuthModulePath -FileName $AuthModuleManifest @@ -42,7 +44,6 @@ $AllowPreRelease = $true if($ModulePreviewNumber -eq -1) { $AllowPreRelease = $false } -Write-Warning $ManifestContent # Validate module version with the one on PSGallery. [VersionState]$VersionState = & $ValidateUpdatedModuleVersionPS1 -ModuleName "$ModulePrefix.$ModuleName" -NextVersion $ManifestContent.ModuleVersion -PSRepository $RepositoryName -ModulePreviewNumber $ModulePreviewNumber @@ -54,9 +55,6 @@ elseif ($VersionState.Equals([VersionState]::EqualToFeed) -and !$BuildWhenEqual) } elseif ($VersionState.Equals([VersionState]::Valid) -or $VersionState.Equals([VersionState]::NotOnFeed) -or $BuildWhenEqual) { $ModuleVersion = $VersionState.Equals([VersionState]::NotOnFeed) ? "0.1.1" : $ManifestContent.ModuleVersion - Write-Warning $VersionState - Write-Warning $ModuleVersion - Write-Warning $ManifestContent.ModuleVersion # Build and pack generated module. if ($Build) { if ($EnableSigning) { @@ -66,6 +64,9 @@ elseif ($VersionState.Equals([VersionState]::Valid) -or $VersionState.Equals([Ve & $BuildModulePS1 -Module $ModuleName -ModulePrefix $ModulePrefix -ModuleVersion $ModuleVersion -ModulePreviewNumber $ModulePreviewNumber -ReleaseNotes $ManifestContent.PrivateData.PSData.ReleaseNotes } } + if($Test){ + & $TestModulePS1 -ModulePath $AuthModulePath -ModuleName "$ModulePrefix.$ModuleName" + } if ($Pack) { & $PackModulePS1 -Module $ModuleName -ArtifactsLocation $ArtifactsLocation diff --git a/tools/TestModule.ps1 b/tools/TestModule.ps1 index 32ee8ee3b6a..9e09b39a5f6 100644 --- a/tools/TestModule.ps1 +++ b/tools/TestModule.ps1 @@ -24,6 +24,8 @@ $testFolder = Join-Path $ModulePath 'test' $PesterConfiguration = [PesterConfiguration]::Default $PesterConfiguration.Run.Path = $testFolder $PesterConfiguration.Run.Exit = $true +$PesterConfiguration.CodeCoverage.Enabled = $true +$PesterConfiguration.TestResult.Enabled = $true $PesterConfiguration.TestResult.OutputPath = (Join-Path $testFolder "$moduleName-TestResults.xml") try {