diff --git a/src/Layers/W1/Tests/Misc/APITestAuthProviderTests.Codeunit.al b/src/Layers/W1/Tests/Misc/APITestAuthProviderTests.Codeunit.al new file mode 100644 index 00000000000..21919987b05 --- /dev/null +++ b/src/Layers/W1/Tests/Misc/APITestAuthProviderTests.Codeunit.al @@ -0,0 +1,204 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AA0247 + +codeunit 139494 "API Test Auth Provider Tests" +{ + EventSubscriberInstance = Manual; + Subtype = Test; + TestPermissions = Disabled; + + trigger OnRun() + begin + // [FEATURE] [API Test Authentication] + end; + + var + Assert: Codeunit Assert; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; + APITestAuthProviderTests: Codeunit "API Test Auth Provider Tests"; + LibraryGraphMgt: Codeunit "Library - Graph Mgt"; + SecondLibraryGraphMgt: Codeunit "Library - Graph Mgt"; + FirstHttpWebRequestMgt: Codeunit "Http Web Request Mgt."; + SecondHttpWebRequestMgt: Codeunit "Http Web Request Mgt."; + IsInitialized: Boolean; + TargetURLTok: Label 'http://127.0.0.1/', Locked = true; + ProviderCallTok: Label 'Provider|%1', Locked = true, Comment = '%1 - Provider invocation number'; + EventCallTok: Label 'Event', Locked = true; + UnexpectedCallErr: Label 'Unexpected authentication call.'; + + [Test] + procedure DefaultAuthenticationDoesNotConfigureRequest() + begin + // [SCENARIO] The default API test authentication does not configure the request + Initialize(); + + // [GIVEN] A Graph library instance without an explicitly selected provider + + // [WHEN] A web request is initialized + LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpWebRequestMgt, TargetURLTok); + + // [THEN] Only the final request event is raised + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + [Test] + procedure ExtendedAuthenticationProviderRunsBeforeFinalEvent() + begin + // [SCENARIO] An enum extension can provide API test authentication + Initialize(); + + // [GIVEN] A Graph library instance using the mock authentication provider + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); + + // [WHEN] A web request is initialized + LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpWebRequestMgt, TargetURLTok); + + // [THEN] The selected provider configures authentication + VerifyNextCall(StrSubstNo(ProviderCallTok, 1)); + + // [THEN] The final request event runs after the provider + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + [Test] + procedure AuthenticationProviderInstanceIsReused() + begin + // [SCENARIO] A selected authentication provider instance is reused across requests + Initialize(); + + // [GIVEN] A Graph library instance using the mock authentication provider + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); + + // [WHEN] Two web requests are initialized through the same Graph library instance + LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpWebRequestMgt, TargetURLTok); + LibraryGraphMgt.InitializeWebRequestWithURL(SecondHttpWebRequestMgt, TargetURLTok); + + // [THEN] The same provider instance handles both requests + VerifyNextCall(StrSubstNo(ProviderCallTok, 1)); + VerifyNextCall(EventCallTok); + VerifyNextCall(StrSubstNo(ProviderCallTok, 2)); + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + [Test] + procedure AuthenticationProviderSelectionIsInstanceScoped() + begin + // [SCENARIO] Authentication provider selection is scoped to a Graph library instance + Initialize(); + + // [GIVEN] Two Graph library instances where only the first uses the mock provider + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); + + // [WHEN] Each Graph library instance initializes a web request + LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpWebRequestMgt, TargetURLTok); + SecondLibraryGraphMgt.InitializeWebRequestWithURL(SecondHttpWebRequestMgt, TargetURLTok); + + // [THEN] Only the first request uses the mock provider + VerifyNextCall(StrSubstNo(ProviderCallTok, 1)); + VerifyNextCall(EventCallTok); + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + [Test] + procedure RepeatedProviderSelectionPreservesInstance() + begin + // [SCENARIO] Repeated initialization with the same provider preserves its state + Initialize(); + + // [GIVEN] A request has used the selected provider + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); + LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpWebRequestMgt, TargetURLTok); + + // [WHEN] Initialization selects the same provider before another request + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); + LibraryGraphMgt.InitializeWebRequestWithURL(SecondHttpWebRequestMgt, TargetURLTok); + + // [THEN] The provider instance retains its invocation count + VerifyNextCall(StrSubstNo(ProviderCallTok, 1)); + VerifyNextCall(EventCallTok); + VerifyNextCall(StrSubstNo(ProviderCallTok, 2)); + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + [Test] + procedure SelectingNoneStopsConfiguringRequests() + begin + // [SCENARIO] Selecting None replaces the previously selected provider + Initialize(); + + // [GIVEN] A request has used the mock provider + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::Mock); + LibraryGraphMgt.InitializeWebRequestWithURL(FirstHttpWebRequestMgt, TargetURLTok); + + // [WHEN] Authentication is deselected before another request + LibraryGraphMgt.SetAuthenticationProvider(Enum::"API Test Authentication"::None); + LibraryGraphMgt.InitializeWebRequestWithURL(SecondHttpWebRequestMgt, TargetURLTok); + + // [THEN] Only the first request invokes the mock provider + VerifyNextCall(StrSubstNo(ProviderCallTok, 1)); + VerifyNextCall(EventCallTok); + VerifyNextCall(EventCallTok); + VerifyNoRemainingCalls(); + end; + + local procedure Initialize() + begin + Clear(LibraryGraphMgt); + Clear(SecondLibraryGraphMgt); + Clear(FirstHttpWebRequestMgt); + Clear(SecondHttpWebRequestMgt); + // The manually bound instance owns both recording and verification, including cleanup after a failed test. + APITestAuthProviderTests.ClearRecordedCalls(); + if IsInitialized then + exit; + + BindSubscription(APITestAuthProviderTests); + IsInitialized := true; + end; + + local procedure VerifyNextCall(ExpectedCall: Text) + begin + APITestAuthProviderTests.VerifyRecordedCall(ExpectedCall); + end; + + local procedure VerifyNoRemainingCalls() + begin + APITestAuthProviderTests.VerifyRecordedCallsEmpty(); + end; + + internal procedure ClearRecordedCalls() + begin + LibraryVariableStorage.Clear(); + end; + + internal procedure VerifyRecordedCall(ExpectedCall: Text) + begin + Assert.AreEqual(ExpectedCall, LibraryVariableStorage.DequeueText(), UnexpectedCallErr); + end; + + internal procedure VerifyRecordedCallsEmpty() + begin + LibraryVariableStorage.AssertEmpty(); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Mock API Test Auth Provider", OnAfterConfigureAuthentication, '', false, false)] + local procedure RecordProviderInvocation(InvocationNumber: Integer) + begin + LibraryVariableStorage.Enqueue(StrSubstNo(ProviderCallTok, InvocationNumber)); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Library - Graph Mgt", OnAfterInitializeWebRequestWithURL, '', false, false)] + local procedure RecordFinalRequestEvent(var HttpWebRequestMgt: Codeunit "Http Web Request Mgt.") + begin + LibraryVariableStorage.Enqueue(EventCallTok); + end; +} diff --git a/src/Layers/W1/Tests/Misc/APITestAuthenticationTest.EnumExt.al b/src/Layers/W1/Tests/Misc/APITestAuthenticationTest.EnumExt.al new file mode 100644 index 00000000000..8857a0387a0 --- /dev/null +++ b/src/Layers/W1/Tests/Misc/APITestAuthenticationTest.EnumExt.al @@ -0,0 +1,14 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AA0247 + +enumextension 139492 "API Test Authentication Test" extends "API Test Authentication" +{ + value(139492; Mock) + { + Implementation = "API Test Auth Provider" = "Mock API Test Auth Provider"; + } +} diff --git a/src/Layers/W1/Tests/Misc/MockAPITestAuthProvider.Codeunit.al b/src/Layers/W1/Tests/Misc/MockAPITestAuthProvider.Codeunit.al new file mode 100644 index 00000000000..4b53fd501f1 --- /dev/null +++ b/src/Layers/W1/Tests/Misc/MockAPITestAuthProvider.Codeunit.al @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AA0247 + +codeunit 139493 "Mock API Test Auth Provider" implements "API Test Auth Provider" +{ + Access = Internal; + + var + InvocationCount: Integer; + PasswordTxt: Label 'Password', Locked = true; + + /// + /// Records an invocation and configures Basic authentication for the request. + /// + /// The authentication context to configure. + procedure ConfigureAuthentication(var Authentication: Codeunit "API Test Auth Context") + var + Password: SecretText; + PasswordText: Text; + begin + InvocationCount += 1; + PasswordText := PasswordTxt; + Password := PasswordText; + Authentication.SetBasicAuthentication('User', Password); + OnAfterConfigureAuthentication(InvocationCount); + end; + + [IntegrationEvent(false, false)] + local procedure OnAfterConfigureAuthentication(InvocationNumber: Integer) + begin + end; +} diff --git a/src/Layers/W1/Tests/TestLibraries/APITestAuthContext.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/APITestAuthContext.Codeunit.al new file mode 100644 index 00000000000..34d6dd4e06e --- /dev/null +++ b/src/Layers/W1/Tests/TestLibraries/APITestAuthContext.Codeunit.al @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AA0247 + +/// +/// Stores authentication configuration for an API test request. +/// +codeunit 131023 "API Test Auth Context" +{ + var + BasicUserName: Text; + BasicPassword: SecretText; + BasicAuthenticationConfigured: Boolean; + + /// + /// Configures Basic authentication for the API test request. + /// + /// The user name used for Basic authentication. + /// The password used for Basic authentication. + procedure SetBasicAuthentication(UserName: Text; Password: SecretText) + begin + BasicUserName := UserName; + BasicPassword := Password; + BasicAuthenticationConfigured := true; + end; + + /// + /// Applies the configured authentication to the API test request. + /// + /// The request to authenticate. + internal procedure Apply(var HttpWebRequestMgt: Codeunit "Http Web Request Mgt.") + begin + if BasicAuthenticationConfigured then + HttpWebRequestMgt.AddBasicAuthentication(BasicUserName, BasicPassword); + end; +} diff --git a/src/Layers/W1/Tests/TestLibraries/APITestAuthProvider.Interface.al b/src/Layers/W1/Tests/TestLibraries/APITestAuthProvider.Interface.al new file mode 100644 index 00000000000..e0e6f8054bc --- /dev/null +++ b/src/Layers/W1/Tests/TestLibraries/APITestAuthProvider.Interface.al @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AA0247 + +/// +/// Provides authentication configuration for API test requests. +/// +interface "API Test Auth Provider" +{ + /// + /// Configures authentication for an API test request. + /// + /// The authentication context to configure. + procedure ConfigureAuthentication(var Authentication: Codeunit "API Test Auth Context"); +} diff --git a/src/Layers/W1/Tests/TestLibraries/APITestAuthentication.Enum.al b/src/Layers/W1/Tests/TestLibraries/APITestAuthentication.Enum.al new file mode 100644 index 00000000000..c7ad8041b73 --- /dev/null +++ b/src/Layers/W1/Tests/TestLibraries/APITestAuthentication.Enum.al @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AA0247 + +/// +/// Specifies the authentication provider used for API test requests. +/// +enum 131023 "API Test Authentication" implements "API Test Auth Provider" +{ + Extensible = true; + DefaultImplementation = "API Test Auth Provider" = "No API Test Auth Provider"; + + value(0; None) + { + Caption = 'None'; + Implementation = "API Test Auth Provider" = "No API Test Auth Provider"; + } + value(1; "Microsoft Test Environment") + { + Caption = 'Microsoft Test Environment'; + Implementation = "API Test Auth Provider" = "Microsoft Test Auth Provider"; + } +} diff --git a/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al index b82c12f9943..bcf97009f73 100644 --- a/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al +++ b/src/Layers/W1/Tests/TestLibraries/LibraryGraphMgt.Codeunit.al @@ -7,12 +7,30 @@ codeunit 130618 "Library - Graph Mgt" var Assert: Codeunit Assert; + Authentication: Enum "API Test Authentication"; + AuthenticationProvider: Interface "API Test Auth Provider"; + AuthenticationProviderResolved: Boolean; IncorrectValueErr: Label 'Incorrect value found in JSON for %1 property.', Comment = '%1 - Name of property'; GraphCollectionMgtItem: Codeunit "Graph Collection Mgt - Item"; UnexpectedResponseCodeErr: Label 'Response code %1 (%2) differs from the expected %3.', Comment = '%1 - Actual response code number, %2 - Actual response code, %3 - Expected response code number'; FailedRequestErr: Label '%1 request failed. Response code is %2 (%3). %4', Comment = '%1 - request method, %2 - response code number, %3 - response code, %4 - error message'; FailedRequestWithUnexpectedResponseCodeErr: Label '%1 request failed. Response code is %2 (%3), expected code is %4. %5', Comment = '%1 - request method, %2 - response code number, %3 - response code, %4 - expected response code, %5 - error message'; + /// + /// Sets the authentication provider used by this library instance. + /// Selecting the same provider preserves its cached state. + /// + /// The authentication provider to use for subsequent API test requests. + procedure SetAuthenticationProvider(NewAuthentication: Enum "API Test Authentication") + begin + if AuthenticationProviderResolved and (Authentication = NewAuthentication) then + exit; + + Authentication := NewAuthentication; + AuthenticationProvider := Authentication; + AuthenticationProviderResolved := true; + end; + procedure EnsureWebServiceExist(ServiceNameTxt: Text[240]; PageNumber: Integer) var WebService: Record "Web Service"; @@ -135,9 +153,30 @@ codeunit 130618 "Library - Graph Mgt" procedure InitializeWebRequestWithURL(var HttpWebRequestMgt: Codeunit "Http Web Request Mgt."; TargetURL: Text) begin HttpWebRequestMgt.Initialize(TargetURL); + ApplyAuthentication(HttpWebRequestMgt); OnAfterInitializeWebRequestWithURL(HttpWebRequestMgt); end; + local procedure ApplyAuthentication(var HttpWebRequestMgt: Codeunit "Http Web Request Mgt.") + var + AuthenticationContext: Codeunit "API Test Auth Context"; + CurrentAuthenticationProvider: Interface "API Test Auth Provider"; + begin + CurrentAuthenticationProvider := GetAuthenticationProvider(); + CurrentAuthenticationProvider.ConfigureAuthentication(AuthenticationContext); + AuthenticationContext.Apply(HttpWebRequestMgt); + end; + + local procedure GetAuthenticationProvider(): Interface "API Test Auth Provider" + begin + if not AuthenticationProviderResolved then begin + AuthenticationProvider := Authentication; + AuthenticationProviderResolved := true; + end; + + exit(AuthenticationProvider); + end; + procedure PatchToWebServiceAndCheckResponseCode(TargetURL: Text; JSONBody: Text; var ResponseText: Text; ExpectedResponseCode: Integer) begin UpdateToWebServiceAndCheckResponseCode(TargetURL, JSONBody, 'PATCH', ResponseText, ExpectedResponseCode); @@ -833,4 +872,3 @@ codeunit 130618 "Library - Graph Mgt" begin end; } - diff --git a/src/Layers/W1/Tests/TestLibraries/MicrosoftTestAuthProvider.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/MicrosoftTestAuthProvider.Codeunit.al new file mode 100644 index 00000000000..e0516d38988 --- /dev/null +++ b/src/Layers/W1/Tests/TestLibraries/MicrosoftTestAuthProvider.Codeunit.al @@ -0,0 +1,96 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.TestLibraries.ERP; + +using System; +using System.Azure.KeyVault; +using System.Environment; +using System.Security.AccessControl; +using System.Test.Library; + +/// +/// Provides authentication for API tests running in Microsoft test environments. +/// +codeunit 131022 "Microsoft Test Auth Provider" implements "API Test Auth Provider" +{ + Access = Internal; + + var + ApiTestPasswordFileTok: Label 'C:\Run\my\ApiTestPassword', Locked = true; + NavServerUserPasswordKeyTok: Label 'NavServerUserPassword', Locked = true; + CachedAuthenticationPassword: SecretText; + ContainerPasswordReadErr: Label 'The API test password could not be read from %1.', Comment = '%1 - Password file path'; + KeyVaultPasswordReadErr: Label 'The API test password could not be retrieved from the %1 secret.', Comment = '%1 - Azure Key Vault secret name'; + PasswordRetrievalFailedErr: Label 'The API test password could not be retrieved.'; + + /// + /// Configures authentication for API test requests, preserving ambient authentication on Windows and SaaS. + /// + /// The authentication context to configure. + procedure ConfigureAuthentication(var Authentication: Codeunit "API Test Auth Context") + var + EnvironmentInfo: Codeunit "Environment Information"; + SecurityGroup: Codeunit "Security Group"; + begin + if EnvironmentInfo.IsSaaSInfrastructure() then + exit; + if SecurityGroup.IsWindowsAuthentication() then + exit; + + Authentication.SetBasicAuthentication(UserId(), GetAuthenticationPassword()); + end; + + local procedure GetAuthenticationPassword(): SecretText + var + Password: SecretText; + begin + if not CachedAuthenticationPassword.IsEmpty() then + exit(CachedAuthenticationPassword); + + if ContainerPasswordFileExists() then begin + if not TryGetContainerPassword(Password) then + Error(ContainerPasswordReadErr, ApiTestPasswordFileTok); + end else + if not TryGetNavEnlistmentPassword(Password) then + Error(KeyVaultPasswordReadErr, NavServerUserPasswordKeyTok); + + CachedAuthenticationPassword := Password; + exit(CachedAuthenticationPassword); + end; + + [Scope('OnPrem')] + local procedure ContainerPasswordFileExists(): Boolean + var + File: DotNet File; + begin + exit(File.Exists(ApiTestPasswordFileTok)); + end; + + [TryFunction] + [NonDebuggable] + local procedure TryGetContainerPassword(var Password: SecretText) + var + MockAzureKeyvaultSecretProvider: DotNet MockAzureKeyVaultSecretProvider; + begin + // Read through a private provider without replacing the session's Azure Key Vault provider or cache. + MockAzureKeyvaultSecretProvider := MockAzureKeyvaultSecretProvider.MockAzureKeyVaultSecretProvider(); + MockAzureKeyvaultSecretProvider.AddSecretMappingFromFile(NavServerUserPasswordKeyTok, ApiTestPasswordFileTok); + Password := MockAzureKeyvaultSecretProvider.GetSecret(NavServerUserPasswordKeyTok); + if Password.IsEmpty() then + Error(PasswordRetrievalFailedErr); + end; + + [TryFunction] + local procedure TryGetNavEnlistmentPassword(var Password: SecretText) + var + AzureKeyVault: Codeunit "Azure Key Vault"; + begin + if not AzureKeyVault.GetAzureKeyVaultSecret(NavServerUserPasswordKeyTok, Password) then + Error(PasswordRetrievalFailedErr); + if Password.IsEmpty() then + Error(PasswordRetrievalFailedErr); + end; +} diff --git a/src/Layers/W1/Tests/TestLibraries/NoAPITestAuthProvider.Codeunit.al b/src/Layers/W1/Tests/TestLibraries/NoAPITestAuthProvider.Codeunit.al new file mode 100644 index 00000000000..88ac0b7af4f --- /dev/null +++ b/src/Layers/W1/Tests/TestLibraries/NoAPITestAuthProvider.Codeunit.al @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +#pragma warning disable AA0247 + +/// +/// Leaves API test requests configured with the transport's default authentication behavior. +/// +codeunit 131024 "No API Test Auth Provider" implements "API Test Auth Provider" +{ + Access = Internal; + + /// + /// Leaves the authentication context unchanged. + /// + /// The authentication context to configure. + procedure ConfigureAuthentication(var Authentication: Codeunit "API Test Auth Context") + begin + end; +}