File: NativePublishingTests.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;
using System.Text.Json.Nodes;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Native.Api;
using Aspire.Hosting.Native.Core.Tests.TestServices;
using Aspire.Hosting.Native.Model;
using StreamJsonRpc;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class NativePublishingTests
{
    [Fact]
    public async Task PublishProjectionPreservesExpressionsWithoutAllocationsSecretsOrRuntimeIdentities()
    {
        var executor = new WorkloadTestExecutor();
        var server = new NativeApplicationServer(executor);
        var session = server.OpenApplication();
        var composition = session.StartGeneration();
        var password = composition.AddSecretParameter("cache-password", "must-not-be-exported");
        var cache = composition.AddContainer("cache", "redis:7.4-alpine")
            .WithEndpoint(new() { Name = "tcp", TargetPort = 6379, Protocol = "TCP", UriScheme = "redis", HostPort = 0, EnvironmentVariable = "" })
            .WithEnvironment("REDIS_PASSWORD", password)
            .WithEntrypoint("/bin/sh")
            .WithArgument(composition.CreateLiteralValue("-c"))
            .WithArgument(composition.CreateLiteralValue("exec redis-server --requirepass \"$REDIS_PASSWORD\""))
            .WithVolume("cache-data", "/data", false);
        var web = composition.AddExecutable("web", Environment.ProcessPath!, AppContext.BaseDirectory);
        var connection = composition.CreateLiteralValue("redis://:")
            .ConcatValue(password.EncodeUriValue())
            .ConcatValue(composition.CreateLiteralValue("@"))
            .ConcatValue(composition.CreateEndpointValue(cache, "tcp", "hostAndPort"));
        web.WithEnvironment("CACHE_URL", connection);
        web.WithArgument(composition.CreateLiteralValue("server.mjs"));
        web.SetResourceConfiguration(new()
        {
            Properties = [new() { Name = "publish.dockerCompose", Value = """{"build":{"context":"."},"image":"web:latest"}""" }]
        });
        web.WaitFor(cache);
        var document = composition.ExportPublishModel();
        var exported = JsonNode.Parse(document)!;
        var application = exported["resources"]!.AsArray().Single(resource => resource!["name"]!.GetValue<string>() == "web")!;
        Assert.Equal(Environment.ProcessPath, application["executable"]!.GetValue<string>());
        Assert.Equal(AppContext.BaseDirectory, application["workingDirectory"]!.GetValue<string>());
        application["executable"] = "/app/host";
        application["workingDirectory"] = "/app";
        await Verifier.Verify(exported.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), "json").UseDirectory("Snapshots");
        Assert.Empty(executor.Prepared);
        Assert.Empty(executor.Started);
        Assert.Equal(document, composition.ExportPublishModel());
        Assert.Throws<InvalidOperationException>(() => composition.AddContainer("late", "redis"));
        await session.RetireGeneration(composition);
        Assert.Throws<ObjectDisposedException>(composition.ExportPublishModel);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task PublishingUsesExistingCliStreamAndWaitsForStopAcknowledgement(bool failed)
    {
        var server = new NativeApplicationServer();
        await using var host = NativeCliTestHost.Start(server, new(), () => new());
        server.ConfigurePublishing("""{"steps":[{"name":"publish"},{"name":"deploy","dependsOn":["publish"]}]}""");
        server.BeginPublishing("publish");
        Assert.Equal(["publish"], server.ClaimPublishingSteps());
        server.ReportPublishingActivity("publish", failed ? "Publishing failed." : "Artifacts generated.", true, failed);
        server.CompletePublishing(failed);
        Assert.False(server.WaitForPublishingStop().IsCompleted);
        var stream = await host.Rpc.InvokeAsync<IAsyncEnumerable<PublishingActivity>>("GetPublishingActivitiesAsync");
        var activities = new List<PublishingActivity>();
        await foreach (var activity in stream.WithCancellation(TestContext.Current.CancellationToken))
        {
            activities.Add(activity);
        }
        Assert.Collection(activities,
            activity => Assert.Equal(CompletionStates.InProgress, activity.Data.CompletionState),
            activity => Assert.Equal(failed, activity.Data.IsError),
            activity =>
            {
                Assert.Equal(PublishingActivityTypes.PublishComplete, activity.Type);
                Assert.True(activity.Data.IsComplete);
                Assert.Equal(failed, activity.Data.IsError);
            });
        await host.Rpc.InvokeAsync("RequestStopAsync");
        await server.WaitForPublishingStop().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
        Assert.Equal(["baseline.v2", "pipeline-steps.v2"], await host.Rpc.InvokeAsync<string[]>("GetCapabilitiesAsync"));
    }
 
    [Theory]
    [InlineData(null, 2)]
    [InlineData("publish", 1)]
    [InlineData("deploy", 2)]
    public async Task PipelineInspectionWaitsForRegistrationAndFiltersDependencies(string? step, int expectedCount)
    {
        var server = new NativeApplicationServer();
        await using var host = NativeCliTestHost.Start(server, new(), () => new());
        var inspection = host.Rpc.InvokeAsync<GetPipelineStepsResponse>("GetPipelineStepsAsync", new GetPipelineStepsRequest { Step = step });
        server.ConfigurePublishing("""{"steps":[{"name":"publish"},{"name":"deploy","dependsOn":["publish"]}]}""");
        var result = await inspection.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
        Assert.Equal(expectedCount, result.Steps.Length);
        Assert.Equal("publish", result.Steps[0].Name);
        Assert.Empty(result.Steps[0].DependsOn);
        if (expectedCount == 2)
        {
            Assert.Equal("deploy", result.Steps[1].Name);
            Assert.Equal(["publish"], result.Steps[1].DependsOn);
        }
        await Assert.ThrowsAsync<RemoteMethodNotFoundException>(() => host.Rpc.InvokeAsync<GetPipelineStepsResponse>(
            "GetPipelineStepsAsync", new GetPipelineStepsRequest { Step = "unknown" }));
    }
 
    [Fact]
    public void SymbolicProjectionNeverInvokesTheLiteralVisitorForASecret()
    {
        var secret = SymbolicValue.SecretParameter(Guid.NewGuid(), "password", "not-a-literal");
        var projected = secret.UriEncode().Project(
            _ => throw new InvalidOperationException("Secret payload exposed."),
            name => $"parameter:{name}",
            (_, _, _) => throw new InvalidOperationException("Unexpected endpoint."),
            parts => string.Concat(parts),
            value => $"encode({value})");
        Assert.Equal("encode(parameter:password)", projected);
    }
}