File: NativeModelAlignmentTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Native.Core.Tests\Aspire.Hosting.Native.Core.Tests.csproj (Aspire.Hosting.Native.Core.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json.Nodes;
using Aspire.Hosting.Native.Api;
using Aspire.Hosting.Native.Core.Tests.TestServices;
using Aspire.Hosting.Native.Model;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class NativeModelAlignmentTests
{
    [Fact]
    public async Task EnvironmentAssignmentRetainsNativeIdentityWithoutReadinessDependency()
    {
        var session = new NativeApplicationServer().OpenApplication();
        var composition = session.StartGeneration();
        var first = composition.AddComputeEnvironment("first", "docker-compose/environment");
        var second = composition.AddComputeEnvironment("second", "example/custom-target");
        var workload = composition.AddContainer("api", "nginx:alpine")
            .WithEndpoint(new() { Name = "http", Protocol = "TCP", UriScheme = "http", TargetPort = 80, HostPort = 0, EnvironmentVariable = "" })
            .WithEndpoint(new() { Name = "tcp", Protocol = "TCP", UriScheme = "tcp", TargetPort = 90, HostPort = 0, EnvironmentVariable = "" })
            .WithExternalHttpEndpoints();
        workload.WithComputeEnvironment(first);
        workload.WithComputeEnvironment(second);
        var snapshot = composition.Model.ReadResource(workload.Handle);
        Assert.Equal(second.Handle, snapshot.ComputeEnvironment);
        Assert.Empty(snapshot.Dependencies);
        var exported = JsonNode.Parse(composition.ExportPublishModel())!;
        var api = exported["resources"]!.AsArray().Single(resource => resource!["name"]!.GetValue<string>() == "api")!;
        Assert.Equal("second", api["computeEnvironment"]!.GetValue<string>());
        Assert.Equal("second", api["configuration"]!["publish.computeEnvironment"]!.GetValue<string>());
        Assert.Collection(api["endpoints"]!.AsArray(),
            endpoint => Assert.True(endpoint!["isExternal"]!.GetValue<bool>()),
            endpoint => Assert.False(endpoint!["isExternal"]!.GetValue<bool>()));
        await session.RetireGeneration(composition);
    }
 
    [Fact]
    public async Task EnvironmentAssignmentRejectsCrossCompositionAndForgedConfiguration()
    {
        var server = new NativeApplicationServer();
        var first = server.OpenApplication();
        var second = server.OpenApplication();
        var model = first.StartGeneration();
        var other = second.StartGeneration();
        var api = model.AddContainer("api", "nginx:alpine");
        var invalid = model.AddResource("ordinary", "docker-compose/environment");
        var environment = other.AddComputeEnvironment("compose", "docker-compose/environment");
        Assert.Throws<ArgumentException>(() => api.WithComputeEnvironment(environment));
        Assert.Throws<ArgumentException>(() => api.WithComputeEnvironment(invalid));
        Assert.Throws<ArgumentException>(() => api.WithComputeEnvironment(api));
        Assert.Throws<ArgumentException>(() => api.SetResourceConfiguration(new()
        {
            Properties = [new() { Name = "publish.computeEnvironment", Value = "ordinary" }]
        }));
        await first.RetireGeneration(model);
        await second.RetireGeneration(other);
    }
 
    [Fact]
    public async Task PublishingParametersRemainUnboundAndOnlyNonsecretDefaultsAreExported()
    {
        var session = new NativeApplicationServer().OpenApplication();
        var composition = session.StartGeneration();
        var required = composition.AddConfiguredParameter("required", false, true);
        var password = composition.AddConfiguredParameter("password", true, true);
        var defaults = composition.AddParameterValue("workers", "3", false, true);
        var secret = composition.AddParameterValue("explicit-secret", "must-not-be-exported", true, false);
        Assert.False(required.Value.IsSecret);
        Assert.False(required.Value.HasValue);
        Assert.True(password.Value.IsSecret);
        Assert.False(password.Value.HasValue);
        Assert.Empty(password.Value.SecretRedactions);
        Assert.Throws<SymbolicValueException>(() => required.Value.Resolve(ValueNetworkContext.Host, (_, _, _) => ""));
        Assert.Throws<ArgumentException>(() => composition.AddParameterValue("invalid-secret-default", "not-exported", true, true));
        composition.AddContainer("api", "nginx:alpine")
            .WithEnvironment("WORKERS", defaults)
            .WithEnvironment("REQUIRED", required)
            .WithEnvironment("PASSWORD", password)
            .WithEnvironment("SECRET", secret);
        await Verifier.Verify(JsonNode.Parse(composition.ExportPublishModel())!.ToJsonString(
            new System.Text.Json.JsonSerializerOptions { WriteIndented = true }), "json").UseDirectory("Snapshots");
        await session.RetireGeneration(composition);
    }
 
    [Fact]
    public async Task OrdinaryRunParametersRequireConfigurationRatherThanGeneratingValues()
    {
        var session = new NativeApplicationServer().OpenApplication();
        var composition = session.StartGeneration();
        var name = "missing-" + Guid.NewGuid().ToString("N");
        var exception = Assert.Throws<InvalidOperationException>(() => composition.AddConfiguredParameter(name, true, false));
        Assert.Contains($"ASPIRE_PARAMETER_{name.Replace('-', '_').ToUpperInvariant()}", exception.Message);
        Assert.Empty(composition.Model.Inspect().SecretParameters);
        await session.RetireGeneration(composition);
    }
 
    [Fact]
    public async Task GeneratedParametersRequireExplicitAuthoringAndHonorCharacterRequirements()
    {
        var session = new NativeApplicationServer().OpenApplication();
        var composition = session.StartGeneration();
        var parameter = composition.AddGeneratedParameter("generated",
            """{"minLength":24,"lower":true,"upper":true,"numeric":true,"special":false,"minUpper":2,"minNumeric":2}""", true);
        var value = parameter.Value.Resolve(ValueNetworkContext.Host, (_, _, _) => "");
        Assert.Equal(24, value.Length);
        Assert.All(value, character => Assert.True(char.IsAsciiLetterOrDigit(character)));
        Assert.True(value.Count(char.IsAsciiLetterUpper) >= 2);
        Assert.True(value.Count(char.IsAsciiDigit) >= 2);
        Assert.Contains(value, char.IsAsciiLetterLower);
        Assert.Throws<ArgumentException>(() => composition.AddGeneratedParameter("invalid",
            """{"minLength":1,"minUpper":2}""", true));
        await session.RetireGeneration(composition);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task PublishDeclarationsDoNotRequireTargetPathsButLocalExecutionStillDoes(bool bindMount)
    {
        var executor = new WorkloadTestExecutor();
        var session = new NativeApplicationServer(executor).OpenApplication();
        var composition = session.StartGeneration();
        var missing = Path.Combine(AppContext.BaseDirectory, "missing-" + Guid.NewGuid().ToString("N"));
        if (bindMount)
        {
            composition.AddContainer("api", "nginx:alpine").WithBindMount(missing, "/data", true);
        }
        else
        {
            composition.AddExecutable("tool", Path.Combine(missing, "tool"), missing);
        }
        var exported = JsonNode.Parse(composition.ExportPublishModel())!;
        Assert.Single(exported["resources"]!.AsArray());
        var execution = composition.StartApplication();
        await Assert.ThrowsAsync<ArgumentException>(() => execution.WaitApplicationReady(1000));
        Assert.Empty(executor.Prepared);
        await session.RetireGeneration(composition);
    }
}