diff --git a/eng/Npm.Workspace.nodeproj b/eng/Npm.Workspace.nodeproj
index 58722e78cc3a..bca7304dd870 100644
--- a/eng/Npm.Workspace.nodeproj
+++ b/eng/Npm.Workspace.nodeproj
@@ -36,7 +36,7 @@
-
@@ -58,6 +58,8 @@
+
+
diff --git a/eng/tools/RepoTasks/GenerateTestDevCert.cs b/eng/tools/RepoTasks/GenerateTestDevCert.cs
new file mode 100644
index 000000000000..37a4b33216ed
--- /dev/null
+++ b/eng/tools/RepoTasks/GenerateTestDevCert.cs
@@ -0,0 +1,74 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace RepoTasks;
+
+///
+/// Generates the test HTTPs certificate used by the template tests
+///
+public class GenerateTestDevCert : Task
+{
+ [Required]
+ public string CertificatePath { get; private set; }
+
+ public override bool Execute()
+ {
+ Mutex mutex = null;
+ try
+ {
+ // MSBuild will potentially invoke this task in parallel across different subprocesses/nodes.
+ // The build is configured to generate the certificate in a single location, but multiple projects
+ // import the same targets that will use this task, which will result in multiple calls.
+ // To avoid issues where we try to generate multiple certificates on the same location, we wrap the
+ // usage in a named mutex which guarantees that only one instance will run at a time.
+ mutex = new(initiallyOwned: true, "Global\\GenerateTestDevCert", out var createdNew);
+ if (!createdNew)
+ {
+ // The mutex already exists, wait for it to be released.
+ mutex.WaitOne();
+ }
+
+ if (File.Exists(CertificatePath))
+ {
+ Log.LogMessage(MessageImportance.Normal, $"A test certificate already exists at {CertificatePath}");
+ return true;
+ }
+
+ var cert = DevelopmentCertificate.Create(CertificatePath);
+
+ var devCertJsonFile = Path.ChangeExtension(CertificatePath, ".json");
+ var devCertJson = new CertificateInfo
+ {
+ Password = cert.CertificatePassword,
+ Thumbprint = cert.CertificateThumbprint
+ };
+
+ using var file = File.OpenWrite(devCertJsonFile);
+ file.SetLength(0);
+ JsonSerializer.Serialize(file, devCertJson);
+ }
+ catch (Exception e)
+ {
+ Log.LogErrorFromException(e, showStackTrace: true);
+ }
+ finally
+ {
+ mutex.ReleaseMutex();
+ }
+
+ return !Log.HasLoggedErrors;
+ }
+
+ private class CertificateInfo
+ {
+ public string Password { get; set; }
+ public string Thumbprint { get; set; }
+ }
+}
diff --git a/eng/tools/RepoTasks/RepoTasks.csproj b/eng/tools/RepoTasks/RepoTasks.csproj
index c9fd42558d07..c462a7517ae0 100644
--- a/eng/tools/RepoTasks/RepoTasks.csproj
+++ b/eng/tools/RepoTasks/RepoTasks.csproj
@@ -22,6 +22,15 @@
+
+
+
+
+
+
+
+
+
@@ -46,4 +55,5 @@
$(WiXSdkPath)\Microsoft.Deployment.WindowsInstaller.Package.dll
+
diff --git a/eng/tools/RepoTasks/RepoTasks.tasks b/eng/tools/RepoTasks/RepoTasks.tasks
index c5722a9e266d..b6cd9a820d1e 100644
--- a/eng/tools/RepoTasks/RepoTasks.tasks
+++ b/eng/tools/RepoTasks/RepoTasks.tasks
@@ -10,5 +10,6 @@
+
diff --git a/eng/tools/RepoTasks/shared/CertificateGeneration/DevelopmentCertificate.cs b/eng/tools/RepoTasks/shared/CertificateGeneration/DevelopmentCertificate.cs
new file mode 100644
index 000000000000..a7c826021814
--- /dev/null
+++ b/eng/tools/RepoTasks/shared/CertificateGeneration/DevelopmentCertificate.cs
@@ -0,0 +1,45 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Certificates.Generation;
+
+namespace RepoTasks;
+
+public readonly struct DevelopmentCertificate
+{
+ public DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint)
+ {
+ CertificatePath = certificatePath;
+ CertificatePassword = certificatePassword;
+ CertificateThumbprint = certificateThumbprint;
+ }
+
+ public readonly string CertificatePath { get; }
+ public readonly string CertificatePassword { get; }
+ public readonly string CertificateThumbprint { get; }
+
+ public static DevelopmentCertificate Create(string certificatePath)
+ {
+ var certificatePassword = "";
+ var certificateThumbprint = EnsureDevelopmentCertificates(certificatePath, certificatePassword);
+
+ return new DevelopmentCertificate(certificatePath, certificatePassword, certificateThumbprint);
+ }
+
+ private static string EnsureDevelopmentCertificates(string certificatePath, string certificatePassword)
+ {
+ var now = DateTimeOffset.Now;
+ var manager = CertificateManager.Instance;
+ var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
+ var certificateThumbprint = certificate.Thumbprint;
+ manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
+
+ return certificateThumbprint;
+ }
+}
diff --git a/eng/tools/RepoTasks/shared/CertificateGeneration/Global.cs b/eng/tools/RepoTasks/shared/CertificateGeneration/Global.cs
new file mode 100644
index 000000000000..b9ed6282209f
--- /dev/null
+++ b/eng/tools/RepoTasks/shared/CertificateGeneration/Global.cs
@@ -0,0 +1,6 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+global using System;
+global using System.Collections.Generic;
+global using System.IO;
diff --git a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs
index 080f35c3b3f8..3ee2f8e6eab2 100644
--- a/src/ProjectTemplates/Shared/DevelopmentCertificate.cs
+++ b/src/ProjectTemplates/Shared/DevelopmentCertificate.cs
@@ -1,42 +1,61 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System;
-using System.IO;
-using Microsoft.AspNetCore.Certificates.Generation;
+using System.Reflection;
+using System.Text.Json;
namespace Templates.Test.Helpers;
-public readonly struct DevelopmentCertificate
+public readonly struct DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint)
{
- public DevelopmentCertificate(string certificatePath, string certificatePassword, string certificateThumbprint)
+ public readonly string CertificatePath { get; } = certificatePath;
+ public readonly string CertificatePassword { get; } = certificatePassword;
+ public readonly string CertificateThumbprint { get; } = certificateThumbprint;
+
+ public static DevelopmentCertificate Get(Assembly assembly)
{
- CertificatePath = certificatePath;
- CertificatePassword = certificatePassword;
- CertificateThumbprint = certificateThumbprint;
- }
+ string[] locations = [
+ Path.Combine(AppContext.BaseDirectory, "aspnetcore-https.json"),
+ Path.Combine(Environment.CurrentDirectory, "aspnetcore-https.json"),
+ Path.Combine(AppContext.BaseDirectory, "aspnetcore-https.json"),
+ ];
- public readonly string CertificatePath { get; }
- public readonly string CertificatePassword { get; }
- public readonly string CertificateThumbprint { get; }
+ var json = TryGetExistingFile(locations)
+ ?? throw new InvalidOperationException($"The aspnetcore-https.json file does not exist. Searched locations: {Environment.NewLine}{string.Join(Environment.NewLine, locations)}");
- public static DevelopmentCertificate Create(string workingDirectory)
- {
- var certificatePath = Path.Combine(workingDirectory, $"{Guid.NewGuid()}.pfx");
- var certificatePassword = Guid.NewGuid().ToString();
- var certificateThumbprint = EnsureDevelopmentCertificates(certificatePath, certificatePassword);
+ using var file = File.OpenRead(json);
+ var certificateAttributes = JsonSerializer.Deserialize(file) ??
+ throw new InvalidOperationException($"The aspnetcore-https.json file does not contain valid JSON.");
+
+ var path = Path.ChangeExtension(json, ".pfx");
- return new DevelopmentCertificate(certificatePath, certificatePassword, certificateThumbprint);
+ if (!File.Exists(path))
+ {
+ throw new InvalidOperationException($"The certificate file does not exist. Expected at: '{path}'.");
+ }
+
+ var password = certificateAttributes.Password;
+ var thumbprint = certificateAttributes.Thumbprint;
+
+ return new DevelopmentCertificate(path, password, thumbprint);
}
- private static string EnsureDevelopmentCertificates(string certificatePath, string certificatePassword)
+ private static string TryGetExistingFile(string[] locations)
{
- var now = DateTimeOffset.Now;
- var manager = CertificateManager.Instance;
- var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
- var certificateThumbprint = certificate.Thumbprint;
- manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
+ foreach (var location in locations)
+ {
+ if (File.Exists(location))
+ {
+ return location;
+ }
+ }
+
+ return null;
+ }
- return certificateThumbprint;
+ private sealed class CertificateAttributes
+ {
+ public string Password { get; set; }
+ public string Thumbprint { get; set; }
}
}
diff --git a/src/ProjectTemplates/Shared/Project.cs b/src/ProjectTemplates/Shared/Project.cs
index ed00a32f7f8e..771095b177de 100644
--- a/src/ProjectTemplates/Shared/Project.cs
+++ b/src/ProjectTemplates/Shared/Project.cs
@@ -51,7 +51,7 @@ public static string ArtifactsLogDir
public string TemplateOutputDir { get; set; }
public string TargetFramework { get; set; } = GetAssemblyMetadata("Test.DefaultTargetFramework");
public string RuntimeIdentifier { get; set; } = string.Empty;
- public static DevelopmentCertificate DevCert { get; } = DevelopmentCertificate.Create(AppContext.BaseDirectory);
+ public static DevelopmentCertificate DevCert { get; } = DevelopmentCertificate.Get(typeof(Project).Assembly);
public string TemplateBuildDir => Path.Combine(TemplateOutputDir, "bin", "Debug", TargetFramework, RuntimeIdentifier);
public string TemplatePublishDir => Path.Combine(TemplateOutputDir, "bin", "Release", TargetFramework, RuntimeIdentifier, "publish");
diff --git a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets
index 4e1a98035f07..04f5ec7ede2c 100644
--- a/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets
+++ b/src/ProjectTemplates/TestInfrastructure/PrepareForTest.targets
@@ -22,6 +22,31 @@
+
+
+
+ <_DevCertFileName>aspnetcore-https.pfx
+ <_DevCertJsonFileName>aspnetcore-https.json
+ <_DevCertPath>$(ArtifactsTmpDir)$(_DevCertFileName)
+ <_DevCertJsonPath>$(ArtifactsTmpDir)$(_DevCertJsonFileName)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
$([MSBuild]::NormalizePath('$(OutputPath)$(TestTemplateCreationFolder)'))
diff --git a/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj b/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj
index fb297bfae7c5..a22f7fdea519 100644
--- a/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj
+++ b/src/ProjectTemplates/test/Templates.Tests/Templates.Tests.csproj
@@ -1,4 +1,4 @@
-
+