diff --git a/Aspire.sln b/Aspire.sln index e0bfd2cf28a..c815a3b698b 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -665,6 +665,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Docker.Tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Kubernetes.Tests", "tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj", "{582B06FD-CC56-4C58-8138-D92F8FE62BBD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql", "src\Components\Aspire.Azure.Npgsql\Aspire.Azure.Npgsql.csproj", "{417C3703-058A-210D-7E9A-28198EFDB25F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql.Tests", "tests\Aspire.Azure.Npgsql.Tests\Aspire.Azure.Npgsql.Tests.csproj", "{201765B1-37A1-9F5B-DD7F-730347046BE1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -3891,6 +3895,30 @@ Global {582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x64.Build.0 = Release|Any CPU {582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.ActiveCfg = Release|Any CPU {582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.Build.0 = Release|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x64.ActiveCfg = Debug|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x64.Build.0 = Debug|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x86.ActiveCfg = Debug|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x86.Build.0 = Debug|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|Any CPU.Build.0 = Release|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x64.ActiveCfg = Release|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x64.Build.0 = Release|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x86.ActiveCfg = Release|Any CPU + {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x86.Build.0 = Release|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x64.ActiveCfg = Debug|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x64.Build.0 = Debug|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x86.ActiveCfg = Debug|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x86.Build.0 = Debug|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|Any CPU.Build.0 = Release|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x64.ActiveCfg = Release|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x64.Build.0 = Release|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x86.ActiveCfg = Release|Any CPU + {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -4210,6 +4238,8 @@ Global {3AE2ED5B-4EC7-4E6D-A61D-C68B837E5FA7} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} {43B560D6-F158-4A4C-8E43-981056EB9038} = {830A89EC-4029-4753-B25A-068BAE37DEC7} {582B06FD-CC56-4C58-8138-D92F8FE62BBD} = {830A89EC-4029-4753-B25A-068BAE37DEC7} + {417C3703-058A-210D-7E9A-28198EFDB25F} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {201765B1-37A1-9F5B-DD7F-730347046BE1} = {C424395C-1235-41A4-BF55-07880A04368C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4} diff --git a/src/Components/Aspire.Azure.Npgsql/Aspire.Azure.Npgsql.csproj b/src/Components/Aspire.Azure.Npgsql/Aspire.Azure.Npgsql.csproj new file mode 100644 index 00000000000..880c56ec2c3 --- /dev/null +++ b/src/Components/Aspire.Azure.Npgsql/Aspire.Azure.Npgsql.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultTargetFramework) + true + $(ComponentAzurePackageTags) postgresql postgres npgsql sql + A client for Azure Database for PostgreSQL® that integrates with Aspire, including health checks, logging and telemetry. + $(SharedDir)AzurePostgreSQL_256x.png + false + + + + + + + + diff --git a/src/Components/Aspire.Azure.Npgsql/AspireAzureNpgsqlExtensions.cs b/src/Components/Aspire.Azure.Npgsql/AspireAzureNpgsqlExtensions.cs new file mode 100644 index 00000000000..49c292e3cd7 --- /dev/null +++ b/src/Components/Aspire.Azure.Npgsql/AspireAzureNpgsqlExtensions.cs @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json; +using Aspire.Azure.Npgsql; +using Aspire.Npgsql; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for connecting to an Azure Database for PostgreSQL with Npgsql client +/// +public static class AspireAzureNpgsqlExtensions +{ + private const string AzureDatabaseForPostgresSqlScope = "https://ossrdbms-aad.database.windows.net/.default"; + private const string AzureManagementScope = "https://management.azure.com/.default"; + + private static readonly TokenRequestContext s_databaseForPostgresSqlTokenRequestContext = new([AzureDatabaseForPostgresSqlScope]); + private static readonly TokenRequestContext s_managementTokenRequestContext = new([AzureManagementScope]); + + /// + /// Registers service for connecting PostgreSQL database with Npgsql client. + /// Configures health check, logging and telemetry for the Npgsql client. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate that can be used for customizing the . + /// Reads the configuration from "Aspire:Npgsql" section. + /// Thrown if mandatory is null. + /// Thrown when mandatory is not provided or the is invalid. + public static void AddAzureNpgsqlDataSource(this IHostApplicationBuilder builder, string connectionName, Action? configureSettings = null, Action? configureDataSourceBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + AzureNpgsqlSettings? azureSettings = null; + + builder.AddNpgsqlDataSource(connectionName, settings => azureSettings = ConfigureSettings(configureSettings, settings), dataSourceBuilder => + { + Debug.Assert(azureSettings != null); + + ConfigureDataSourceBuilder(azureSettings, dataSourceBuilder); + + configureDataSourceBuilder?.Invoke(dataSourceBuilder); + }); + } + + /// + /// Registers as a keyed service for given for connecting PostgreSQL database with Npgsql client. + /// Configures health check, logging and telemetry for the Npgsql client. + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate that can be used for customizing the . + /// Reads the configuration from "Aspire:Npgsql:{name}" section. + /// Thrown when or is null. + /// Thrown if mandatory is empty. + /// Thrown when mandatory is not provided or the is invalid. + public static void AddKeyedAzureNpgsqlDataSource(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureDataSourceBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + AzureNpgsqlSettings? azureSettings = null; + + builder.AddKeyedNpgsqlDataSource(name, settings => azureSettings = ConfigureSettings(configureSettings, settings), dataSourceBuilder => + { + Debug.Assert(azureSettings != null); + + ConfigureDataSourceBuilder(azureSettings, dataSourceBuilder); + + configureDataSourceBuilder?.Invoke(dataSourceBuilder); + }); + } + + private static AzureNpgsqlSettings ConfigureSettings(Action? userConfigureSettings, NpgsqlSettings settings) + { + var azureSettings = new AzureNpgsqlSettings(); + + // Copy the values updated by Npgsql integration. + CopySettings(settings, azureSettings); + + // Invoke the Aspire configuration. + userConfigureSettings?.Invoke(azureSettings); + + // Copy to the Npgsql integration settings as it needs to get any values set in userConfigureSettings. + CopySettings(azureSettings, settings); + + return azureSettings; + } + + private static void CopySettings(NpgsqlSettings source, NpgsqlSettings destination) + { + destination.ConnectionString = source.ConnectionString; + destination.DisableHealthChecks = source.DisableHealthChecks; + destination.DisableMetrics = source.DisableMetrics; + destination.DisableTracing = source.DisableTracing; + } + + private static void ConfigureDataSourceBuilder(AzureNpgsqlSettings settings, NpgsqlDataSourceBuilder dataSourceBuilder) + { + // The connection string required the username to be provided. Since it will depend on the Managed Identity that is used + // we attempt to get the username from the access token. + + var credential = settings.Credential ?? new DefaultAzureCredential(); + + if (string.IsNullOrEmpty(dataSourceBuilder.ConnectionStringBuilder.Username)) + { + // Ensure to use the management scope, so the token contains user names for all managed identity types - e.g. user and service principal + var token = credential.GetToken(s_managementTokenRequestContext, default); + + if (TryGetUsernameFromToken(token.Token, out var username)) + { + dataSourceBuilder.ConnectionStringBuilder.Username = username; + } + else + { + // Otherwise check using the PostgresSql scope + token = credential.GetToken(s_databaseForPostgresSqlTokenRequestContext, default); + + if (TryGetUsernameFromToken(token.Token, out username)) + { + dataSourceBuilder.ConnectionStringBuilder.Username = username; + } + } + + // If we still don't have a username, we let Npgsql handle the error when trying to connect. + // The user will be hinted to provide a username by using the configureDataSourceBuilder callback. + } + + if (string.IsNullOrEmpty(dataSourceBuilder.ConnectionStringBuilder.Password)) + { + // The token is not cached since it is refreshed for each new physical connection, or when it has expired. + + dataSourceBuilder.UsePasswordProvider( + passwordProvider: _ => credential.GetToken(s_databaseForPostgresSqlTokenRequestContext, default).Token, + passwordProviderAsync: async (_, ct) => (await credential.GetTokenAsync(s_databaseForPostgresSqlTokenRequestContext, default).ConfigureAwait(false)).Token + ); + } + } + + private static bool TryGetUsernameFromToken(string jwtToken, out string? username) + { + username = null; + + // Split the token into its parts (Header, Payload, Signature) + var tokenParts = jwtToken.Split('.'); + if (tokenParts.Length != 3) + { + return false; + } + + // The payload is the second part, Base64Url encoded + var payload = tokenParts[1]; + + // Add padding if necessary + payload = AddBase64Padding(payload); + + // Decode the payload from Base64Url + var decodedBytes = Convert.FromBase64String(payload); + + // Parse the decoded payload as JSON + var reader = new Utf8JsonReader(decodedBytes); + var payloadJson = JsonElement.ParseValue(ref reader); + + // Try to get the username from 'xms_mirid', 'upn', 'preferred_username', or 'unique_name' claims + if (payloadJson.TryGetProperty("xms_mirid", out var xms_mirid) && + xms_mirid.GetString() is string xms_miridString && + ParsePrincipalName(xms_miridString) is string principalName) + { + username = principalName; + } + else if (payloadJson.TryGetProperty("upn", out var upn)) + { + username = upn.GetString(); + } + else if (payloadJson.TryGetProperty("preferred_username", out var preferredUsername)) + { + username = preferredUsername.GetString(); + } + else if (payloadJson.TryGetProperty("unique_name", out var uniqueName)) + { + username = uniqueName.GetString(); + } + + return username != null; + } + + // parse the xms_mirid claim which look like + // /subscriptions/{subId}/resourcegroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{principalName} + private static string? ParsePrincipalName(string xms_mirid) + { + var lastSlashIndex = xms_mirid.LastIndexOf('/'); + if (lastSlashIndex == -1) + { + return null; + } + + var beginning = xms_mirid.AsSpan(0, lastSlashIndex); + var principalName = xms_mirid.AsSpan(lastSlashIndex + 1); + + if (principalName.IsEmpty || !beginning.EndsWith("providers/Microsoft.ManagedIdentity/userAssignedIdentities", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return principalName.ToString(); + } + + private static string AddBase64Padding(string base64) => (base64.Length % 4) switch + { + 2 => base64 + "==", + 3 => base64 + "=", + _ => base64, + }; +} diff --git a/src/Components/Aspire.Azure.Npgsql/AzureNpgsqlSettings.cs b/src/Components/Aspire.Azure.Npgsql/AzureNpgsqlSettings.cs new file mode 100644 index 00000000000..689bf0eb377 --- /dev/null +++ b/src/Components/Aspire.Azure.Npgsql/AzureNpgsqlSettings.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Npgsql; +using Azure.Core; + +namespace Aspire.Azure.Npgsql; + +/// +/// Provides the client configuration settings for connecting to an Azure Database for PostgreSQL using Npgsql. +/// +public sealed class AzureNpgsqlSettings : NpgsqlSettings +{ + /// + /// Gets or sets the credential used to authenticate to the Azure Database for PostgreSQL. + /// + public TokenCredential? Credential { get; set; } +} diff --git a/src/Components/Aspire.Azure.Npgsql/README.md b/src/Components/Aspire.Azure.Npgsql/README.md new file mode 100644 index 00000000000..ec18c88147a --- /dev/null +++ b/src/Components/Aspire.Azure.Npgsql/README.md @@ -0,0 +1,146 @@ +# Aspire.Azure.Npgsql library + +Registers [NpgsqlDataSource](https://www.npgsql.org/doc/api/Npgsql.NpgsqlDataSource.html) in the DI container for connecting to PostgreSQL and Azure Database for PostgreSQL. Enables corresponding health check, metrics, logging and telemetry. + +## Getting started + +### Prerequisites + +- PostgreSQL database and connection string for accessing the database. +- or an Azure Database for PostgreSQL instance, learn more about how to [Create an Azure Database for PostgreSQL resource](https://learn.microsoft.com/azure/postgresql/flexible-server/quickstart-create-server?tabs=portal-create-flexible%2Cportal-get-connection%2Cportal-delete-resources). + +### Differences with Aspire.Npgsql + +The Aspire.Azure.Npgsql library is a wrapper around the Aspire.Npgsql library that provides additional features for connecting to Azure Database for PostgreSQL. If you don't need these features, you can use the Aspire.Npgsql library instead. +At runtime the client integration will detect whether the connection string has a Username and Password, and if not, it will use Entra Id to authenticate with Azure Database for PostgreSQL. + +### Install the package + +Install the .NET Aspire Azure Npgsql library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Azure.Npgsql +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddAzureNpgsqlDataSource` extension method to register a `NpgsqlDataSource` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddAzureNpgsqlDataSource("postgresdb"); +``` + +You can then retrieve the `NpgsqlDataSource` instance using dependency injection. For example, to retrieve the data source from a Web API controller: + +```csharp +private readonly NpgsqlDataSource _dataSource; + +public ProductsController(NpgsqlDataSource dataSource) +{ + _dataSource = dataSource; +} +``` + +## Configuration + +The .NET Aspire Azure Npgsql component provides multiple options to configure the database connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddAzureNpgsqlDataSource()`: + +```csharp +builder.AddAzureNpgsqlDataSource("myConnection"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myConnection": "Host=myserver;Database=test" + } +} +``` + +See the [ConnectionString documentation](https://www.npgsql.org/doc/connection-string-parameters.html) for more information on how to format this connection string. + +Note that the username and password will be automatically inferred from the credential provided in the settings. + +### Use configuration providers + +The .NET Aspire Azure Npgsql component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `AzureNpgsqlSettings` from configuration by using the `Aspire:Azure:Npgsql` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Azure": { + "Npgsql": { + "DisableHealthChecks": true, + "DisableTracing": true + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code: + +```csharp + builder.AddAzureNpgsqlDataSource("postgresdb", settings => settings.DisableHealthChecks = true); +``` + +Use the `AzureNpgsqlSettings.Credential` property to establish a connection. If no credential is configured, the [DefaultAzureCredential](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential) is used. + +If the connection string contains a username and a password then the credential will be ignored. + +## AppHost extensions + +In your AppHost project, install the `Aspire.Hosting.Azure.PostgreSQL` library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Azure.PostgreSQL +``` + +Then, in the _Program.cs_ file of `AppHost`, register a Azure Database for PostgreSQL instance and consume the connection using the following methods: + +```csharp +var postgresdb = builder.AddAzurePostgresFlexibleServer("pg").AddDatabase("postgresdb"); + +var myService = builder.AddProject() + .WithReference(postgresdb); +``` + +The `WithReference` method configures a connection in the `MyService` project named `postgresdb`. In the _Program.cs_ file of `MyService`, the database connection can be consumed using: + +```csharp +builder.AddAzureNpgsqlDataSource("postgresdb"); +``` + +This will also require your Azure environment to be configure by following [these instructions](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration). + +## Troubleshooting + +In the rare case that the Username property is not provided and the integration can't detect it using the application's Managed Identity, Npgsql will throw an exception like the following: + +> Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user ... + +In that case you can configure the Username property in the connection string by using the `configureDataSourceBuilder` callback like so: + +```csharp +builder.AddAzureNpgsqlDataSource("db", configureDataSourceBuilder: + dataSourceBuilder => dataSourceBuilder.ConnectionStringBuilder.Username = ""); +``` + +## Additional documentation + +* https://www.npgsql.org/doc/basic-usage.html +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire + +_*Postgres, PostgreSQL and the Slonik Logo are trademarks or registered trademarks of the PostgreSQL Community Association of Canada, and used with their permission._ diff --git a/src/Components/Aspire.Npgsql/NpgsqlSettings.cs b/src/Components/Aspire.Npgsql/NpgsqlSettings.cs index 9f8eca81c39..800c4ba5b85 100644 --- a/src/Components/Aspire.Npgsql/NpgsqlSettings.cs +++ b/src/Components/Aspire.Npgsql/NpgsqlSettings.cs @@ -6,7 +6,7 @@ namespace Aspire.Npgsql; /// /// Provides the client configuration settings for connecting to a PostgreSQL database using Npgsql. /// -public sealed class NpgsqlSettings +public class NpgsqlSettings { /// /// The connection string of the PostgreSQL database to connect to. diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 246caf788e1..32fa704c7b3 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -16,6 +16,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | Azure.Messaging.EventHubs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | | Azure.Messaging.WebPubSub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Messaging.ServiceBus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Azure.Npgsql | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Azure.Search.Documents | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Security.KeyVault | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Storage.Blobs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index 2ce7170e07d..46fbc46c4ec 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -48,6 +48,9 @@ Aspire.Azure.Messaging.WebPubSub: - Metric names: - none (currently not supported by the Azure SDK) +Aspire.Azure.Npgsql: +- Everything from `Aspire.Npgsql` + Aspire.Azure.Search.Documents: - Log categories: - "Azure.Core" diff --git a/tests/Aspire.Azure.Npgsql.Tests/Aspire.Azure.Npgsql.Tests.csproj b/tests/Aspire.Azure.Npgsql.Tests/Aspire.Azure.Npgsql.Tests.csproj new file mode 100644 index 00000000000..563c5a32a8a --- /dev/null +++ b/tests/Aspire.Azure.Npgsql.Tests/Aspire.Azure.Npgsql.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + + + + + diff --git a/tests/Aspire.Azure.Npgsql.Tests/AspireAzurePostgreSqlNpgsqlExtensionsTests.cs b/tests/Aspire.Azure.Npgsql.Tests/AspireAzurePostgreSqlNpgsqlExtensionsTests.cs new file mode 100644 index 00000000000..91f7ed8da77 --- /dev/null +++ b/tests/Aspire.Azure.Npgsql.Tests/AspireAzurePostgreSqlNpgsqlExtensionsTests.cs @@ -0,0 +1,340 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Npgsql; +using Azure.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Xunit; + +namespace Aspire.Azure.Npgsql.Tests; + +public class AspireAzurePostgreSqlNpgsqlExtensionsTests +{ + private const string ConnectionString = "Host=localhost;Database=test_aspire_npgsql"; + private const string ConnectionStringWithUsername = "Host=localhost;Database=test_aspire_npgsql;Username=admin"; + private const string ConnectionStringWithUsernameAndPassword = "Host=localhost;Database=test_aspire_npgsql;Username=admin;Password=p@ssw0rd1"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadsUsernameFromToken(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql", ConnectionString) + ]); + + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.Contains(ConnectionString, dataSource.ConnectionString); + Assert.Contains("Username=mikey@mouse.com", dataSource.ConnectionString); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadsUsernameFromManagedIdentityToken(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql", ConnectionString) + ]); + + var fakeCred = new FakeTokenCredential(useManagedIdentity: true); + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings => settings.Credential = fakeCred); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings => settings.Credential = fakeCred); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.Contains(ConnectionString, dataSource.ConnectionString); + Assert.Contains("Username=mi-123", dataSource.ConnectionString); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TokenCredentialIsIgnoredWhenUsernameAndPasswordAreSet(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql", ConnectionStringWithUsernameAndPassword) + ]); + + FakeTokenCredential? tokenCredential = null; + + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings => + { + ConfigureTokenCredentials(settings); + tokenCredential = settings.Credential as FakeTokenCredential; + }); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings => + { + ConfigureTokenCredentials(settings); + tokenCredential = settings.Credential as FakeTokenCredential; + }); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.NotNull(tokenCredential); + Assert.Equal(ConnectionStringWithUsername, dataSource.ConnectionString); + Assert.False(tokenCredential.IsGetTokenInvoked); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionStringCanBeSetInCode(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql", "unused") + ]); + + static void SetConnectionString(NpgsqlSettings settings) => settings.ConnectionString = ConnectionStringWithUsernameAndPassword; + + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", SetConnectionString); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", SetConnectionString); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionStringWithUsername, dataSource.ConnectionString); + // the connection string from config should not be used since code set it explicitly + Assert.DoesNotContain("unused", dataSource.ConnectionString); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DoesNotThrowWhenTokenCredentialHasNoUsername(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql", ConnectionString) + ]); + + FakeTokenCredential? tokenCredential = null; + + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings => + { + ConfigureAnonumousTokenCredentials(settings); + tokenCredential = settings.Credential as FakeTokenCredential; + }); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings => + { + ConfigureAnonumousTokenCredentials(settings); + tokenCredential = settings.Credential as FakeTokenCredential; + }); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.NotNull(tokenCredential); + Assert.Equal(ConnectionString, dataSource.ConnectionString); + Assert.True(tokenCredential.IsGetTokenInvoked); + Assert.Contains("https://ossrdbms-aad.database.windows.net/.default", tokenCredential.RequestedScopes); + Assert.Contains("https://management.azure.com/.default", tokenCredential.RequestedScopes); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void UsernameCanBeConfiguredWhenTokenCredentialHasNoUsername(bool useKeyed) + { + const string username = "admin"; + + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql", ConnectionString) + ]); + + FakeTokenCredential? tokenCredential = null; + + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings => + { + ConfigureAnonumousTokenCredentials(settings); + tokenCredential = settings.Credential as FakeTokenCredential; + }, configureDataSourceBuilder: dataSourceBuilder => dataSourceBuilder.ConnectionStringBuilder.Username = username); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings => + { + ConfigureAnonumousTokenCredentials(settings); + tokenCredential = settings.Credential as FakeTokenCredential; + }, configureDataSourceBuilder: dataSourceBuilder => dataSourceBuilder.ConnectionStringBuilder.Username = username); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.NotNull(tokenCredential); + Assert.Contains(ConnectionString, dataSource.ConnectionString); + Assert.Contains("Username=admin", dataSource.ConnectionString); + Assert.True(tokenCredential.IsGetTokenInvoked); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionNameWinsOverConfigSection(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var key = useKeyed ? "npgsql" : null; + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair(ConformanceTests.CreateConfigKey("Aspire:Npgsql", key, "ConnectionString"), "unused"), + new KeyValuePair("ConnectionStrings:npgsql", ConnectionStringWithUsername) + ]); + + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionStringWithUsername, dataSource.ConnectionString); + // the connection string from config should not be used since it was found in ConnectionStrings + Assert.DoesNotContain("unused", dataSource.ConnectionString); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CustomDataSourceBuilderIsExecuted(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql", ConnectionString) + ]); + + var wasCalled = false; + void configureDataSourceBuilder(NpgsqlDataSourceBuilder b) => wasCalled = true; + + if (useKeyed) + { + builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials, configureDataSourceBuilder: configureDataSourceBuilder); + } + else + { + builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials, configureDataSourceBuilder: configureDataSourceBuilder); + } + + using var host = builder.Build(); + var dataSource = useKeyed ? + host.Services.GetRequiredKeyedService("npgsql") : + host.Services.GetRequiredService(); + + Assert.True(wasCalled); + } + + [Fact] + public void CanAddMultipleKeyedServices() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:npgsql1", "Host=localhost1;Database=test_aspire_npgsql"), + new KeyValuePair("ConnectionStrings:npgsql2", "Host=localhost2;Database=test_aspire_npgsql"), + new KeyValuePair("ConnectionStrings:npgsql3", "Host=localhost3;Database=test_aspire_npgsql"), + ]); + + builder.AddAzureNpgsqlDataSource("npgsql1", configureSettings: ConfigureTokenCredentials); + builder.AddKeyedAzureNpgsqlDataSource("npgsql2", configureSettings: ConfigureTokenCredentials); + builder.AddKeyedAzureNpgsqlDataSource("npgsql3", configureSettings: ConfigureTokenCredentials); + + using var host = builder.Build(); + + var connection1 = host.Services.GetRequiredService(); + var connection2 = host.Services.GetRequiredKeyedService("npgsql2"); + var connection3 = host.Services.GetRequiredKeyedService("npgsql3"); + + Assert.NotSame(connection1, connection2); + Assert.NotSame(connection1, connection3); + Assert.NotSame(connection2, connection3); + + Assert.Contains("localhost1", connection1.ConnectionString); + Assert.Contains("localhost2", connection2.ConnectionString); + Assert.Contains("localhost3", connection3.ConnectionString); + } + + private void ConfigureTokenCredentials(AzureNpgsqlSettings settings) + { + settings.Credential = new FakeTokenCredential(); + } + + private static void ConfigureAnonumousTokenCredentials(AzureNpgsqlSettings settings) + { + const string token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJJc3N1ZWQgQXQiOiIyMDI1LTAzLTIxVDAxOjM3OjAwLjE5OFoiLCJFeHBpcmF0aW9uIjoiMjAyNS0wMy0yMVQwMTozNzowMC4xOThaIiwiUm9sZSI6IkFkbWluIn0.nT9VhsXfI0v78C5J57ehy3NERNNN0e6NvVZwq_XOr-A"; + var accesstoken = new AccessToken(token, DateTimeOffset.Now.AddHours(1)); + + // { + // "Issuer": "Issuer", + // "Issued At": "2025-03-21T01:37:00.198Z", + // "Expiration": "2025-03-21T01:37:00.198Z", + // "Role": "Admin" + // } + + settings.Credential = new FakeTokenCredential(accesstoken); + } +} diff --git a/tests/Aspire.Azure.Npgsql.Tests/ConfigurationTests.cs b/tests/Aspire.Azure.Npgsql.Tests/ConfigurationTests.cs new file mode 100644 index 00000000000..dd5be09895e --- /dev/null +++ b/tests/Aspire.Azure.Npgsql.Tests/ConfigurationTests.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Azure.Npgsql.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ConnectionStringIsNullByDefault() + => Assert.Null(new AzureNpgsqlSettings().ConnectionString); + + [Fact] + public void HealthCheckIsEnabledByDefault() + => Assert.False(new AzureNpgsqlSettings().DisableHealthChecks); + + [Fact] + public void TracingIsEnabledByDefault() + => Assert.False(new AzureNpgsqlSettings().DisableTracing); + + [Fact] + public void MetricsAreEnabledByDefault() + => Assert.False(new AzureNpgsqlSettings().DisableMetrics); +} diff --git a/tests/Aspire.Azure.Npgsql.Tests/ConformanceTests.cs b/tests/Aspire.Azure.Npgsql.Tests/ConformanceTests.cs new file mode 100644 index 00000000000..020fa6bed5d --- /dev/null +++ b/tests/Aspire.Azure.Npgsql.Tests/ConformanceTests.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; +using Aspire.Components.Common.Tests; +using Aspire.Components.ConformanceTests; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Xunit; + +namespace Aspire.Azure.Npgsql.Tests; + +public class ConformanceTests : ConformanceTests, IClassFixture +{ + private readonly PostgreSQLContainerFixture? _containerFixture; + protected string ConnectionString { get; private set; } + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + // https://github.com/npgsql/npgsql/blob/ef9db1ffe9e432c1562d855b46dfac3514726b1b/src/Npgsql.OpenTelemetry/TracerProviderBuilderExtensions.cs#L18 + protected override string ActivitySourceName => "Npgsql"; + + protected override string[] RequiredLogCategories => new string[] + { + "Npgsql.Connection", + "Npgsql.Command", + "Npgsql.Transaction", + "Npgsql.Copy", + "Npgsql.Replication", + "Npgsql.Exception" + }; + + protected override bool SupportsKeyedRegistrations => true; + + protected override bool CanConnectToServer => RequiresDockerAttribute.IsSupported; + + protected override string? ConfigurationSectionName => "Aspire:Npgsql"; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Npgsql": { + "ConnectionString": "YOUR_CONNECTION_STRING", + "DisableHealthChecks": true, + "DisableTracing": false, + "DisableMetrics": false + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => []; + + public ConformanceTests(PostgreSQLContainerFixture? containerFixture) + { + _containerFixture = containerFixture; + ConnectionString = (_containerFixture is not null && RequiresDockerAttribute.IsSupported) + ? _containerFixture.GetConnectionString() + : "Server=localhost;User ID=root;Password=password;Database=test_aspire_mysql"; + } + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[1] + { + new KeyValuePair(CreateConfigKey("Aspire:Npgsql", key, "ConnectionString"), ConnectionString) + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + void Configure(AzureNpgsqlSettings settings) + { + configure?.Invoke(settings); + settings.Credential = new FakeTokenCredential(); + }; + + if (key is null) + { + builder.AddAzureNpgsqlDataSource("npgsql", Configure); + } + else + { + builder.AddKeyedAzureNpgsqlDataSource(key, Configure); + } + } + + protected override void SetHealthCheck(AzureNpgsqlSettings options, bool enabled) + => options.DisableHealthChecks = !enabled; + + protected override void SetTracing(AzureNpgsqlSettings options, bool enabled) + => options.DisableTracing = !enabled; + + protected override void SetMetrics(AzureNpgsqlSettings options, bool enabled) + => options.DisableMetrics = !enabled; + + protected override void TriggerActivity(NpgsqlDataSource service) + { + using NpgsqlConnection connection = service.CreateConnection(); + connection.Open(); + using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = "Select 1;"; + command.ExecuteScalar(); + } + + [Theory] + [InlineData(null)] + [InlineData("key")] + public void BothDataSourceAndConnectionCanBeResolved(string? key) + { + using IHost host = CreateHostWithComponent(key: key); + + NpgsqlDataSource? npgsqlDataSource = Resolve(); + DbDataSource? dbDataSource = Resolve(); + NpgsqlConnection? npgsqlConnection = Resolve(); + DbConnection? dbConnection = Resolve(); + + Assert.NotNull(npgsqlDataSource); + Assert.Same(npgsqlDataSource, dbDataSource); + + Assert.NotNull(npgsqlConnection); + Assert.NotNull(dbConnection); + + Assert.Equal(dbConnection.ConnectionString, npgsqlConnection.ConnectionString); + Assert.Equal(npgsqlDataSource.ConnectionString, npgsqlConnection.ConnectionString); + + T? Resolve() => key is null ? host.Services.GetService() : host.Services.GetKeyedService(key); + } + + [Fact] + [RequiresDocker] + public void TracingEnablesTheRightActivitySource() + => RemoteExecutor.Invoke(static connectionStringToUse => RunWithConnectionString(connectionStringToUse, obj => obj.ActivitySourceTest(key: null)), + ConnectionString).Dispose(); + + [Fact] + [RequiresDocker] + public void TracingEnablesTheRightActivitySource_Keyed() + => RemoteExecutor.Invoke(static connectionStringToUse => RunWithConnectionString(connectionStringToUse, obj => obj.ActivitySourceTest(key: "key")), + ConnectionString).Dispose(); + + private static void RunWithConnectionString(string connectionString, Action test) + => test(new ConformanceTests(null) { ConnectionString = connectionString }); +} diff --git a/tests/Aspire.Azure.Npgsql.Tests/FakeTokenCredential.cs b/tests/Aspire.Azure.Npgsql.Tests/FakeTokenCredential.cs new file mode 100644 index 00000000000..1370aa569a4 --- /dev/null +++ b/tests/Aspire.Azure.Npgsql.Tests/FakeTokenCredential.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Core; + +namespace Aspire.Azure.Npgsql.Tests; + +internal sealed class FakeTokenCredential : TokenCredential +{ + private const string Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJJc3N1ZWQgQXQiOiIyMDI1LTAzLTIxVDAxOjM3OjAwLjE5OFoiLCJFeHBpcmF0aW9uIjoiMjAyNS0wMy0yMVQwMTozNzowMC4xOThaIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibWlrZXlAbW91c2UuY29tIiwiUm9sZSI6IkFkbWluIn0.WdKzHL5CBeMOIlpzqaotvNbo0mmvaMtcW0zpMlB3lUE"; + // { + // "Issuer": "Issuer", + // "Issued At": "2025-03-21T01:37:00.198Z", + // "Expiration": "2025-03-21T01:37:00.198Z", + // "preferred_username": "mikey@mouse.com", + // "Role": "Admin" + // } + + private const string ManagedIdentityToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJJc3N1ZWQgQXQiOiIyMDI1LTAzLTIxVDAxOjM3OjAwLjE5OFoiLCJFeHBpcmF0aW9uIjoiMjAyNS0wMy0yMVQwMTozNzowMC4xOThaIiwieG1zX21pcmlkIjoiL3N1YnNjcmlwdGlvbnMvMTIzL3Jlc291cmNlZ3JvdXBzL3JnLTEyMy9wcm92aWRlcnMvTWljcm9zb2Z0Lk1hbmFnZWRJZGVudGl0eS91c2VyQXNzaWduZWRJZGVudGl0aWVzL21pLTEyMyIsIlJvbGUiOiJBZG1pbiJ9.luuw0374yNSOWKfswHURCm620UoY9qrZriqLG0668Tw"; + // { + // "Issuer": "Issuer", + // "Issued At": "2025-03-21T01:37:00.198Z", + // "Expiration": "2025-03-21T01:37:00.198Z", + // "xms_mirid": "/subscriptions/123/resourcegroups/rg-123/providers/Microsoft.ManagedIdentity/userAssignedIdentities/mi-123", + // "Role": "Admin" + //} + + private readonly AccessToken _token; + + public FakeTokenCredential(bool useManagedIdentity = false) : + this(new AccessToken(useManagedIdentity ? ManagedIdentityToken : Token, DateTime.UtcNow)) + { + } + + public FakeTokenCredential(AccessToken token) + { + _token = token; + } + + public bool IsGetTokenInvoked { get; private set; } + + public List RequestedScopes { get; } = []; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + RequestedScopes.AddRange(requestContext.Scopes); + IsGetTokenInvoked = true; + return _token; + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + RequestedScopes.AddRange(requestContext.Scopes); + IsGetTokenInvoked = true; + return new ValueTask(_token); + } +} diff --git a/tests/Aspire.Azure.Npgsql.Tests/NpgsqlPublicApiTests.cs b/tests/Aspire.Azure.Npgsql.Tests/NpgsqlPublicApiTests.cs new file mode 100644 index 00000000000..d3ad2749d19 --- /dev/null +++ b/tests/Aspire.Azure.Npgsql.Tests/NpgsqlPublicApiTests.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.Npgsql.Tests; + +public class NpgsqlPublicApiTests +{ + [Fact] + public void AddNpgsqlDataSourceShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + const string connectionName = "npgsql"; + + var action = () => builder.AddAzureNpgsqlDataSource(connectionName, configureSettings: ConfigureTokenCredentials); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddNpgsqlDataSourceShouldThrowWhenConnectionNameIsNullOrEmpty(bool isNull) + { + IHostApplicationBuilder builder = new HostApplicationBuilder(); + var connectionName = isNull ? null! : string.Empty; + + var action = () => builder.AddAzureNpgsqlDataSource(connectionName, configureSettings: ConfigureTokenCredentials); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddKeyedNpgsqlDataSourceShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + const string name = "npgsql"; + + var action = () => builder.AddKeyedAzureNpgsqlDataSource(name, configureSettings: ConfigureTokenCredentials); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddKeyedNpgsqlDataSourceShouldThrowWhenNameIsNullOrEmpty(bool isNull) + { + IHostApplicationBuilder builder = new HostApplicationBuilder(); + var name = isNull ? null! : string.Empty; + + var action = () => builder.AddKeyedAzureNpgsqlDataSource(name, configureSettings: ConfigureTokenCredentials); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + private void ConfigureTokenCredentials(AzureNpgsqlSettings settings) + { + settings.Credential = new FakeTokenCredential(); + } +} diff --git a/tests/Aspire.Azure.Npgsql.Tests/PostgreSQLContainerFixture.cs b/tests/Aspire.Azure.Npgsql.Tests/PostgreSQLContainerFixture.cs new file mode 100644 index 00000000000..52d3e828c5f --- /dev/null +++ b/tests/Aspire.Azure.Npgsql.Tests/PostgreSQLContainerFixture.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Hosting.Postgres; +using Testcontainers.PostgreSql; +using Xunit; + +namespace Aspire.Azure.Npgsql.Tests; + +public sealed class PostgreSQLContainerFixture : IAsyncLifetime +{ + public PostgreSqlContainer? Container { get; private set; } + + public string GetConnectionString() => Container?.GetConnectionString() ?? + throw new InvalidOperationException("The test container was not initialized."); + + public async Task InitializeAsync() + { + if (RequiresDockerAttribute.IsSupported) + { + Container = new PostgreSqlBuilder() + .WithImage($"{ComponentTestConstants.AspireTestContainerRegistry}/{PostgresContainerImageTags.Image}:{PostgresContainerImageTags.Tag}") + .Build(); + await Container.StartAsync(); + } + } + + public async Task DisposeAsync() + { + if (Container is not null) + { + await Container.DisposeAsync(); + } + } +} diff --git a/tests/Aspire.Components.Common.Tests/ConformanceTests.cs b/tests/Aspire.Components.Common.Tests/ConformanceTests.cs index 87f4eb6025d..f4143745977 100644 --- a/tests/Aspire.Components.Common.Tests/ConformanceTests.cs +++ b/tests/Aspire.Components.Common.Tests/ConformanceTests.cs @@ -54,6 +54,8 @@ protected virtual void DisableRetries(TOptions options) { } protected bool TracingIsSupported => CheckIfImplemented(SetTracing); + protected virtual bool CheckOptionClassSealed => true; + /// /// Calls the actual Component /// @@ -93,6 +95,11 @@ public void OptionsTypeIsSealed() throw new SkipTestException("Not implemented yet"); } + if (!CheckOptionClassSealed) + { + throw new SkipTestException("Opt-out of test"); + } + Assert.True(typeof(TOptions).IsSealed); } diff --git a/tests/Aspire.Npgsql.Tests/ConformanceTests.cs b/tests/Aspire.Npgsql.Tests/ConformanceTests.cs index 2b57c17faa2..a245df9f34f 100644 --- a/tests/Aspire.Npgsql.Tests/ConformanceTests.cs +++ b/tests/Aspire.Npgsql.Tests/ConformanceTests.cs @@ -137,6 +137,8 @@ public void TracingEnablesTheRightActivitySource_Keyed() => RemoteExecutor.Invoke(static connectionStringToUse => RunWithConnectionString(connectionStringToUse, obj => obj.ActivitySourceTest(key: "key")), ConnectionString).Dispose(); + protected override bool CheckOptionClassSealed => false; // AzureNpgsqlSettings needs to inherit from NpgsqlSettings + private static void RunWithConnectionString(string connectionString, Action test) => test(new ConformanceTests(null) { ConnectionString = connectionString }); }