diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fef4d632..1990d7d2 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -59,6 +59,7 @@ jobs: Hosting.Sqlite.Tests, Hosting.SqlServer.Extensions.Tests, Hosting.SurrealDb.Tests, + Hosting.Zitadel.Tests, # Client integration tests GoFeatureFlag.Tests, diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index a66c47b9..ad184232 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -162,6 +162,9 @@ + + + @@ -199,6 +202,7 @@ + @@ -251,6 +255,7 @@ + diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/AppHost.cs b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/AppHost.cs new file mode 100644 index 00000000..4f89588c --- /dev/null +++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/AppHost.cs @@ -0,0 +1,8 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var database = builder.AddPostgres("postgres"); + +builder.AddZitadel("zitadel") + .WithDatabase(database); + +builder.Build().Run(); \ No newline at end of file diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost.csproj b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost.csproj new file mode 100644 index 00000000..a13fcb3e --- /dev/null +++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost.csproj @@ -0,0 +1,12 @@ + + + + Exe + 981579ce-9426-4ad6-b6f1-192f3f4cf73b + + + + + + + diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/Properties/launchSettings.json b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..e0366292 --- /dev/null +++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17100;http://localhost:15025", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21182", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23175", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22260" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15025", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19275", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18212", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20294" + } + } + } +} diff --git a/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/appsettings.json b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/examples/zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.csproj b/src/CommunityToolkit.Aspire.Hosting.Zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.csproj new file mode 100644 index 00000000..230ce099 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.csproj @@ -0,0 +1,13 @@ + + + + A .NET Aspire host integration for Zitadel. + zitadel auth identity oauth2 authenticatioon openid-connect oidc + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/README.md b/src/CommunityToolkit.Aspire.Hosting.Zitadel/README.md new file mode 100644 index 00000000..bf22453f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/README.md @@ -0,0 +1,65 @@ +# CommunityToolkit.Aspire.Hosting.Zitadel library + +Provides extension methods and resource definitions for a .NET Aspire AppHost to configure Zitadel. + +## Getting Started + +### Install the package + +In your AppHost project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.Zitadel +``` + +### Example usage + +Then, in the _Program.cs_ file of `AppHost`, define a Zitadel resource, then call `AddZitadel`: + +```csharp +builder.AddZitadel("zitadel"); +``` + +Zitadel *requires* a Postgres database, you can add one with `WithDatabase`: +```csharp +var database = builder.AddPostgres("postgres"); + +builder.AddZitadel("zitadel") + .WithDatabase(database); +``` +You can also pass in a database rather than server (`AddPostgres().AddDatabase()`). + +### Configuring the External Domain + +By default, Zitadel uses `{name}.dev.localhost` as the external domain, which works well for local development. For production deployments or custom scenarios, you can configure a custom external domain: + +**Option 1: Using the parameter** +```csharp +builder.AddZitadel("zitadel", externalDomain: "auth.example.com"); +``` + +**Option 2: Using the fluent API** +```csharp +builder.AddZitadel("zitadel") + .WithExternalDomain("auth.example.com"); +``` + +**Option 3: From configuration** +```csharp +var domain = builder.Configuration["Zitadel:ExternalDomain"]; +builder.AddZitadel("zitadel", externalDomain: domain); +``` + +#### Why `.dev.localhost`? + +`.dev.localhost` is a special top-level domain that: +- Automatically resolves to `127.0.0.1` without requiring DNS configuration +- Provides unique subdomains for each Zitadel instance (e.g., `zitadel1.dev.localhost`, `zitadel2.dev.localhost`) +- Works reliably in local development and CI/CD environments +- Satisfies Zitadel's requirement for stable hostnames in OIDC/OAuth2 flows + +For production deployments, replace this with your actual domain name using one of the configuration methods above. + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelContainerImageTags.cs new file mode 100644 index 00000000..2026305d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelContainerImageTags.cs @@ -0,0 +1,13 @@ +namespace CommunityToolkit.Aspire.Hosting.Zitadel; + +internal static class ZitadelContainerImageTags +{ + /// Github Container Registry + public const string Registry = "ghcr.io"; + + /// zitadel/zitadel + public const string Image = "zitadel/zitadel"; + + /// v4.7.0 + public const string Tag = "v4.7.0"; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelHostingExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelHostingExtensions.cs new file mode 100644 index 00000000..aef826ef --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelHostingExtensions.cs @@ -0,0 +1,141 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Zitadel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Zitadel to an . +/// +public static class ZitadelHostingExtensions +{ + /// + /// Adds a Zitadel container resource to the . + /// + /// The to add the Zitadel container to. + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port used when launching the container. If null a random port will be assigned + /// An optional parameter to set a username for the admin account, if null will auto generate one. + /// An optional parameter to set a password for the admin account, if null will auto generate one. + /// An optional parameter to set the masterkey, if null will auto generate one. + public static IResourceBuilder AddZitadel( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + int? port = null, + IResourceBuilder? username = null, + IResourceBuilder? password = null, + IResourceBuilder? masterKey = null + ) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + + var usernameParameter = username?.Resource ?? new ParameterResource($"{name}-username", _ => "admin", false); + var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password", minSpecial: 1); + var masterKeyParameter = masterKey?.Resource ?? ParameterResourceBuilderExtensions.CreateGeneratedParameter(builder, $"{name}-masterKey", true, new GenerateParameterDefault + { + MinLength = 32, // Zitadel requires 32, CreateDefaultPasswordParameter generates 22 + Lower = true, + Upper = true, + Numeric = true, + Special = true, + MinLower = 1, + MinUpper = 1, + MinNumeric = 1, + MinSpecial = 1 + }); + + var resource = new ZitadelResource(name) + { + AdminUsernameParameter = usernameParameter, + AdminPasswordParameter = passwordParameter + }; + + var zitadelBuilder = builder.AddResource(resource) + .WithImage(ZitadelContainerImageTags.Image) + .WithImageTag(ZitadelContainerImageTags.Tag) + .WithImageRegistry(ZitadelContainerImageTags.Registry) + .WithArgs("start-from-init", "--masterkeyFromEnv") + .WithHttpEndpoint( + targetPort: 8080, + port: port, + name: ZitadelResource.HttpEndpointName + ) + .WithHttpHealthCheck("/healthz") + .WithEnvironment("ZITADEL_MASTERKEY", masterKeyParameter) + .WithEnvironment("ZITADEL_TLS_ENABLED", "false") + .WithEnvironment("ZITADEL_EXTERNALSECURE", "false") + .WithUrlForEndpoint(ZitadelResource.HttpEndpointName, e => e.DisplayText = "Zitadel Dashboard"); + + // Use ReferenceExpression for the port to avoid issues with endpoint allocation + var endpoint = resource.GetEndpoint(ZitadelResource.HttpEndpointName); + var portExpression = ReferenceExpression.Create($"{endpoint.Property(EndpointProperty.Port)}"); + var hostExpression = ReferenceExpression.Create($"{endpoint.Property(EndpointProperty.Host)}"); + + return zitadelBuilder + .WithEnvironment("ZITADEL_EXTERNALDOMAIN", hostExpression) + .WithEnvironment("ZITADEL_EXTERNALPORT", portExpression) + // Disable Login V2 for simpler setup (no separate login container needed) + .WithEnvironment("ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED", "false") + // Configure admin user + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME", usernameParameter) + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD", passwordParameter) + .WithEnvironment("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED", "false"); + } + + /// + /// Adds database support to the Zitadel resource. + /// + /// The Zitadel resource to add database support to. + /// The Postgres server resource to use for the database. + /// An optional name for the database Zitadel will use, if left empty will default to "zitadel-db". + public static IResourceBuilder WithDatabase( + this IResourceBuilder builder, + IResourceBuilder server, + [ResourceName] string? databaseName = null + ) + { + databaseName = string.IsNullOrWhiteSpace(databaseName) ? "zitadel-db" : databaseName; + var database = server.AddDatabase(databaseName); + + return WithDatabase(builder, database); + } + + /// + /// Adds database support to the Zitadel resource. + /// + /// The Zitadel resource to add database support to. + /// The Postgres database resource to use for the database. + public static IResourceBuilder WithDatabase(this IResourceBuilder builder, IResourceBuilder database) + { + ArgumentNullException.ThrowIfNull(database); + + builder + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_USER_USERNAME", database.Resource.Parent.UserNameReference) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_USER_PASSWORD", database.Resource.Parent.PasswordParameter) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME", database.Resource.Parent.UserNameReference) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD", database.Resource.Parent.PasswordParameter) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_HOST", database.Resource.Parent.Host) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_PORT", database.Resource.Parent.Port) + .WithEnvironment("ZITADEL_DATABASE_POSTGRES_DATABASE", database.Resource.DatabaseName) + .WithReference(database) + .WaitFor(database); + + return builder; + } + + /// + /// Configures the external domain for the Zitadel resource. This overrides the default domain set in . + /// + /// The Zitadel resource builder. + /// The external domain to use (e.g., "auth.example.com"). Cannot be null or empty. + /// The resource builder for chaining. + /// Thrown if is null or whitespace. + public static IResourceBuilder WithExternalDomain( + this IResourceBuilder builder, + string externalDomain) + { + ArgumentException.ThrowIfNullOrWhiteSpace(externalDomain); + + return builder.WithEnvironment("ZITADEL_EXTERNALDOMAIN", externalDomain); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelResource.cs b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelResource.cs new file mode 100644 index 00000000..3904eb55 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Zitadel/ZitadelResource.cs @@ -0,0 +1,21 @@ +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Zitadel; + +/// +/// Resource for the Zitadel API server. +/// +public sealed class ZitadelResource(string name) : ContainerResource(name) +{ + internal const string HttpEndpointName = "http"; + + /// + /// The parameter that contains the (default) Zitadel admin username. + /// + public required ParameterResource AdminUsernameParameter { get; set; } + + /// + /// The parameter that contains the (default) Zitadel admin password. + /// + public required ParameterResource AdminPasswordParameter { get; set; } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/AppHostTests.cs new file mode 100644 index 00000000..e77e178a --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/AppHostTests.cs @@ -0,0 +1,99 @@ +using CommunityToolkit.Aspire.Testing; +using Aspire.Components.Common.Tests; +using System.Net; +using System.Net.Http.Json; + +namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests; + +[RequiresDocker] +public class AppHostTests( + AspireIntegrationTestFixture fixture +) : IClassFixture> +{ + [Fact] + public async Task Zitadel_Starts_And_Responds_Ok() + { + var resourceName = "zitadel"; + + // Wait for Zitadel to be healthy (it has a health check configured) + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(5)); + + var httpClient = fixture.CreateHttpClient(resourceName); + + // Test the health endpoint + var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openid-configuration"); + // Needs to match the external domain for Zitadel or we get a 404 + request.Headers.Host = $"{resourceName}.dev.localhost"; + var response = await httpClient.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Zitadel_Starts_And_Serves_Dashboard() + { + var resourceName = "zitadel"; + + // Wait for Zitadel to be healthy (it has a health check configured) + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(resourceName) + .WaitAsync(TimeSpan.FromMinutes(5)); + + var httpClient = fixture.CreateHttpClient(resourceName); + + // Test the health endpoint + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + // Needs to match the external domain for Zitadel or we get a 404 + request.Headers.Host = $"{resourceName}.dev.localhost"; + var response = await httpClient.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains(" + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelHostingExtensionsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelHostingExtensionsTests.cs new file mode 100644 index 00000000..c41337c9 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelHostingExtensionsTests.cs @@ -0,0 +1,202 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Zitadel; + +namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests; + +public class ZitadelHostingExtensionsTests +{ + [Fact] + public void AddZitadel_Should_Throw_If_Builder_Is_Null() + { + IDistributedApplicationBuilder builder = null!; + + var act = () => builder.AddZitadel("zitadel"); + + var exception = Assert.Throws(act); + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public void AddZitadel_Should_Throw_If_Name_Is_Null() + { + var builder = DistributedApplication.CreateBuilder(); + + var act = () => builder.AddZitadel(null!); + + var exception = Assert.Throws(act); + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public void AddZitadel_Creates_ZitadelResource() + { + var builder = DistributedApplication.CreateBuilder(); + + var zitadel = builder.AddZitadel("zitadel"); + + Assert.NotNull(zitadel); + Assert.IsType(zitadel.Resource); + Assert.Equal("zitadel", zitadel.Resource.Name); + } + + [Fact] + public async Task AddZitadel_Sets_Default_Environment_Variables() + { + var builder = DistributedApplication.CreateBuilder(); + + var zitadel = builder.AddZitadel("zitadel"); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal("false", env["ZITADEL_TLS_ENABLED"]); + Assert.Equal("false", env["ZITADEL_EXTERNALSECURE"]); + Assert.Equal("zitadel.dev.localhost", env["ZITADEL_EXTERNALDOMAIN"]); + Assert.Equal("false", env["ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED"]); + Assert.Equal("false", env["ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED"]); + } + + [Fact] + public async Task AddZitadel_Sets_Admin_Username_And_Password() + { + var builder = DistributedApplication.CreateBuilder(); + + var zitadel = builder.AddZitadel("zitadel"); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.NotNull(zitadel.Resource.AdminUsernameParameter); + Assert.NotNull(zitadel.Resource.AdminPasswordParameter); + Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME")); + Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD")); + } + + [Fact] + public void AddZitadel_Uses_Custom_Username_And_Password() + { + var builder = DistributedApplication.CreateBuilder(); + var username = builder.AddParameter("custom-username"); + var password = builder.AddParameter("custom-password"); + + var zitadel = builder.AddZitadel("zitadel", username: username, password: password); + + Assert.Same(username.Resource, zitadel.Resource.AdminUsernameParameter); + Assert.Same(password.Resource, zitadel.Resource.AdminPasswordParameter); + } + + [Fact] + public async Task AddZitadel_Uses_Custom_MasterKey() + { + var builder = DistributedApplication.CreateBuilder(); + var masterKey = builder.AddParameter("custom-masterkey"); + + var zitadel = builder.AddZitadel("zitadel", masterKey: masterKey); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.True(env.ContainsKey("ZITADEL_MASTERKEY")); + } + + [Fact] + public void AddZitadel_Has_HttpEndpoint() + { + var builder = DistributedApplication.CreateBuilder(); + + var zitadel = builder.AddZitadel("zitadel"); + + var endpoint = zitadel.Resource.Annotations.OfType() + .FirstOrDefault(e => e.Name == "http"); + + Assert.NotNull(endpoint); + } + + [Fact] + public void AddZitadel_With_Custom_Port() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddZitadel("zitadel", port: 8888); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var endpoint = resource.Annotations.OfType() + .First(e => e.Name == "http"); + + Assert.Equal(8888, endpoint.Port); + } + + [Fact] + public async Task AddZitadel_Uses_Custom_ExternalDomain() + { + var builder = DistributedApplication.CreateBuilder(); + + var zitadel = builder.AddZitadel("zitadel", externalDomain: "auth.example.com"); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal("auth.example.com", env["ZITADEL_EXTERNALDOMAIN"]); + } + + [Fact] + public async Task WithExternalDomain_Overrides_Default() + { + var builder = DistributedApplication.CreateBuilder(); + + var zitadel = builder.AddZitadel("zitadel") + .WithExternalDomain("custom.domain.com"); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal("custom.domain.com", env["ZITADEL_EXTERNALDOMAIN"]); + } + + [Fact] + public async Task WithExternalDomain_Can_Override_Parameter() + { + var builder = DistributedApplication.CreateBuilder(); + + var zitadel = builder.AddZitadel("zitadel", externalDomain: "first.example.com") + .WithExternalDomain("second.example.com"); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + // WithExternalDomain should override the parameter + Assert.Equal("second.example.com", env["ZITADEL_EXTERNALDOMAIN"]); + } + + [Fact] + public void WithExternalDomain_Throws_If_Null() + { + var builder = DistributedApplication.CreateBuilder(); + var zitadel = builder.AddZitadel("zitadel"); + + var act = () => zitadel.WithExternalDomain(null!); + + Assert.Throws(act); + } + + [Fact] + public void WithExternalDomain_Throws_If_Empty() + { + var builder = DistributedApplication.CreateBuilder(); + var zitadel = builder.AddZitadel("zitadel"); + + var act = () => zitadel.WithExternalDomain(""); + + Assert.Throws(act); + } + + [Fact] + public void WithExternalDomain_Throws_If_Whitespace() + { + var builder = DistributedApplication.CreateBuilder(); + var zitadel = builder.AddZitadel("zitadel"); + + var act = () => zitadel.WithExternalDomain(" "); + + Assert.Throws(act); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelIntegrationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelIntegrationTests.cs new file mode 100644 index 00000000..b5f0db8d --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelIntegrationTests.cs @@ -0,0 +1,80 @@ +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests; + +[RequiresDocker] +public class ZitadelIntegrationTests( + AspireIntegrationTestFixture fixture +) : IClassFixture> +{ + [Fact] + public async Task Zitadel_WithPostgres_Starts_And_HealthReady_Ok() + { + var postgresName = "postgres"; + var zitadelName = "zitadel"; + + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(postgresName) + .WaitAsync(TimeSpan.FromMinutes(3)); + + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(zitadelName) + .WaitAsync(TimeSpan.FromMinutes(5)); + + var httpClient = fixture.CreateHttpClient(zitadelName); + var response = await httpClient.GetAsync("/healthz"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Zitadel_WithPostgres_Env_Is_Applied_And_DbConfig_Is_Valid() + { + var postgresName = "postgres"; + var zitadelName = "zitadel"; + + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(postgresName) + .WaitAsync(TimeSpan.FromMinutes(3)); + + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(zitadelName) + .WaitAsync(TimeSpan.FromMinutes(5)); + + var appModel = fixture.App.Services.GetRequiredService(); + var zitadelResource = appModel.Resources.OfType() + .Single(r => r.Name == zitadelName); + + var env = await zitadelResource.GetEnvironmentVariableValuesAsync(); + + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_HOST")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_PORT")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_DATABASE")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_USERNAME")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_PASSWORD")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD")); + } + + [Fact] + public async Task Zitadel_Admin_Credentials_Are_Set() + { + var zitadelName = "zitadel"; + + await fixture.ResourceNotificationService + .WaitForResourceHealthyAsync(zitadelName) + .WaitAsync(TimeSpan.FromMinutes(5)); + + var appModel = fixture.App.Services.GetRequiredService(); + var zitadelResource = appModel.Resources.OfType() + .Single(r => r.Name == zitadelName); + + var env = await zitadelResource.GetEnvironmentVariableValuesAsync(); + + Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME")); + Assert.True(env.ContainsKey("ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD")); + Assert.NotEmpty(env["ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME"]); + Assert.NotEmpty(env["ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD"]); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelWithDatabaseTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelWithDatabaseTests.cs new file mode 100644 index 00000000..59ca30c4 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/ZitadelWithDatabaseTests.cs @@ -0,0 +1,142 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Zitadel.Tests; + +public class ZitadelWithDatabaseTests +{ + [Fact] + public void WithDatabase_Should_Throw_If_Builder_Is_Null() + { + IResourceBuilder builder = null!; + var app = DistributedApplication.CreateBuilder(); + var pg = app.AddPostgres("postgres"); + + var act = () => builder.WithDatabase(pg); + + var exception = Assert.Throws(act); + } + + [Fact] + public void WithDatabase_Should_Throw_If_Server_Is_Null() + { + var app = DistributedApplication.CreateBuilder(); + var zitadel = app.AddZitadel("zitadel"); + + var act = () => zitadel.WithDatabase((IResourceBuilder)null!); + + var exception = Assert.Throws(act); + } + + [Fact] + public void WithDatabase_Should_Throw_If_Database_Is_Null() + { + var app = DistributedApplication.CreateBuilder(); + var zitadel = app.AddZitadel("zitadel"); + + var act = () => zitadel.WithDatabase((IResourceBuilder)null!); + + var exception = Assert.Throws(act); + } + + [Fact] + public async Task WithDatabase_Sets_Default_Database_Name() + { + var builder = DistributedApplication.CreateBuilder(); + var pg = builder.AddPostgres("postgres"); + var zitadel = builder.AddZitadel("zitadel") + .WithDatabase(pg); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal("zitadel-db", env["ZITADEL_DATABASE_POSTGRES_DATABASE"]); + } + + [Fact] + public async Task WithDatabase_Uses_Custom_Database_Name() + { + var builder = DistributedApplication.CreateBuilder(); + var pg = builder.AddPostgres("postgres"); + var zitadel = builder.AddZitadel("zitadel") + .WithDatabase(pg, "custom-db"); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal("custom-db", env["ZITADEL_DATABASE_POSTGRES_DATABASE"]); + } + + [Fact] + public async Task WithDatabase_Sets_Postgres_Environment_Variables() + { + var builder = DistributedApplication.CreateBuilder(); + var pg = builder.AddPostgres("postgres"); + var db = pg.AddDatabase("zitadel-db"); + + var zitadel = builder.AddZitadel("zitadel") + .WithDatabase(db); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_USERNAME")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_USER_PASSWORD")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_HOST")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_PORT")); + Assert.True(env.ContainsKey("ZITADEL_DATABASE_POSTGRES_DATABASE")); + } + + [Fact] + public async Task WithDatabase_Uses_Server_Parameters() + { + var builder = DistributedApplication.CreateBuilder(); + var pg = builder.AddPostgres("postgres"); + var db = pg.AddDatabase("zitadel-db"); + + var zitadel = builder.AddZitadel("zitadel") + .WithDatabase(db); + + var env = await zitadel.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_USER_USERNAME"]); + Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_USER_PASSWORD"]); + Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME"]); + Assert.NotNull(env["ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD"]); + } + + [Fact] + public void WithDatabase_Creates_WaitFor_Dependency() + { + var app = DistributedApplication.CreateBuilder(); + var pg = app.AddPostgres("postgres"); + var db = pg.AddDatabase("zitadel-db"); + + var zitadel = app.AddZitadel("zitadel") + .WithDatabase(db); + + // The resource should have a WaitFor annotation + var waitForAnnotation = zitadel.Resource.Annotations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(waitForAnnotation); + } + + [Fact] + public void WithDatabase_Creates_Reference() + { + var app = DistributedApplication.CreateBuilder(); + var pg = app.AddPostgres("postgres"); + var db = pg.AddDatabase("zitadel-db"); + + var zitadel = app.AddZitadel("zitadel") + .WithDatabase(db); + + // The resource should have a ResourceReference annotation + var references = zitadel.Resource.Annotations + .OfType() + .ToList(); + + Assert.NotEmpty(references); + } +}