File: GoGeneratorEscapingTests.cs
Web Access
Project: src\tests\Aspire.Hosting.CodeGeneration.Go.Tests\Aspire.Hosting.CodeGeneration.Go.Tests.csproj (Aspire.Hosting.CodeGeneration.Go.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.TestUtilities;
using Aspire.TypeSystem;
 
namespace Aspire.Hosting.CodeGeneration.Go.Tests;
 
public class GoGeneratorEscapingTests
{
    private const string ResourceTypeId = "Tests/Tests.EscapedResource";
    private const string ChildTypeId = "Tests/Tests.Child\"Quote";
    private const string SettingsTypeId = "Tests/Tests.EscapedSettings";
    private const string StatusTypeId = "Tests/Tests.EscapedStatus";
 
    // Tries to end the comment it is written into and to declare a function after it.
    private const string InjectedText = "Adds Foo.\nfunc init() {}\n// ";
    private const string InjectedStatement = "func init() {}";
 
    public enum EscapedStatus
    {
        Ready
    }
 
    [Fact]
    public async Task GenerateDistributedApplication_WithHostileMetadata_EscapesGoOutput()
    {
        var sdk = new AtsGoCodeGenerator().GenerateDistributedApplication(CreateHostileContext())["aspire.go"];
 
        await Verify(MetadataDriven(sdk), extension: "go")
            .UseFileName("EscapedMetadataGeneratedAspire");
    }
 
    [Fact]
    public void GenerateDistributedApplication_WithHostileMetadata_KeepsInjectedTextOutOfGoCode()
    {
        var sdk = new AtsGoCodeGenerator().GenerateDistributedApplication(CreateHostileContext())["aspire.go"];
 
        // The Go scanner ends a line at CR, LF or the Unicode line and paragraph separators, so a raw one in the
        // output would put the injected text at the start of a line.
        Assert.DoesNotContain('\u2028', sdk);
        Assert.DoesNotContain('\u2029', sdk);
        Assert.DoesNotContain('\r', sdk.Replace("\r\n", "\n", StringComparison.Ordinal));
 
        var lines = sdk.ReplaceLineEndings("\n").Split('\n').Select(line => line.TrimStart()).ToList();
 
        // The injected text must be present for the checks below to prove anything.
        Assert.Contains(lines, line => line.Contains(InjectedStatement, StringComparison.Ordinal));
        Assert.DoesNotContain(lines, line => line.Contains(InjectedStatement, StringComparison.Ordinal) && !line.StartsWith("//", StringComparison.Ordinal));
        Assert.DoesNotContain(lines, line => line.StartsWith("func init", StringComparison.Ordinal) || line.StartsWith("func injected", StringComparison.Ordinal));
    }
 
    [Fact]
    [RequiresTools(["go"])]
    public async Task GeneratedSdk_WithHostileMetadata_PassesGoBuildAndVet()
    {
        var directory = Directory.CreateTempSubdirectory();
        using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5));
        try
        {
            foreach (var (name, content) in new AtsGoCodeGenerator().GenerateDistributedApplication(CreateHostileContext()))
            {
                await File.WriteAllTextAsync(Path.Combine(directory.FullName, name), content, timeout.Token);
            }
 
            var build = await GoDocCommentTests.RunAsync("go", directory.FullName, timeout.Token, "build", "./...");
            Assert.True(build.ExitCode == 0, $"go build failed:{Environment.NewLine}{build.Output}");
 
            var vet = await GoDocCommentTests.RunAsync("go", directory.FullName, timeout.Token, "vet", "./...");
            Assert.True(vet.ExitCode == 0, $"go vet failed:{Environment.NewLine}{vet.Output}");
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    // Everything between the enums and the builder entry point is generated from metadata; the header before it and
    // the builder after it are fixed text.
    private static string MetadataDriven(string sdk)
    {
        var lines = sdk.ReplaceLineEndings("\n").Split('\n');
        var start = Array.IndexOf(lines, "// Enums") - 1;
        var end = Array.IndexOf(lines, "// Builder construction & Build()") - 1;
        return string.Join('\n', lines[start..end]);
    }
 
    private static AtsContext CreateHostileContext()
    {
        var stringType = Primitive(AtsConstants.String);
        var numberType = Primitive(AtsConstants.Number);
        var voidType = Primitive(AtsConstants.Void);
        var resourceType = Handle(ResourceTypeId);
        var tagsType = new AtsTypeRef { TypeId = "Tests/List<string>", Category = AtsTypeCategory.List, ElementType = stringType };
        var unionType = new AtsTypeRef { TypeId = "Tests/Union", Category = AtsTypeCategory.Union, UnionTypes = [stringType, numberType] };
        var callbackType = new AtsTypeRef { TypeId = "Tests/Callback", Category = AtsTypeCategory.Callback };
        var enumType = new AtsTypeRef { TypeId = StatusTypeId, Category = AtsTypeCategory.Enum };
        var lookupType = new AtsTypeRef { TypeId = "Tests/Dict<string,string>", Category = AtsTypeCategory.Dict, KeyType = stringType, ValueType = stringType, IsReadOnly = true };
        var unknownType = new AtsTypeRef { TypeId = "Tests/Unknown", Category = AtsTypeCategory.Unknown };
        AtsCallbackParameterInfo[] callbackParameters = [new() { Name = "con\"text", Type = stringType }];
 
        return new AtsContext
        {
            Capabilities =
            [
                // The description is the only documentation, so it reaches the comment without any XML-doc processing.
                Capability("Tests/addFoo", "addFoo", resourceType, description: InjectedText),
                Capability("Tests/withHostileDocumentation", "withHostileDocumentation", resourceType,
                    documentation: new AtsDocumentationInfo { Summary = InjectedText, Remarks = InjectedText, Returns = InjectedText },
                    parameters: [Parameter("value", stringType, InjectedText)],
                    isObsolete: true, obsoleteMessage: InjectedText, isExperimental: true),
 
                // Fluent capability: required, optional (Options struct) and union parameters.
                Capability("Tests/with\"Quote", "with\"Quote", resourceType,
                    parameters:
                    [
                        Parameter("na\"me", stringType, "The name."),
                        Parameter("un\"ion", unionType, "The union value."),
                        Parameter("op\"tional", stringType, "The optional value.", isOptional: true)
                    ]),
 
                // Capability returning a child handle whose type id is also registered as a handle wrapper.
                Capability("Tests/addChild\"Quote%dId", "add-Child\"X", Handle(ChildTypeId), description: "Adds a child."),
 
                // The unexpected-type error text embeds the id, so a percent sign must not become a format verb.
                Capability("Tests/getExpression\"%d", "getExpression", Handle(AtsConstants.ReferenceExpressionTypeId), description: "Gets an expression."),
                Capability("Tests/getBack\\slash", "getBackslash", stringType, kind: AtsCapabilityKind.InstanceMethod),
 
                // Line and paragraph separators in an id, and a target parameter that needs a map key.
                Capability("Tests/void\r\nfunc injected() {}\u2028func injected() {}\u2029", "reset", voidType, kind: AtsCapabilityKind.InstanceMethod,
                    targetParameterName: "tar\"get",
                    parameters: [Callback("cal\"lback", callbackType, callbackParameters, voidType)]),
                Capability("Tests/tags\"List", "tags", tagsType, kind: AtsCapabilityKind.PropertyGetter, description: "Gets the tags."),
                Capability("Tests/withCancel", "withCancel", resourceType,
                    parameters: [Parameter("cancel\"Token", Primitive(AtsConstants.CancellationToken), isOptional: true)])
            ],
            HandleTypes =
            [
                new AtsTypeInfo { AtsTypeId = ResourceTypeId, Documentation = new AtsDocumentationInfo { Summary = InjectedText } }
            ],
            DtoTypes =
            [
                new AtsDtoTypeInfo
                {
                    TypeId = SettingsTypeId,
                    Name = "EscapedSettings",
                    Properties =
                    [
                        new AtsDtoPropertyInfo { Name = "Bad\"Name", Type = stringType, Documentation = new AtsDocumentationInfo { Summary = InjectedText } },
                        new AtsDtoPropertyInfo { Name = "Back\\slash", Type = numberType },
                        new AtsDtoPropertyInfo { Name = "cal\"lback", Type = callbackType, IsCallback = true, CallbackParameters = callbackParameters, CallbackReturnType = voidType }
                    ]
                }
            ],
            EnumTypes =
            [
                new AtsEnumTypeInfo
                {
                    TypeId = StatusTypeId,
                    Name = "EscapedStatus",
                    ClrType = typeof(EscapedStatus),
                    Values = [nameof(EscapedStatus.Ready)],
                    ValueInfos = [new AtsEnumValueInfo { Name = nameof(EscapedStatus.Ready), Documentation = new AtsDocumentationInfo { Summary = InjectedText } }]
                }
            ],
            ExportedValues =
            [
                new AtsExportedValueInfo
                {
                    OwningAssemblyName = "Tests",
                    PathSegments = ["Defaults\"X", "Pri-mary"],
                    Type = stringType,
                    Value = JsonValue.Create("primary"),
                    Description = InjectedText
                },
 
                // Characters outside the Basic Multilingual Plane (U+1F600 here) are written as UTF-8. A JSON string
                // would spell them as the surrogate pair \uD83D\uDE00, which Go rejects in a string literal. The
                // string, map key, enum and untyped values each take a different path through the renderer.
                ExportedValue(["Emoji", "Greeting"], stringType, JsonValue.Create("hi \U0001F600")),
                ExportedValue(["Emoji", "Lookup"], lookupType, new JsonObject { ["\U0001F600"] = "\U0001F600" }),
                ExportedValue(["Emoji", "Status"], enumType, JsonValue.Create("Ready\U0001F600")),
                ExportedValue(["Emoji", "Anything"], unknownType, JsonValue.Create("\U0001F600"))
            ]
        };
    }
 
    private static AtsExportedValueInfo ExportedValue(IReadOnlyList<string> pathSegments, AtsTypeRef type, JsonNode value) => new()
    {
        OwningAssemblyName = "Tests",
        PathSegments = pathSegments,
        Type = type,
        Value = value
    };
 
    private static AtsTypeRef Primitive(string typeId) => new() { TypeId = typeId, Category = AtsTypeCategory.Primitive };
 
    private static AtsTypeRef Handle(string typeId) => new() { TypeId = typeId, Category = AtsTypeCategory.Handle };
 
    private static AtsParameterInfo Parameter(string name, AtsTypeRef type, string? summary = null, bool isOptional = false) => new()
    {
        Name = name,
        Type = type,
        IsOptional = isOptional,
        Documentation = summary is null ? null : new AtsDocumentationInfo { Summary = summary }
    };
 
    private static AtsParameterInfo Callback(string name, AtsTypeRef type, IReadOnlyList<AtsCallbackParameterInfo> parameters, AtsTypeRef returnType) => new()
    {
        Name = name,
        Type = type,
        IsCallback = true,
        CallbackParameters = parameters,
        CallbackReturnType = returnType
    };
 
    private static AtsCapabilityInfo Capability(
        string capabilityId,
        string methodName,
        AtsTypeRef returnType,
        AtsDocumentationInfo? documentation = null,
        IReadOnlyList<AtsParameterInfo>? parameters = null,
        AtsCapabilityKind kind = AtsCapabilityKind.Method,
        string? description = null,
        bool isObsolete = false,
        string? obsoleteMessage = null,
        bool isExperimental = false,
        string targetParameterName = "builder")
    {
        var resourceType = Handle(ResourceTypeId);
        return new AtsCapabilityInfo
        {
            CapabilityId = capabilityId,
            MethodName = methodName,
            Description = description,
            Documentation = documentation,
            IsObsolete = isObsolete,
            ObsoleteMessage = obsoleteMessage,
            IsExperimental = isExperimental,
            Parameters = [Parameter(targetParameterName, resourceType), .. parameters ?? []],
            ReturnType = returnType,
            TargetTypeId = ResourceTypeId,
            TargetType = resourceType,
            TargetParameterName = targetParameterName,
            ExpandedTargetTypes = [resourceType],
            ReturnsBuilder = returnType.TypeId == ResourceTypeId,
            CapabilityKind = kind
        };
    }
}